Skip to content

Commit 803a9f6

Browse files
authored
Allow internal webhook call on the HTTP action on local dev environment (#2213)
Fixes OPS-4101
1 parent 811cf5e commit 803a9f6

2 files changed

Lines changed: 182 additions & 105 deletions

File tree

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,45 @@
11
import { networkUtls, validateHost } from '@openops/server-shared';
22

3+
const WEBHOOK_SYNC_PATH_REGEX = /^\/v1\/webhooks\/[0-9A-Za-z]{21}\/sync$/;
4+
5+
function normalizeBasePath(pathname: string): string {
6+
return pathname.replace(/\/$/, '');
7+
}
8+
9+
function normalizePath(pathname: string): string {
10+
const withLeadingSlash = pathname.startsWith('/') ? pathname : `/${pathname}`;
11+
return withLeadingSlash.replace(/\/{2,}/g, '/');
12+
}
13+
14+
function stripBasePath(pathname: string, basePath: string): string {
15+
if (!basePath || basePath === '/') {
16+
return pathname;
17+
}
18+
19+
if (!pathname.startsWith(basePath)) {
20+
return pathname;
21+
}
22+
23+
return pathname.slice(basePath.length) || '/';
24+
}
25+
26+
function extractWebhookPath(
27+
pathname: string,
28+
publicBasePath: string,
29+
internalBasePath: string,
30+
): string | null {
31+
const candidates = [
32+
normalizePath(stripBasePath(pathname, publicBasePath)),
33+
normalizePath(stripBasePath(pathname, internalBasePath)),
34+
normalizePath(pathname),
35+
];
36+
37+
return (
38+
candidates.find((candidate) => WEBHOOK_SYNC_PATH_REGEX.test(candidate)) ??
39+
null
40+
);
41+
}
42+
343
export async function validateAndRewritePublicWebhookUrl(
444
userUrl: string,
545
): Promise<string> {
@@ -10,42 +50,33 @@ export async function validateAndRewritePublicWebhookUrl(
1050
try {
1151
await validateHost(userUrl);
1252
return userUrl;
13-
} catch (error) {
53+
} catch (originalError) {
1454
const publicUrl = await networkUtls.getPublicUrl();
1555
const internalApiUrl = networkUtls.getInternalApiUrl();
1656

1757
const publicUrlObj = new URL(publicUrl);
1858
const internalUrlObj = new URL(internalApiUrl);
1959
const userUrlObj = new URL(userUrl);
2060

21-
if (userUrlObj.origin !== publicUrlObj.origin) {
22-
throw error;
61+
if (userUrlObj.host !== publicUrlObj.host) {
62+
throw originalError;
2363
}
2464

25-
const internalBasePath = internalUrlObj.pathname.replace(/\/$/, '');
26-
let relativePath = userUrlObj.pathname;
65+
const publicBasePath = normalizeBasePath(publicUrlObj.pathname);
66+
const internalBasePath = normalizeBasePath(internalUrlObj.pathname);
2767

28-
if (
29-
internalBasePath &&
30-
internalBasePath !== '/' &&
31-
relativePath.startsWith(internalBasePath)
32-
) {
33-
relativePath = relativePath.slice(internalBasePath.length);
34-
}
35-
36-
if (!relativePath.startsWith('/')) {
37-
relativePath = `/${relativePath}`;
38-
}
68+
const webhookPath = extractWebhookPath(
69+
userUrlObj.pathname,
70+
publicBasePath,
71+
internalBasePath,
72+
);
3973

40-
if (!/^\/v1\/webhooks\/[0-9A-Za-z]{21}\/sync$/.test(relativePath)) {
41-
throw error;
74+
if (!webhookPath) {
75+
throw originalError;
4276
}
4377

44-
const rewrittenPath = `${internalBasePath}${relativePath}`.replace(
45-
/\/{2,}/g,
46-
'/',
47-
);
78+
const rewrittenPath = normalizePath(`${internalBasePath}${webhookPath}`);
4879

49-
return `${internalUrlObj.origin}${rewrittenPath}`;
80+
return `${internalUrlObj.origin}${rewrittenPath}${userUrlObj.search}${userUrlObj.hash}`;
5081
}
5182
}

packages/blocks/http/test/webhook-url-validator.test.ts

Lines changed: 128 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -27,95 +27,141 @@ describe('webhook-url-validator', () => {
2727
expect(validateHost).toHaveBeenCalledWith(userUrl);
2828
});
2929

30-
it('should rewrite the URL if it matches the public URL origin and valid webhook path', async () => {
31-
const error = new Error('Host must not be an internal address');
32-
(validateHost as jest.Mock).mockRejectedValue(error);
33-
(networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(
30+
test.each([
31+
[
32+
'rewrites when public URL has no base path and internal URL has no base path',
3433
'https://public.openops.com',
35-
);
36-
(networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(
3734
'http://internal-api:3000',
38-
);
39-
40-
const userUrl =
41-
'https://public.openops.com/v1/webhooks/123456789012345678901/sync';
42-
const result = await validateAndRewritePublicWebhookUrl(userUrl);
43-
44-
expect(result).toBe(
35+
'https://public.openops.com/v1/webhooks/123456789012345678901/sync',
4536
'http://internal-api:3000/v1/webhooks/123456789012345678901/sync',
46-
);
47-
});
48-
49-
it('should rewrite the URL when public URL has a base path', async () => {
50-
const error = new Error('Host must not be an internal address');
51-
(validateHost as jest.Mock).mockRejectedValue(error);
52-
(networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(
53-
'https://openops.com/',
54-
);
55-
(networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(
37+
],
38+
[
39+
'rewrites when public URL has a base path and internal URL has no base path',
40+
'http://localhost:4200/api',
41+
'http://127.0.0.1:3000',
42+
'http://localhost:4200/api/v1/webhooks/123456789012345678901/sync',
43+
'http://127.0.0.1:3000/v1/webhooks/123456789012345678901/sync',
44+
],
45+
[
46+
'rewrites when public URL has a base path and internal URL has a different base path',
47+
'https://public.openops.com/api',
48+
'http://internal-api:3000/internal',
49+
'https://public.openops.com/api/v1/webhooks/123456789012345678901/sync',
50+
'http://internal-api:3000/internal/v1/webhooks/123456789012345678901/sync',
51+
],
52+
[
53+
'rewrites when public URL and internal URL share the same base path',
54+
'https://public.openops.com/api',
5655
'http://internal-api:3000/api',
57-
);
58-
59-
const userUrl =
60-
'https://openops.com/api/v1/webhooks/123456789012345678901/sync';
61-
const result = await validateAndRewritePublicWebhookUrl(userUrl);
62-
63-
expect(result).toBe(
56+
'https://public.openops.com/api/v1/webhooks/123456789012345678901/sync',
6457
'http://internal-api:3000/api/v1/webhooks/123456789012345678901/sync',
65-
);
66-
});
67-
68-
it('should throw the original error if origin does not match public URL origin', async () => {
69-
const error = new Error('Host must not be an internal address');
70-
(validateHost as jest.Mock).mockRejectedValue(error);
71-
(networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(
58+
],
59+
[
60+
'rewrites when public URL ends with a trailing slash',
61+
'https://public.openops.com/api/',
62+
'http://internal-api:3000',
63+
'https://public.openops.com/api/v1/webhooks/123456789012345678901/sync',
64+
'http://internal-api:3000/v1/webhooks/123456789012345678901/sync',
65+
],
66+
[
67+
'rewrites when internal URL ends with a trailing slash',
68+
'https://public.openops.com/api',
69+
'http://internal-api:3000/',
70+
'https://public.openops.com/api/v1/webhooks/123456789012345678901/sync',
71+
'http://internal-api:3000/v1/webhooks/123456789012345678901/sync',
72+
],
73+
[
74+
'rewrites when both public and internal URLs end with trailing slashes',
75+
'https://public.openops.com/api/',
76+
'http://internal-api:3000/internal/',
77+
'https://public.openops.com/api/v1/webhooks/123456789012345678901/sync',
78+
'http://internal-api:3000/internal/v1/webhooks/123456789012345678901/sync',
79+
],
80+
[
81+
'rewrites localhost public URL to internal host',
82+
'http://localhost:4200',
83+
'http://internal-api:3000',
84+
'http://localhost:4200/v1/webhooks/123456789012345678901/sync',
85+
'http://internal-api:3000/v1/webhooks/123456789012345678901/sync',
86+
],
87+
[
88+
'rewrites when user URL already contains the internal base path after public host matching',
89+
'https://public.openops.com',
90+
'http://internal-api:3000/api',
91+
'https://public.openops.com/api/v1/webhooks/123456789012345678901/sync',
92+
'http://internal-api:3000/api/v1/webhooks/123456789012345678901/sync',
93+
],
94+
])(
95+
'%s',
96+
async (
97+
_caseName: string,
98+
publicUrl: string,
99+
internalApiUrl: string,
100+
userUrl: string,
101+
expectedUrl: string,
102+
) => {
103+
const originalError = new Error('Host must not be an internal address');
104+
105+
(validateHost as jest.Mock).mockRejectedValue(originalError);
106+
(networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(publicUrl);
107+
(networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(
108+
internalApiUrl,
109+
);
110+
111+
await expect(validateAndRewritePublicWebhookUrl(userUrl)).resolves.toBe(
112+
expectedUrl,
113+
);
114+
115+
expect(validateHost).toHaveBeenCalledWith(userUrl);
116+
expect(networkUtls.getPublicUrl).toHaveBeenCalledTimes(1);
117+
expect(networkUtls.getInternalApiUrl).toHaveBeenCalledTimes(1);
118+
},
119+
);
120+
121+
test.each([
122+
[
123+
'throws when user URL host does not match public URL host',
72124
'https://public.openops.com',
73-
);
74-
(networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(
75125
'http://internal-api:3000',
76-
);
77-
78-
const userUrl =
79-
'https://other-domain.com/v1/webhooks/123456789012345678901/sync';
80-
81-
await expect(validateAndRewritePublicWebhookUrl(userUrl)).rejects.toThrow(
82-
error,
83-
);
84-
});
85-
86-
it('should throw the original error if the path does not match the webhook pattern', async () => {
87-
const error = new Error('Host must not be an internal address');
88-
(validateHost as jest.Mock).mockRejectedValue(error);
89-
(networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(
126+
'https://other.openops.com/v1/webhooks/123456789012345678901/sync',
127+
],
128+
[
129+
'throws when path is not a valid webhook sync path',
90130
'https://public.openops.com',
91-
);
92-
(networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(
93131
'http://internal-api:3000',
94-
);
95-
96-
const userUrl = 'https://public.openops.com/v1/webhooks/invalid-id/sync';
97-
98-
await expect(validateAndRewritePublicWebhookUrl(userUrl)).rejects.toThrow(
99-
error,
100-
);
101-
});
102-
103-
it('should handle multiple slashes correctly during rewrite', async () => {
104-
const error = new Error('Host must not be an internal address');
105-
(validateHost as jest.Mock).mockRejectedValue(error);
106-
(networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(
107-
'https://public.openops.com/',
108-
);
109-
(networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(
110-
'http://internal-api:3000/',
111-
);
112-
113-
const userUrl =
114-
'https://public.openops.com/v1/webhooks/123456789012345678901/sync';
115-
const result = await validateAndRewritePublicWebhookUrl(userUrl);
116-
117-
expect(result).toBe(
118-
'http://internal-api:3000/v1/webhooks/123456789012345678901/sync',
119-
);
120-
});
132+
'https://public.openops.com/v1/webhooks/invalid/sync',
133+
],
134+
[
135+
'throws when path does not match webhook route',
136+
'https://public.openops.com/api',
137+
'http://internal-api:3000',
138+
'https://public.openops.com/api/v1/other/123456789012345678901/sync',
139+
],
140+
[
141+
'throws when webhook path has extra suffix',
142+
'https://public.openops.com',
143+
'http://internal-api:3000',
144+
'https://public.openops.com/v1/webhooks/123456789012345678901/sync/extra',
145+
],
146+
])(
147+
'%s',
148+
async (
149+
_caseName: string,
150+
publicUrl: string,
151+
internalApiUrl: string,
152+
userUrl: string,
153+
) => {
154+
const originalError = new Error('Host must not be an internal address');
155+
156+
(validateHost as jest.Mock).mockRejectedValue(originalError);
157+
(networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(publicUrl);
158+
(networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(
159+
internalApiUrl,
160+
);
161+
162+
await expect(validateAndRewritePublicWebhookUrl(userUrl)).rejects.toBe(
163+
originalError,
164+
);
165+
},
166+
);
121167
});

0 commit comments

Comments
 (0)