diff --git a/packages/blocks/http/jest.config.ts b/packages/blocks/http/jest.config.ts new file mode 100644 index 0000000000..68afe2eafc --- /dev/null +++ b/packages/blocks/http/jest.config.ts @@ -0,0 +1,11 @@ +export default { + displayName: 'blocks-http', + preset: '../../../jest.preset.js', + setupFiles: ['../../../jest.env.js'], + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/packages/blocks/http', +}; diff --git a/packages/blocks/http/project.json b/packages/blocks/http/project.json index b361831a7f..61ba599cbd 100644 --- a/packages/blocks/http/project.json +++ b/packages/blocks/http/project.json @@ -17,6 +17,13 @@ "updateBuildableProjectDepsInPackageJson": true } }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/blocks/http/jest.config.ts" + } + }, "lint": { "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"] diff --git a/packages/blocks/http/src/lib/actions/send-http-request-action.ts b/packages/blocks/http/src/lib/actions/send-http-request-action.ts index 9e14c955bb..c266202928 100644 --- a/packages/blocks/http/src/lib/actions/send-http-request-action.ts +++ b/packages/blocks/http/src/lib/actions/send-http-request-action.ts @@ -10,13 +10,14 @@ import { DynamicPropsValue, Property, } from '@openops/blocks-framework'; -import { validateHostAllowingPublicWebhookUrl } from '@openops/server-shared'; +import { validateHost } from '@openops/server-shared'; import { assertNotNullOrUndefined } from '@openops/shared'; import axios from 'axios'; import FormData from 'form-data'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { httpAuth } from '../common/auth'; import { httpMethodDropdown } from '../common/props'; +import { validateAndRewritePublicWebhookUrl } from '../common/webhook-url-validator'; const toLowerCaseKeys = (obj: HttpHeaders) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])); @@ -170,10 +171,8 @@ export const httpSendRequestAction = createAction({ assertNotNullOrUndefined(method, 'Method'); assertNotNullOrUndefined(url, 'URL'); - await validateHostAllowingPublicWebhookUrl(url); - await validateHostAllowingPublicWebhookUrl( - context.propsValue.proxy_settings?.proxy_host, - ); + const newUrl = await validateAndRewritePublicWebhookUrl(url); + await validateHost(context.propsValue.proxy_settings?.proxy_host); const headersArray = (context.auth?.headers as @@ -193,7 +192,7 @@ export const httpSendRequestAction = createAction({ const request: HttpRequest = { method, - url, + url: newUrl, headers: mergedHeaders, queryParams: (queryParams ?? {}) as QueryParams, timeout: timeout ? timeout * 1000 : 0, diff --git a/packages/blocks/http/src/lib/common/webhook-url-validator.ts b/packages/blocks/http/src/lib/common/webhook-url-validator.ts new file mode 100644 index 0000000000..cca6258b97 --- /dev/null +++ b/packages/blocks/http/src/lib/common/webhook-url-validator.ts @@ -0,0 +1,51 @@ +import { networkUtls, validateHost } from '@openops/server-shared'; + +export async function validateAndRewritePublicWebhookUrl( + userUrl: string, +): Promise { + if (!userUrl) { + return userUrl; + } + + try { + await validateHost(userUrl); + return userUrl; + } catch (error) { + const publicUrl = await networkUtls.getPublicUrl(); + const internalApiUrl = networkUtls.getInternalApiUrl(); + + const publicUrlObj = new URL(publicUrl); + const internalUrlObj = new URL(internalApiUrl); + const userUrlObj = new URL(userUrl); + + if (userUrlObj.origin !== publicUrlObj.origin) { + throw error; + } + + const internalBasePath = internalUrlObj.pathname.replace(/\/$/, ''); + let relativePath = userUrlObj.pathname; + + if ( + internalBasePath && + internalBasePath !== '/' && + relativePath.startsWith(internalBasePath) + ) { + relativePath = relativePath.slice(internalBasePath.length); + } + + if (!relativePath.startsWith('/')) { + relativePath = `/${relativePath}`; + } + + if (!/^\/v1\/webhooks\/[0-9A-Za-z]{21}\/sync$/.test(relativePath)) { + throw error; + } + + const rewrittenPath = `${internalBasePath}${relativePath}`.replace( + /\/{2,}/g, + '/', + ); + + return `${internalUrlObj.origin}${rewrittenPath}`; + } +} diff --git a/packages/blocks/http/test/webhook-url-validator.test.ts b/packages/blocks/http/test/webhook-url-validator.test.ts new file mode 100644 index 0000000000..b99c7b648b --- /dev/null +++ b/packages/blocks/http/test/webhook-url-validator.test.ts @@ -0,0 +1,121 @@ +import { networkUtls, validateHost } from '@openops/server-shared'; +import { validateAndRewritePublicWebhookUrl } from '../src/lib/common/webhook-url-validator'; + +jest.mock('@openops/server-shared', () => ({ + validateHost: jest.fn(), + networkUtls: { + getPublicUrl: jest.fn(), + getInternalApiUrl: jest.fn(), + }, +})); + +describe('webhook-url-validator', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return the original URL if it is empty', async () => { + const result = await validateAndRewritePublicWebhookUrl(''); + expect(result).toBe(''); + }); + + it('should return the original URL if validateHost passes', async () => { + (validateHost as jest.Mock).mockResolvedValue(undefined); + const userUrl = 'https://example.com/webhook'; + const result = await validateAndRewritePublicWebhookUrl(userUrl); + expect(result).toBe(userUrl); + expect(validateHost).toHaveBeenCalledWith(userUrl); + }); + + it('should rewrite the URL if it matches the public URL origin and valid webhook path', async () => { + const error = new Error('Host must not be an internal address'); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://public.openops.com', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000', + ); + + const userUrl = + 'https://public.openops.com/v1/webhooks/123456789012345678901/sync'; + const result = await validateAndRewritePublicWebhookUrl(userUrl); + + expect(result).toBe( + 'http://internal-api:3000/v1/webhooks/123456789012345678901/sync', + ); + }); + + it('should rewrite the URL when public URL has a base path', async () => { + const error = new Error('Host must not be an internal address'); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://openops.com/', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000/api', + ); + + const userUrl = + 'https://openops.com/api/v1/webhooks/123456789012345678901/sync'; + const result = await validateAndRewritePublicWebhookUrl(userUrl); + + expect(result).toBe( + 'http://internal-api:3000/api/v1/webhooks/123456789012345678901/sync', + ); + }); + + it('should throw the original error if origin does not match public URL origin', async () => { + const error = new Error('Host must not be an internal address'); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://public.openops.com', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000', + ); + + const userUrl = + 'https://other-domain.com/v1/webhooks/123456789012345678901/sync'; + + await expect(validateAndRewritePublicWebhookUrl(userUrl)).rejects.toThrow( + error, + ); + }); + + it('should throw the original error if the path does not match the webhook pattern', async () => { + const error = new Error('Host must not be an internal address'); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://public.openops.com', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000', + ); + + const userUrl = 'https://public.openops.com/v1/webhooks/invalid-id/sync'; + + await expect(validateAndRewritePublicWebhookUrl(userUrl)).rejects.toThrow( + error, + ); + }); + + it('should handle multiple slashes correctly during rewrite', async () => { + const error = new Error('Host must not be an internal address'); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://public.openops.com/', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000/', + ); + + const userUrl = + 'https://public.openops.com/v1/webhooks/123456789012345678901/sync'; + const result = await validateAndRewritePublicWebhookUrl(userUrl); + + expect(result).toBe( + 'http://internal-api:3000/v1/webhooks/123456789012345678901/sync', + ); + }); +}); diff --git a/packages/blocks/http/tsconfig.spec.json b/packages/blocks/http/tsconfig.spec.json new file mode 100644 index 0000000000..69a251f328 --- /dev/null +++ b/packages/blocks/http/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/server/shared/src/lib/host-validation/index.ts b/packages/server/shared/src/lib/host-validation/index.ts index aebfe5f351..e7042c3692 100644 --- a/packages/server/shared/src/lib/host-validation/index.ts +++ b/packages/server/shared/src/lib/host-validation/index.ts @@ -1,7 +1,6 @@ import { promises as dns } from 'dns'; import ipRangeCheck from 'ip-range-check'; import { isIPv4, isIPv6 } from 'net'; -import { networkUtls } from '../network-utils'; import { SharedSystemProp, system } from '../system'; const internalV4Cidrs = [ @@ -64,27 +63,3 @@ export async function validateHost(host: string | undefined): Promise { const isPrivate = await isInternalHost(host); if (isPrivate) throw new Error('Host must not be an internal address'); } - -export async function validateHostAllowingPublicWebhookUrl( - url: string | undefined, -): Promise { - if (!url) { - return; - } - - try { - await validateHost(url); - } catch (error) { - const publicUrl = await networkUtls.getPublicUrl(); - - const escapedBase = publicUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - const regex = new RegExp( - `^${escapedBase}v1/webhooks/[0-9a-zA-Z]{21}/sync$`, - ); - - if (!regex.test(url)) { - throw error; - } - } -} diff --git a/packages/server/shared/test/host-validation/index.test.ts b/packages/server/shared/test/host-validation/index.test.ts index 6b4f99f05b..fcd44da29a 100644 --- a/packages/server/shared/test/host-validation/index.test.ts +++ b/packages/server/shared/test/host-validation/index.test.ts @@ -1,7 +1,6 @@ const resolve4 = jest.fn(); const resolve6 = jest.fn(); const getBoolean = jest.fn().mockReturnValue(true); -const getPublicUrl = jest.fn(); jest.mock('dns', () => ({ promises: { resolve4, resolve6 } })); jest.mock('../../src/lib/system', () => ({ @@ -15,14 +14,8 @@ jest.mock('../../src/lib/system', () => ({ }, AppSystemProp: { CLIENT_REAL_IP_HEADER: 'CLIENT_REAL_IP_HEADER' }, })); -jest.mock('../../src/lib/network-utils', () => ({ - networkUtls: { getPublicUrl }, -})); -import { - validateHost, - validateHostAllowingPublicWebhookUrl, -} from '../../src/lib/host-validation'; +import { validateHost } from '../../src/lib/host-validation'; describe('Host Validation', () => { beforeEach(() => { @@ -128,76 +121,4 @@ describe('Host Validation', () => { 'Host must not be an internal address', ); }); - - describe('validateHostAllowingPublicWebhookUrl', () => { - test('should skip for empty url', async () => { - const url = ''; - await expect( - validateHostAllowingPublicWebhookUrl(url), - ).resolves.toBeUndefined(); - }); - - test('should allow public webhook url even if it resolves to private ip', async () => { - const publicUrl = 'https://openops.example.com/'; - const webhookUrl = - 'https://openops.example.com/v1/webhooks/123456789012345678901/sync'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockResolvedValue(['127.0.0.1']); - resolve6.mockResolvedValue([]); - - await expect( - validateHostAllowingPublicWebhookUrl(webhookUrl), - ).resolves.toBeUndefined(); - }); - - test('should throw for internal host that is not the public webhook url', async () => { - const publicUrl = 'https://openops.example.com/'; - const internalUrl = - 'https://10.0.0.1/v1/webhooks/123456789012345678901/sync'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockResolvedValue(['10.0.0.1']); - resolve6.mockResolvedValue([]); - - await expect( - validateHostAllowingPublicWebhookUrl(internalUrl), - ).rejects.toThrow('Host must not be an internal address'); - }); - - test('should throw for public webhook url with invalid id length', async () => { - const publicUrl = 'https://openops.example.com/'; - const webhookUrl = - 'https://openops.example.com/v1/webhooks/too-short/sync'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockResolvedValue(['127.0.0.1']); - resolve6.mockResolvedValue([]); - - await expect( - validateHostAllowingPublicWebhookUrl(webhookUrl), - ).rejects.toThrow('Host must not be an internal address'); - }); - - test('should allow public host', async () => { - const publicUrl = 'https://openops.example.com/'; - const somePublicUrl = 'https://google.com'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockResolvedValue(['8.8.8.8']); - resolve6.mockResolvedValue([]); - - await expect( - validateHostAllowingPublicWebhookUrl(somePublicUrl), - ).resolves.toBeUndefined(); - }); - - test('should throw error for DNS resolution failure on non-webhook URL', async () => { - const publicUrl = 'https://openops.example.com/'; - const unknownUrl = 'https://unknown.example.com'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockRejectedValue(new Error('DNS resolution failed')); - resolve6.mockRejectedValue(new Error('DNS resolution failed')); - - await expect( - validateHostAllowingPublicWebhookUrl(unknownUrl), - ).rejects.toThrow('Failed to resolve host'); - }); - }); });