diff --git a/backend/src/entities/connection/utils/is-host-allowed.ts b/backend/src/entities/connection/utils/is-host-allowed.ts index 1bc77414c..f0b908bb0 100644 --- a/backend/src/entities/connection/utils/is-host-allowed.ts +++ b/backend/src/entities/connection/utils/is-host-allowed.ts @@ -23,32 +23,30 @@ export async function isHostAllowed(connectionData: HostCheckData): Promise((resolve, reject) => { - const testHosts = Constants.getTestConnectionsHostNamesArr(); - if (!connectionData.ssh) { - dns.lookup(connectionData.host ?? '', (err, address) => { - if (err) { - return reject(err); - } - if (ipRangeCheck(address, Constants.FORBIDDEN_HOSTS) && !testHosts.includes(connectionData.host ?? '')) { - resolve(false); - } else { - resolve(true); - } - }); - } else if (connectionData.ssh && connectionData.sshHost) { - dns.lookup(connectionData.sshHost, (err, address) => { - if (err) { - return reject(err); - } - if (ipRangeCheck(address, Constants.FORBIDDEN_HOSTS) && !testHosts.includes(connectionData.host ?? '')) { - resolve(false); - } else { - resolve(true); - } - }); - } + dns.lookup(hostnameToCheck, { all: true }, (err, addresses) => { + if (err) { + return reject(err); + } + const anyForbidden = addresses.some(({ address }) => isForbiddenAddress(address)); + resolve(!anyForbidden); + }); }).catch((_e) => { 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); +} diff --git a/backend/src/helpers/constants/constants.ts b/backend/src/helpers/constants/constants.ts index 75bacacb2..3fccdceb7 100644 --- a/backend/src/helpers/constants/constants.ts +++ b/backend/src/helpers/constants/constants.ts @@ -26,7 +26,17 @@ export type TestConnectionsFromJSON = { export const Constants = { ROCKETADMIN_AUTHENTICATED_COOKIE: 'rocketadmin_authenticated', JWT_COOKIE_KEY_NAME: 'rocketadmin_cookie', - FORBIDDEN_HOSTS: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.0/8', 'fd00::/8'], + FORBIDDEN_HOSTS: [ + '0.0.0.0/8', + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + '127.0.0.0/8', + '169.254.0.0/16', + '::1/128', + 'fd00::/8', + 'fe80::/10', + ], BINARY_DATATYPES: [ 'binary', 'bytea', diff --git a/backend/test/ava-tests/unit-tests/is-host-allowed.test.ts b/backend/test/ava-tests/unit-tests/is-host-allowed.test.ts new file mode 100644 index 000000000..9c80185a4 --- /dev/null +++ b/backend/test/ava-tests/unit-tests/is-host-allowed.test.ts @@ -0,0 +1,109 @@ +import test from 'ava'; +import { isForbiddenAddress, isHostAllowed } from '../../../src/entities/connection/utils/is-host-allowed.js'; + +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; + } +} + +function enableSaaSHostCheck(): void { + process.env.NODE_ENV = 'development'; // isTest() => false + process.env.IS_SAAS = '1'; // isSaaS() => true +} + +test.afterEach.always(() => { + setEnv('NODE_ENV', ORIGINAL_NODE_ENV); + setEnv('IS_SAAS', ORIGINAL_IS_SAAS); +}); + +const FORBIDDEN_ADDRESSES = [ + ['127.0.0.1', 'IPv4 loopback'], + ['::1', 'IPv6 loopback'], + ['169.254.169.254', 'cloud metadata endpoint'], + ['169.254.1.1', 'IPv4 link-local'], + ['10.5.5.5', 'private 10.0.0.0/8'], + ['172.16.0.1', 'private 172.16.0.0/12'], + ['192.168.1.10', 'private 192.168.0.0/16'], + ['0.0.0.0', '"this host"'], + ['fe80::1', 'IPv6 link-local'], + ['fd00::1234', 'IPv6 unique-local'], + ['::ffff:127.0.0.1', 'IPv4-mapped IPv6 loopback (normalized)'], + ['::ffff:169.254.169.254', 'IPv4-mapped IPv6 metadata (normalized)'], +] as const; + +for (const [address, label] of FORBIDDEN_ADDRESSES) { + test(`isForbiddenAddress blocks ${address} (${label})`, (t) => { + t.true(isForbiddenAddress(address)); + }); +} + +const ALLOWED_ADDRESSES = [ + ['8.8.8.8', 'public IPv4 (Google DNS)'], + ['93.184.216.34', 'public IPv4 (example.com)'], + ['1.1.1.1', 'public IPv4 (Cloudflare)'], + ['2606:4700:4700::1111', 'public IPv6 (Cloudflare)'], +] as const; + +for (const [address, label] of ALLOWED_ADDRESSES) { + test(`isForbiddenAddress allows ${address} (${label})`, (t) => { + t.false(isForbiddenAddress(address)); + }); +} + +test.serial('SaaS: blocks IPv4 loopback host', async (t) => { + enableSaaSHostCheck(); + t.false(await isHostAllowed({ type: 'postgres', host: '127.0.0.1' })); +}); + +test.serial('SaaS: blocks IPv6 loopback host', async (t) => { + enableSaaSHostCheck(); + t.false(await isHostAllowed({ type: 'postgres', host: '::1' })); +}); + +test.serial('SaaS: blocks cloud metadata endpoint host', async (t) => { + enableSaaSHostCheck(); + t.false(await isHostAllowed({ type: 'postgres', host: '169.254.169.254' })); +}); + +test.serial('SaaS: blocks private network host', async (t) => { + enableSaaSHostCheck(); + t.false(await isHostAllowed({ type: 'postgres', host: '10.0.0.5' })); +}); + +test.serial('SaaS: allows a public host', async (t) => { + enableSaaSHostCheck(); + t.true(await isHostAllowed({ type: 'postgres', host: '8.8.8.8' })); +}); + +test.serial('SaaS: SSH tunnel to an internal DB host via a public SSH endpoint is allowed', async (t) => { + enableSaaSHostCheck(); + t.true(await isHostAllowed({ type: 'postgres', ssh: true, host: '127.0.0.1', sshHost: '8.8.8.8' })); +}); + +test.serial('SaaS: SSH tunnel through an internal SSH endpoint is blocked', async (t) => { + enableSaaSHostCheck(); + t.false(await isHostAllowed({ type: 'postgres', ssh: true, host: '127.0.0.1', sshHost: '10.0.0.5' })); +}); + +test.serial('SaaS: agent connections bypass the host check', async (t) => { + enableSaaSHostCheck(); + t.true(await isHostAllowed({ type: 'agent_postgres', host: '127.0.0.1' })); +}); + +test.serial('self-hosted (non-SaaS): host check is bypassed even for internal hosts', async (t) => { + process.env.NODE_ENV = 'development'; // isTest() => false + delete process.env.IS_SAAS; // isSaaS() => false + t.true(await isHostAllowed({ type: 'postgres', host: '127.0.0.1' })); +}); + +test.serial('test mode: host check is bypassed even for internal hosts', async (t) => { + process.env.NODE_ENV = 'test'; // isTest() => true + process.env.IS_SAAS = '1'; + t.true(await isHostAllowed({ type: 'postgres', host: '127.0.0.1' })); +}); diff --git a/backend/test/ava-tests/unit-tests/validate-create-connection-data.test.ts b/backend/test/ava-tests/unit-tests/validate-create-connection-data.test.ts new file mode 100644 index 000000000..9382f43ca --- /dev/null +++ b/backend/test/ava-tests/unit-tests/validate-create-connection-data.test.ts @@ -0,0 +1,128 @@ +import { HttpException } from '@nestjs/common'; +import test from 'ava'; +import { CreateConnectionDs } from '../../../src/entities/connection/application/data-structures/create-connection.ds.js'; +import { validateCreateConnectionData } from '../../../src/entities/connection/utils/validate-create-connection-data.js'; +import { Messages } from '../../../src/exceptions/text/messages.js'; + +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 makeData(params: Record): CreateConnectionDs { + return { connection_parameters: params } as unknown as CreateConnectionDs; +} + +const VALID_POSTGRES = { + type: 'postgres', + host: 'db.example.com', + port: 5432, + username: 'user', + password: 'secret', + database: 'mydb', + ssh: false, +}; + +function thrownMessage(error: unknown): string { + const response = (error as HttpException).getResponse(); + return typeof response === 'string' ? response : ((response as { message: string }).message ?? ''); +} + +test('accepts a valid postgres connection', async (t) => { + t.true(await validateCreateConnectionData(makeData({ ...VALID_POSTGRES }))); +}); + +test('accepts a redis connection without username/database', async (t) => { + t.true(await validateCreateConnectionData(makeData({ type: 'redis', host: 'cache.example.com', port: 6379 }))); +}); + +test('accepts an agent connection with only a title (no host required)', async (t) => { + t.true(await validateCreateConnectionData(makeData({ type: 'agent_postgres', title: 'My agent' }))); +}); + +test('rejects a missing connection type', async (t) => { + const error = await t.throwsAsync(() => + validateCreateConnectionData(makeData({ ...VALID_POSTGRES, type: undefined })), + ); + t.true(thrownMessage(error).includes(Messages.TYPE_MISSING)); +}); + +test('rejects a missing host', async (t) => { + const error = await t.throwsAsync(() => + validateCreateConnectionData(makeData({ ...VALID_POSTGRES, host: undefined })), + ); + t.true(thrownMessage(error).includes(Messages.HOST_MISSING)); +}); + +test('rejects a missing username for non-redis connections', async (t) => { + const error = await t.throwsAsync(() => + validateCreateConnectionData(makeData({ ...VALID_POSTGRES, username: undefined })), + ); + t.true(thrownMessage(error).includes(Messages.USERNAME_MISSING)); +}); + +test('rejects a missing database for sql connections', async (t) => { + const error = await t.throwsAsync(() => + validateCreateConnectionData(makeData({ ...VALID_POSTGRES, database: undefined })), + ); + t.true(thrownMessage(error).includes(Messages.DATABASE_MISSING)); +}); + +test('rejects an agent connection without a title', async (t) => { + const error = await t.throwsAsync(() => validateCreateConnectionData(makeData({ type: 'agent_postgres' }))); + t.true(thrownMessage(error).includes('Connection title missing')); +}); + +test('rejects an out-of-range port', async (t) => { + const error = await t.throwsAsync(() => validateCreateConnectionData(makeData({ ...VALID_POSTGRES, port: 70000 }))); + t.true(thrownMessage(error).includes(Messages.PORT_MISSING)); +}); + +test('rejects a non-numeric port', async (t) => { + const error = await t.throwsAsync(() => + validateCreateConnectionData(makeData({ ...VALID_POSTGRES, port: '5432' as unknown as number })), + ); + t.true(thrownMessage(error).includes(Messages.PORT_FORMAT_INCORRECT)); +}); + +test('rejects an ssh connection missing ssh host / port / username', async (t) => { + const error = await t.throwsAsync(() => validateCreateConnectionData(makeData({ ...VALID_POSTGRES, ssh: true }))); + const message = thrownMessage(error); + t.true(message.includes(Messages.SSH_HOST_MISSING)); + t.true(message.includes(Messages.SSH_PORT_MISSING)); + t.true(message.includes(Messages.SSH_USERNAME_MISSING)); +}); + +test('accepts an ssh connection with all ssh parameters', async (t) => { + t.true( + await validateCreateConnectionData( + makeData({ ...VALID_POSTGRES, ssh: true, sshHost: 'bastion.example.com', sshPort: 22, sshUsername: 'tunnel' }), + ), + ); +}); + +test.serial('rejects a malformed hostname when not in test mode', async (t) => { + process.env.NODE_ENV = 'development'; // isTest() => false, so FQDN/IP validation runs + delete process.env.IS_SAAS; // self-hosted => isHostAllowed bypassed, isolating the format check + const error = await t.throwsAsync(() => + validateCreateConnectionData(makeData({ ...VALID_POSTGRES, host: 'not a valid host!!' })), + ); + t.true(thrownMessage(error).includes(Messages.HOST_NAME_INVALID)); +}); + +test.serial('accepts an IP-literal hostname when not in test mode', async (t) => { + process.env.NODE_ENV = 'development'; + delete process.env.IS_SAAS; + t.true(await validateCreateConnectionData(makeData({ ...VALID_POSTGRES, host: '203.0.113.10' }))); +});