Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/blocks/http/jest.config.ts
Original file line number Diff line number Diff line change
@@ -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: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/packages/blocks/http',
};
7 changes: 7 additions & 0 deletions packages/blocks/http/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"]
Expand Down
11 changes: 5 additions & 6 deletions packages/blocks/http/src/lib/actions/send-http-request-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions packages/blocks/http/src/lib/common/webhook-url-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { networkUtls, validateHost } from '@openops/server-shared';

export async function validateAndRewritePublicWebhookUrl(
userUrl: string,
): Promise<string> {
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;
}

Comment thread
MarceloRGonc marked this conversation as resolved.
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(

Check warning on line 44 in packages/blocks/http/src/lib/common/webhook-url-validator.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=openops-cloud_openops&issues=AZ1ydnFvsVhMfK0t7-ln&open=AZ1ydnFvsVhMfK0t7-ln&pullRequest=2212
/\/{2,}/g,
'/',
);

return `${internalUrlObj.origin}${rewrittenPath}`;
}
}
121 changes: 121 additions & 0 deletions packages/blocks/http/test/webhook-url-validator.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
14 changes: 14 additions & 0 deletions packages/blocks/http/tsconfig.spec.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
25 changes: 0 additions & 25 deletions packages/server/shared/src/lib/host-validation/index.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -64,27 +63,3 @@ export async function validateHost(host: string | undefined): Promise<void> {
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<void> {
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;
}
}
}
81 changes: 1 addition & 80 deletions packages/server/shared/test/host-validation/index.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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');
});
});
});
Loading