From f0deb274db020925965240fa1852710c046ac58f Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Wed, 3 Jun 2026 12:45:22 +0000 Subject: [PATCH 1/2] feat: implement SSRF guard with request configuration and add tests --- .../connection/utils/is-host-allowed.ts | 9 +-- .../table-action-activation.service.ts | 2 + .../slack/action-slack-post-message.ts | 3 +- .../validators/is-forbidden-address.ts | 7 ++ .../src/helpers/validators/ssrf-safe-http.ts | 45 +++++++++++ .../unit-tests/ssrf-safe-http.test.ts | 75 +++++++++++++++++++ 6 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 backend/src/helpers/validators/is-forbidden-address.ts create mode 100644 backend/src/helpers/validators/ssrf-safe-http.ts create mode 100644 backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts diff --git a/backend/src/entities/connection/utils/is-host-allowed.ts b/backend/src/entities/connection/utils/is-host-allowed.ts index f0b908bb0..9b65ca998 100644 --- a/backend/src/entities/connection/utils/is-host-allowed.ts +++ b/backend/src/entities/connection/utils/is-host-allowed.ts @@ -1,12 +1,14 @@ import { HttpStatus } from '@nestjs/common'; import { HttpException } from '@nestjs/common/exceptions/http.exception.js'; import dns from 'dns'; -import ipRangeCheck from 'ip-range-check'; import { Messages } from '../../../exceptions/text/messages.js'; import { isSaaS } from '../../../helpers/app/is-saas.js'; import { isTest } from '../../../helpers/app/is-test.js'; import { Constants } from '../../../helpers/constants/constants.js'; import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js'; +import { isForbiddenAddress } from '../../../helpers/validators/is-forbidden-address.js'; + +export { isForbiddenAddress }; export interface HostCheckData { type?: string; @@ -45,8 +47,3 @@ export async function isHostAllowed(connectionData: HostCheckData): Promise | undefined; try { result = await axios.post(tableAction.url, actionRequestBody, { + ...getSsrfSafeRequestConfig(), headers: { 'Rocketadmin-Signature': autoadminSignatureHeader, 'Content-Type': 'application/json' }, maxRedirects: 0, validateStatus: (status) => status <= 599, diff --git a/backend/src/helpers/slack/action-slack-post-message.ts b/backend/src/helpers/slack/action-slack-post-message.ts index b946d0e42..11bb9f8f7 100644 --- a/backend/src/helpers/slack/action-slack-post-message.ts +++ b/backend/src/helpers/slack/action-slack-post-message.ts @@ -1,8 +1,9 @@ import axios from 'axios'; +import { getSsrfSafeRequestConfig } from '../validators/ssrf-safe-http.js'; export async function actionSlackPostMessage(message: string, slack_url: string): Promise { if (!slack_url || !message) { return; } - await axios.post(slack_url, { text: message }); + await axios.post(slack_url, { text: message }, getSsrfSafeRequestConfig()); } diff --git a/backend/src/helpers/validators/is-forbidden-address.ts b/backend/src/helpers/validators/is-forbidden-address.ts new file mode 100644 index 000000000..805248a62 --- /dev/null +++ b/backend/src/helpers/validators/is-forbidden-address.ts @@ -0,0 +1,7 @@ +import ipRangeCheck from 'ip-range-check'; +import { Constants } from '../constants/constants.js'; + +export function isForbiddenAddress(address: string): boolean { + const normalized = address.startsWith('::ffff:') && address.includes('.') ? address.slice('::ffff:'.length) : address; + return ipRangeCheck(normalized, Constants.FORBIDDEN_HOSTS); +} diff --git a/backend/src/helpers/validators/ssrf-safe-http.ts b/backend/src/helpers/validators/ssrf-safe-http.ts new file mode 100644 index 000000000..6f03baacd --- /dev/null +++ b/backend/src/helpers/validators/ssrf-safe-http.ts @@ -0,0 +1,45 @@ +import type { AxiosRequestConfig } from 'axios'; +import dns from 'dns'; +import http from 'http'; +import https from 'https'; +import net from 'net'; +import { isSaaS } from '../app/is-saas.js'; +import { isTest } from '../app/is-test.js'; +import { isForbiddenAddress } from './is-forbidden-address.js'; + +const SSRF_REQUEST_TIMEOUT_MS = 10_000; + +export const ssrfGuardLookup: net.LookupFunction = (hostname, options, callback) => { + dns.lookup(hostname, { all: true, family: options.family, hints: options.hints }, (err, addresses) => { + if (err) { + callback(err, '', 0); + return; + } + const forbidden = addresses.find(({ address }) => isForbiddenAddress(address)); + if (forbidden) { + callback(new Error(`SSRF guard: refusing to connect to ${forbidden.address} for host ${hostname}`), '', 0); + return; + } + const first = addresses[0]; + if (!first) { + callback(new Error(`SSRF guard: no addresses resolved for host ${hostname}`), '', 0); + return; + } + callback(null, first.address, first.family); + }); +}; + +const ssrfSafeHttpAgent = new http.Agent({ lookup: ssrfGuardLookup }); +const ssrfSafeHttpsAgent = new https.Agent({ lookup: ssrfGuardLookup }); + +export function getSsrfSafeRequestConfig(): AxiosRequestConfig { + if (!isSaaS() || isTest()) { + return {}; + } + return { + httpAgent: ssrfSafeHttpAgent, + httpsAgent: ssrfSafeHttpsAgent, + maxRedirects: 0, + timeout: SSRF_REQUEST_TIMEOUT_MS, + }; +} diff --git a/backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts b/backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts new file mode 100644 index 000000000..a8d6a769f --- /dev/null +++ b/backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts @@ -0,0 +1,75 @@ +import test from 'ava'; +import { getSsrfSafeRequestConfig, ssrfGuardLookup } from '../../../src/helpers/validators/ssrf-safe-http.js'; + +// The SSRF guard is only meant to engage in SaaS mode (self-hosted owns its network). isSaaS()/ +// isTest() read env live, so we toggle env per test. IP literals are resolved by dns.lookup with +// no network access, so the guard's blocking behaviour is exercised deterministically. +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; +const ORIGINAL_IS_SAAS = process.env.IS_SAAS; + +function setEnv(key: 'NODE_ENV' | 'IS_SAAS', value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +test.afterEach.always(() => { + setEnv('NODE_ENV', ORIGINAL_NODE_ENV); + setEnv('IS_SAAS', ORIGINAL_IS_SAAS); +}); + +function lookup(host: string): Promise<{ address: string; family: number }> { + return new Promise((resolve, reject) => { + ssrfGuardLookup(host, {}, (err, address, family) => { + if (err) { + reject(err); + } else { + resolve({ address: address as string, family: family ?? 0 }); + } + }); + }); +} + +// --- the validating lookup blocks internal targets, allows public ones -------------------------- + +for (const host of ['127.0.0.1', '::1', '169.254.169.254', '10.0.0.5', '192.168.1.1', '::ffff:127.0.0.1']) { + test(`ssrfGuardLookup rejects forbidden address ${host}`, async (t) => { + await t.throwsAsync(() => lookup(host), { message: /SSRF guard/ }); + }); +} + +test('ssrfGuardLookup resolves a public IPv4 address', async (t) => { + const { address } = await lookup('8.8.8.8'); + t.is(address, '8.8.8.8'); +}); + +test('ssrfGuardLookup resolves a public IPv6 address', async (t) => { + const { address } = await lookup('2606:4700:4700::1111'); + t.is(address, '2606:4700:4700::1111'); +}); + +// --- gating: pinning config is applied only in SaaS, non-test ---------------------------------- + +test.serial('getSsrfSafeRequestConfig returns pinning agents + guards in SaaS mode', (t) => { + process.env.NODE_ENV = 'development'; // isTest() => false + process.env.IS_SAAS = '1'; // isSaaS() => true + const config = getSsrfSafeRequestConfig(); + t.truthy(config.httpAgent); + t.truthy(config.httpsAgent); + t.is(config.maxRedirects, 0); + t.is(typeof config.timeout, 'number'); +}); + +test.serial('getSsrfSafeRequestConfig is a no-op for self-hosted (non-SaaS)', (t) => { + process.env.NODE_ENV = 'development'; + delete process.env.IS_SAAS; + t.deepEqual(getSsrfSafeRequestConfig(), {}); +}); + +test.serial('getSsrfSafeRequestConfig is a no-op in test mode', (t) => { + process.env.NODE_ENV = 'test'; + process.env.IS_SAAS = '1'; + t.deepEqual(getSsrfSafeRequestConfig(), {}); +}); From e144d2b3754d060b4bf561165d61d5f47e95e960 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Wed, 3 Jun 2026 12:57:03 +0000 Subject: [PATCH 2/2] refactor: update getSsrfSafeRequestConfig to return appropriate config for non-SaaS environments --- backend/src/helpers/validators/ssrf-safe-http.ts | 16 ++++++++-------- .../ava-tests/unit-tests/ssrf-safe-http.test.ts | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/backend/src/helpers/validators/ssrf-safe-http.ts b/backend/src/helpers/validators/ssrf-safe-http.ts index 6f03baacd..8f07ca739 100644 --- a/backend/src/helpers/validators/ssrf-safe-http.ts +++ b/backend/src/helpers/validators/ssrf-safe-http.ts @@ -33,13 +33,13 @@ const ssrfSafeHttpAgent = new http.Agent({ lookup: ssrfGuardLookup }); const ssrfSafeHttpsAgent = new https.Agent({ lookup: ssrfGuardLookup }); export function getSsrfSafeRequestConfig(): AxiosRequestConfig { - if (!isSaaS() || isTest()) { - return {}; + const config: AxiosRequestConfig = { timeout: SSRF_REQUEST_TIMEOUT_MS }; + + if (isSaaS() && !isTest()) { + config.httpAgent = ssrfSafeHttpAgent; + config.httpsAgent = ssrfSafeHttpsAgent; + config.maxRedirects = 0; } - return { - httpAgent: ssrfSafeHttpAgent, - httpsAgent: ssrfSafeHttpsAgent, - maxRedirects: 0, - timeout: SSRF_REQUEST_TIMEOUT_MS, - }; + + return config; } diff --git a/backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts b/backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts index a8d6a769f..2b768730b 100644 --- a/backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts +++ b/backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts @@ -62,14 +62,22 @@ test.serial('getSsrfSafeRequestConfig returns pinning agents + guards in SaaS mo t.is(typeof config.timeout, 'number'); }); -test.serial('getSsrfSafeRequestConfig is a no-op for self-hosted (non-SaaS)', (t) => { +test.serial('getSsrfSafeRequestConfig applies a timeout but no SSRF pinning for self-hosted (non-SaaS)', (t) => { process.env.NODE_ENV = 'development'; delete process.env.IS_SAAS; - t.deepEqual(getSsrfSafeRequestConfig(), {}); + const config = getSsrfSafeRequestConfig(); + t.is(typeof config.timeout, 'number'); + t.is(config.httpAgent, undefined); + t.is(config.httpsAgent, undefined); + t.is(config.maxRedirects, undefined); }); -test.serial('getSsrfSafeRequestConfig is a no-op in test mode', (t) => { +test.serial('getSsrfSafeRequestConfig applies a timeout but no SSRF pinning in test mode', (t) => { process.env.NODE_ENV = 'test'; process.env.IS_SAAS = '1'; - t.deepEqual(getSsrfSafeRequestConfig(), {}); + const config = getSsrfSafeRequestConfig(); + t.is(typeof config.timeout, 'number'); + t.is(config.httpAgent, undefined); + t.is(config.httpsAgent, undefined); + t.is(config.maxRedirects, undefined); });