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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -212,9 +213,7 @@ export class TableActionActivationService {
let result: AxiosResponse<any, any> | 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,
Expand All @@ -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;
}
})();
Comment on lines +232 to +238
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 ||
Expand Down
4 changes: 4 additions & 0 deletions backend/src/helpers/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand Down
21 changes: 15 additions & 6 deletions backend/src/helpers/validators/ssrf-safe-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
};
Expand Down
40 changes: 39 additions & 1 deletion backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,31 @@ function lookup(host: string): Promise<{ address: string; family: number }> {
});
}

function lookupAll(host: string): Promise<Array<{ address: string; family: number }>> {
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/ });
});
Expand All @@ -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) => {
Expand Down
Loading