diff --git a/backend/src/entities/table-actions/table-actions-module/table-action-activation.service.ts b/backend/src/entities/table-actions/table-actions-module/table-action-activation.service.ts index efdee71b4..0f81d27c1 100644 --- a/backend/src/entities/table-actions/table-actions-module/table-action-activation.service.ts +++ b/backend/src/entities/table-actions/table-actions-module/table-action-activation.service.ts @@ -10,11 +10,12 @@ import { TableActionMethodEnum } from '../../../enums/table-action-method-enum.j import { ConnectionNotFoundException } from '../../../exceptions/custom-exceptions/connection-not-found-exception.js'; import { Messages } from '../../../exceptions/text/messages.js'; import { isSaaS } from '../../../helpers/app/is-saas.js'; +import { Constants } from '../../../helpers/constants/constants.js'; import { Encryptor } from '../../../helpers/encryption/encryptor.js'; import { actionSlackPostMessage } from '../../../helpers/slack/action-slack-post-message.js'; +import { slackPostMessage } from '../../../helpers/slack/slack-post-message.js'; import { isObjectPropertyExists } from '../../../helpers/validators/is-object-property-exists-validator.js'; -// TODO: temporarily disabled SSRF/URL safety check in activateTableAction. Restore import to re-enable. -// import { getSsrfSafeRequestConfig } from '../../../helpers/validators/ssrf-safe-http.js'; +import { getSsrfSafeRequestConfig } from '../../../helpers/validators/ssrf-safe-http.js'; import { ConnectionEntity } from '../../connection/connection.entity.js'; import { EmailService } from '../../email/email/email.service.js'; import { escapeHtml } from '../../email/utils/escape-html.util.js'; @@ -212,9 +213,7 @@ export class TableActionActivationService { let result: AxiosResponse | undefined; try { result = await axios.post(tableAction.url, actionRequestBody, { - // TODO: SSRF/URL safety check temporarily disabled. Restore the line below to re-enable. - // ...getSsrfSafeRequestConfig(), - timeout: 10_000, + ...getSsrfSafeRequestConfig(), headers: { 'Rocketadmin-Signature': autoadminSignatureHeader, 'Content-Type': 'application/json' }, maxRedirects: 0, validateStatus: (status) => status <= 599, @@ -225,6 +224,23 @@ export class TableActionActivationService { console.info('HTTP action result headers', result?.headers); } } catch (error) { + // TODO: temporary diagnostics for the SSRF safety check. A URL blocked by the guard surfaces + // here as a request failure carrying the "SSRF guard" message. Report those to the errors + // channel so we can confirm the guard is not rejecting legitimate action URLs, then remove. + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('SSRF guard')) { + const host = (() => { + try { + return new URL(tableAction.url).host; + } catch { + return tableAction.url; + } + })(); + slackPostMessage( + `[ssrf-check] Table action URL validation failed for host "${host}": ${errorMessage}`, + Constants.EXCEPTIONS_CHANNELS, + ).catch(() => undefined); + } if (axios.isAxiosError(error)) { const errorMessage = result?.data?.error || diff --git a/backend/src/helpers/constants/constants.ts b/backend/src/helpers/constants/constants.ts index 3fccdceb7..d918574e5 100644 --- a/backend/src/helpers/constants/constants.ts +++ b/backend/src/helpers/constants/constants.ts @@ -33,7 +33,11 @@ export const Constants = { '192.168.0.0/16', '127.0.0.0/8', '169.254.0.0/16', + '100.64.0.0/10', + '192.0.0.0/24', + '198.18.0.0/15', '::1/128', + '::/128', 'fd00::/8', 'fe80::/10', ], diff --git a/backend/src/helpers/validators/ssrf-safe-http.ts b/backend/src/helpers/validators/ssrf-safe-http.ts index 8f07ca739..bb34d49ae 100644 --- a/backend/src/helpers/validators/ssrf-safe-http.ts +++ b/backend/src/helpers/validators/ssrf-safe-http.ts @@ -15,16 +15,25 @@ export const ssrfGuardLookup: net.LookupFunction = (hostname, options, callback) 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); + // Keep only the public addresses. We deliberately do NOT reject the whole host just because one of + // its records is private (split-horizon DNS, stray records); we simply never connect to a forbidden + // one. The request is refused only when no usable address remains. + const allowed = addresses.filter(({ address }) => !isForbiddenAddress(address)); + if (allowed.length === 0) { + callback( + new Error(`SSRF guard: refusing to connect to ${hostname}; all resolved addresses are forbidden`), + '', + 0, + ); return; } - const first = addresses[0]; - if (!first) { - callback(new Error(`SSRF guard: no addresses resolved for host ${hostname}`), '', 0); + // Honour the "all" form so Node keeps every allowed address and can fall back across them + // (Happy Eyeballs / IPv6-first hosts on IPv4-only egress). Otherwise return the first allowed one. + if (options.all) { + callback(null, allowed); return; } + const first = allowed[0]; callback(null, first.address, first.family); }); }; 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 2b768730b..3e8d42d70 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 @@ -32,9 +32,31 @@ function lookup(host: string): Promise<{ address: string; family: number }> { }); } +function lookupAll(host: string): Promise> { + return new Promise((resolve, reject) => { + ssrfGuardLookup(host, { all: true }, (err, addresses) => { + if (err) { + reject(err); + } else { + resolve(addresses as Array<{ address: string; family: number }>); + } + }); + }); +} + // --- 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']) { +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', + '100.64.0.1', // carrier-grade NAT + '198.18.0.1', // benchmarking + '192.0.0.1', // IETF protocol assignments +]) { test(`ssrfGuardLookup rejects forbidden address ${host}`, async (t) => { await t.throwsAsync(() => lookup(host), { message: /SSRF guard/ }); }); @@ -50,6 +72,22 @@ test('ssrfGuardLookup resolves a public IPv6 address', async (t) => { t.is(address, '2606:4700:4700::1111'); }); +// --- the "all" form keeps every allowed address so Node can fall back across them ----------------- + +test('ssrfGuardLookup returns the allowed address as an array when all=true', async (t) => { + const addresses = await lookupAll('8.8.8.8'); + t.true(Array.isArray(addresses)); + t.deepEqual( + addresses.map((a) => a.address), + ['8.8.8.8'], + ); +}); + +test('ssrfGuardLookup (all=true) rejects when every resolved address is forbidden', async (t) => { + // localhost resolves to 127.0.0.1 and ::1 — a multi-record host where nothing is usable. + await t.throwsAsync(() => lookupAll('localhost'), { message: /all resolved addresses are forbidden/ }); +}); + // --- gating: pinning config is applied only in SaaS, non-test ---------------------------------- test.serial('getSsrfSafeRequestConfig returns pinning agents + guards in SaaS mode', (t) => {