Skip to content

Commit 3e412e9

Browse files
authored
Add bypass host validation specifically for internal webhook URL (#2195)
Fixes OPS-4071
1 parent e23d68f commit 3e412e9

3 files changed

Lines changed: 119 additions & 6 deletions

File tree

packages/blocks/http/src/lib/actions/send-http-request-action.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
DynamicPropsValue,
1111
Property,
1212
} from '@openops/blocks-framework';
13-
import { validateHost } from '@openops/server-shared';
13+
import { validateHostAllowingPublicWebhookUrl } from '@openops/server-shared';
1414
import { assertNotNullOrUndefined } from '@openops/shared';
1515
import axios from 'axios';
1616
import FormData from 'form-data';
@@ -170,8 +170,10 @@ export const httpSendRequestAction = createAction({
170170
assertNotNullOrUndefined(method, 'Method');
171171
assertNotNullOrUndefined(url, 'URL');
172172

173-
await validateHost(url);
174-
await validateHost(context.propsValue.proxy_settings?.proxy_host);
173+
await validateHostAllowingPublicWebhookUrl(url);
174+
await validateHostAllowingPublicWebhookUrl(
175+
context.propsValue.proxy_settings?.proxy_host,
176+
);
175177

176178
const headersArray =
177179
(context.auth?.headers as

packages/server/shared/src/lib/host-validation/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { promises as dns } from 'dns';
22
import ipRangeCheck from 'ip-range-check';
33
import { isIPv4, isIPv6 } from 'net';
4+
import { networkUtls } from '../network-utils';
45
import { SharedSystemProp, system } from '../system';
56

67
const internalV4Cidrs = [
@@ -63,3 +64,27 @@ export async function validateHost(host: string | undefined): Promise<void> {
6364
const isPrivate = await isInternalHost(host);
6465
if (isPrivate) throw new Error('Host must not be an internal address');
6566
}
67+
68+
export async function validateHostAllowingPublicWebhookUrl(
69+
url: string | undefined,
70+
): Promise<void> {
71+
if (!url) {
72+
return;
73+
}
74+
75+
try {
76+
await validateHost(url);
77+
} catch (error) {
78+
const publicUrl = await networkUtls.getPublicUrl();
79+
80+
const escapedBase = publicUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
81+
82+
const regex = new RegExp(
83+
`^${escapedBase}v1/webhooks/[0-9a-zA-Z]{21}/sync$`,
84+
);
85+
86+
if (!regex.test(url)) {
87+
throw error;
88+
}
89+
}
90+
}

packages/server/shared/test/host-validation/index.test.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
const resolve4 = jest.fn();
22
const resolve6 = jest.fn();
33
const getBoolean = jest.fn().mockReturnValue(true);
4+
const getPublicUrl = jest.fn();
45

56
jest.mock('dns', () => ({ promises: { resolve4, resolve6 } }));
67
jest.mock('../../src/lib/system', () => ({
7-
system: { getBoolean },
8-
SharedSystemProp: { ENABLE_HOST_VALIDATION: 'ENABLE_HOST_VALIDATION' },
8+
system: {
9+
getBoolean,
10+
getOrThrow: jest.fn().mockReturnValue('x-forwarded-for'),
11+
},
12+
SharedSystemProp: {
13+
ENABLE_HOST_VALIDATION: 'ENABLE_HOST_VALIDATION',
14+
FRONTEND_URL: 'FRONTEND_URL',
15+
},
16+
AppSystemProp: { CLIENT_REAL_IP_HEADER: 'CLIENT_REAL_IP_HEADER' },
17+
}));
18+
jest.mock('../../src/lib/network-utils', () => ({
19+
networkUtls: { getPublicUrl },
920
}));
1021

11-
import { validateHost } from '../../src/lib/host-validation';
22+
import {
23+
validateHost,
24+
validateHostAllowingPublicWebhookUrl,
25+
} from '../../src/lib/host-validation';
1226

1327
describe('Host Validation', () => {
1428
beforeEach(() => {
@@ -114,4 +128,76 @@ describe('Host Validation', () => {
114128
'Host must not be an internal address',
115129
);
116130
});
131+
132+
describe('validateHostAllowingPublicWebhookUrl', () => {
133+
test('should skip for empty url', async () => {
134+
const url = '';
135+
await expect(
136+
validateHostAllowingPublicWebhookUrl(url),
137+
).resolves.toBeUndefined();
138+
});
139+
140+
test('should allow public webhook url even if it resolves to private ip', async () => {
141+
const publicUrl = 'https://openops.example.com/';
142+
const webhookUrl =
143+
'https://openops.example.com/v1/webhooks/123456789012345678901/sync';
144+
getPublicUrl.mockResolvedValue(publicUrl);
145+
resolve4.mockResolvedValue(['127.0.0.1']);
146+
resolve6.mockResolvedValue([]);
147+
148+
await expect(
149+
validateHostAllowingPublicWebhookUrl(webhookUrl),
150+
).resolves.toBeUndefined();
151+
});
152+
153+
test('should throw for internal host that is not the public webhook url', async () => {
154+
const publicUrl = 'https://openops.example.com/';
155+
const internalUrl =
156+
'https://10.0.0.1/v1/webhooks/123456789012345678901/sync';
157+
getPublicUrl.mockResolvedValue(publicUrl);
158+
resolve4.mockResolvedValue(['10.0.0.1']);
159+
resolve6.mockResolvedValue([]);
160+
161+
await expect(
162+
validateHostAllowingPublicWebhookUrl(internalUrl),
163+
).rejects.toThrow('Host must not be an internal address');
164+
});
165+
166+
test('should throw for public webhook url with invalid id length', async () => {
167+
const publicUrl = 'https://openops.example.com/';
168+
const webhookUrl =
169+
'https://openops.example.com/v1/webhooks/too-short/sync';
170+
getPublicUrl.mockResolvedValue(publicUrl);
171+
resolve4.mockResolvedValue(['127.0.0.1']);
172+
resolve6.mockResolvedValue([]);
173+
174+
await expect(
175+
validateHostAllowingPublicWebhookUrl(webhookUrl),
176+
).rejects.toThrow('Host must not be an internal address');
177+
});
178+
179+
test('should allow public host', async () => {
180+
const publicUrl = 'https://openops.example.com/';
181+
const somePublicUrl = 'https://google.com';
182+
getPublicUrl.mockResolvedValue(publicUrl);
183+
resolve4.mockResolvedValue(['8.8.8.8']);
184+
resolve6.mockResolvedValue([]);
185+
186+
await expect(
187+
validateHostAllowingPublicWebhookUrl(somePublicUrl),
188+
).resolves.toBeUndefined();
189+
});
190+
191+
test('should throw error for DNS resolution failure on non-webhook URL', async () => {
192+
const publicUrl = 'https://openops.example.com/';
193+
const unknownUrl = 'https://unknown.example.com';
194+
getPublicUrl.mockResolvedValue(publicUrl);
195+
resolve4.mockRejectedValue(new Error('DNS resolution failed'));
196+
resolve6.mockRejectedValue(new Error('DNS resolution failed'));
197+
198+
await expect(
199+
validateHostAllowingPublicWebhookUrl(unknownUrl),
200+
).rejects.toThrow('Failed to resolve host');
201+
});
202+
});
117203
});

0 commit comments

Comments
 (0)