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
9 changes: 3 additions & 6 deletions backend/src/entities/connection/utils/is-host-allowed.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -45,8 +47,3 @@ export async function isHostAllowed(connectionData: HostCheckData): Promise<bool
throw new HttpException({ message: Messages.CANNOT_CREATE_CONNECTION_TO_THIS_HOST }, HttpStatus.FORBIDDEN);
});
}

export function isForbiddenAddress(address: string): boolean {
const normalized = address.startsWith('::ffff:') && address.includes('.') ? address.slice('::ffff:'.length) : address;
return ipRangeCheck(normalized, Constants.FORBIDDEN_HOSTS);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { isSaaS } from '../../../helpers/app/is-saas.js';
import { Encryptor } from '../../../helpers/encryption/encryptor.js';
import { actionSlackPostMessage } from '../../../helpers/slack/action-slack-post-message.js';
import { isObjectPropertyExists } from '../../../helpers/validators/is-object-property-exists-validator.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 @@ -214,6 +215,7 @@ export class TableActionActivationService {
let result: AxiosResponse<any, any> | undefined;
try {
result = await axios.post(tableAction.url, actionRequestBody, {
...getSsrfSafeRequestConfig(),
headers: { 'Rocketadmin-Signature': autoadminSignatureHeader, 'Content-Type': 'application/json' },
maxRedirects: 0,
validateStatus: (status) => status <= 599,
Expand Down
3 changes: 2 additions & 1 deletion backend/src/helpers/slack/action-slack-post-message.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (!slack_url || !message) {
return;
}
await axios.post(slack_url, { text: message });
await axios.post(slack_url, { text: message }, getSsrfSafeRequestConfig());
}
7 changes: 7 additions & 0 deletions backend/src/helpers/validators/is-forbidden-address.ts
Original file line number Diff line number Diff line change
@@ -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);
}
45 changes: 45 additions & 0 deletions backend/src/helpers/validators/ssrf-safe-http.ts
Original file line number Diff line number Diff line change
@@ -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 {
const config: AxiosRequestConfig = { timeout: SSRF_REQUEST_TIMEOUT_MS };

if (isSaaS() && !isTest()) {
config.httpAgent = ssrfSafeHttpAgent;
config.httpsAgent = ssrfSafeHttpsAgent;
config.maxRedirects = 0;
}

return config;
}
83 changes: 83 additions & 0 deletions backend/test/ava-tests/unit-tests/ssrf-safe-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import test from 'ava';
import { getSsrfSafeRequestConfig, ssrfGuardLookup } from '../../../src/helpers/validators/ssrf-safe-http.js';
Comment on lines +1 to +2

// 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 applies a timeout but no SSRF pinning for self-hosted (non-SaaS)', (t) => {
process.env.NODE_ENV = 'development';
delete process.env.IS_SAAS;
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 applies a timeout but no SSRF pinning in test mode', (t) => {
process.env.NODE_ENV = 'test';
process.env.IS_SAAS = '1';
const config = getSsrfSafeRequestConfig();
t.is(typeof config.timeout, 'number');
t.is(config.httpAgent, undefined);
t.is(config.httpsAgent, undefined);
t.is(config.maxRedirects, undefined);
});
Loading