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
46 changes: 22 additions & 24 deletions backend/src/entities/connection/utils/is-host-allowed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,30 @@ export async function isHostAllowed(connectionData: HostCheckData): Promise<bool
return true;
}

const hostnameToCheck = connectionData.ssh ? connectionData.sshHost : connectionData.host;
if (!hostnameToCheck) {
return true;
}

const testHosts = Constants.getTestConnectionsHostNamesArr();
if (testHosts.includes(connectionData.host ?? '')) {
return true;
}
Comment on lines +26 to +34

return new Promise<boolean>((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);
}
12 changes: 11 additions & 1 deletion backend/src/helpers/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
109 changes: 109 additions & 0 deletions backend/test/ava-tests/unit-tests/is-host-allowed.test.ts
Original file line number Diff line number Diff line change
@@ -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' }));
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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' })));
});
Loading