126 lines
5.8 KiB
TypeScript
126 lines
5.8 KiB
TypeScript
/**
|
|
* SSRF guard for secret provider hosts (C1).
|
|
*
|
|
* Every REST provider (Vault, Infisical, Doppler, 1Password Connect) makes
|
|
* server-side HTTP requests to a user-supplied host. Without a guard an
|
|
* authenticated user could point a provider at cloud metadata (169.254.169.254)
|
|
* or a loopback service and read the response back through testConnection / bulk
|
|
* pull. assertSafeProviderHost blocks loopback + cloud metadata while allowing
|
|
* ordinary LAN (self-hosted Vault/Infisical/Connect on a private network is a
|
|
* legitimate deployment).
|
|
*/
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { assertSafeProviderHost, sanitizeSelectorPath, parseProviderError } from '../src/lib/server/secretproviders/shared';
|
|
|
|
describe('assertSafeProviderHost', () => {
|
|
test('blocks cloud-metadata address', () => {
|
|
expect(() => assertSafeProviderHost('http://169.254.169.254', 'Vault')).toThrow();
|
|
});
|
|
|
|
test('blocks loopback (localhost + 127.0.0.1)', () => {
|
|
expect(() => assertSafeProviderHost('http://localhost:8200', 'Vault')).toThrow();
|
|
expect(() => assertSafeProviderHost('http://127.0.0.1:9000', 'Doppler')).toThrow();
|
|
});
|
|
|
|
test('allows ordinary LAN host (self-hosted Vault/Connect)', () => {
|
|
expect(() => assertSafeProviderHost('http://192.168.1.50:8200', 'Vault')).not.toThrow();
|
|
expect(() => assertSafeProviderHost('http://10.0.0.5:8080', 'Infisical')).not.toThrow();
|
|
});
|
|
|
|
test('allows public SaaS host (api.doppler.com)', () => {
|
|
expect(() => assertSafeProviderHost('https://api.doppler.com', 'Doppler')).not.toThrow();
|
|
});
|
|
|
|
// IPv4-mapped IPv6 encoding bypass: new URL() normalizes the dotted form to hex
|
|
// (::ffff:169.254.169.254 -> ::ffff:a9fe:a9fe), so a regex on the dotted form is
|
|
// dead. Every mapped form must resolve to the embedded v4 and be blocked.
|
|
test('blocks IPv4-mapped IPv6 forms reaching loopback/metadata', () => {
|
|
for (const host of [
|
|
'http://[::ffff:169.254.169.254]/x', // metadata, dotted (URL -> hex)
|
|
'http://[::ffff:a9fe:a9fe]/x', // metadata, hex
|
|
'http://[::ffff:7f00:1]:8200/x', // loopback, hex
|
|
'http://[::ffff:127.0.0.1]/x', // loopback, dotted
|
|
'http://[0:0:0:0:0:ffff:127.0.0.1]/x', // loopback, expanded
|
|
'http://[::1]/x', // ipv6 loopback
|
|
'http://[::]/x' // unspecified / all-interfaces
|
|
]) {
|
|
expect(() => assertSafeProviderHost(host, 'Vault')).toThrow();
|
|
}
|
|
});
|
|
|
|
test('still allows public IPv6 (not an embedded private v4)', () => {
|
|
expect(() => assertSafeProviderHost('http://[2606:4700:4700::1111]/x', 'Vault')).not.toThrow();
|
|
});
|
|
|
|
test('error message names the provider label and the reason', () => {
|
|
try {
|
|
assertSafeProviderHost('http://169.254.169.254', 'HashiCorp Vault');
|
|
throw new Error('should have thrown');
|
|
} catch (e) {
|
|
const msg = (e as Error).message;
|
|
expect(msg).toContain('HashiCorp Vault');
|
|
expect(msg.toLowerCase()).toContain('metadata');
|
|
}
|
|
});
|
|
});
|
|
|
|
// The Vault KV path is concatenated raw into the API URL; the selector comes from a
|
|
// stack env var an operator controls. sanitizeSelectorPath must reject traversal and
|
|
// encode segments so it can't forge a request to another Vault endpoint.
|
|
describe('sanitizeSelectorPath (selector path injection)', () => {
|
|
test('rejects .. traversal', () => {
|
|
expect(() => sanitizeSelectorPath('../../sys/mounts', 'Vault')).toThrow();
|
|
expect(() => sanitizeSelectorPath('secret/../..', 'Vault')).toThrow();
|
|
});
|
|
test('strips leading slash and keeps a normal path', () => {
|
|
expect(sanitizeSelectorPath('/secret/prod', 'Vault')).toBe('secret/prod');
|
|
});
|
|
test('percent-encodes query/fragment/space characters', () => {
|
|
expect(sanitizeSelectorPath('secret/foo?x=1', 'Vault')).toBe('secret/foo%3Fx%3D1');
|
|
expect(sanitizeSelectorPath('secret/a b', 'Vault')).toBe('secret/a%20b');
|
|
});
|
|
});
|
|
|
|
// testConnection shows a parsed provider error (good UX) but never reflects an
|
|
// arbitrary upstream body: a non-provider host probed via SSRF returns HTML/text that
|
|
// doesn't match a provider error shape, so parseProviderError returns null and only
|
|
// the status is shown.
|
|
describe('parseProviderError (error reflection / SSRF probing)', () => {
|
|
test('extracts a message from known provider error shapes', () => {
|
|
expect(parseProviderError('{"errors":["permission denied"]}')).toBe('permission denied');
|
|
expect(parseProviderError('{"messages":["Invalid Auth token"],"success":false}')).toBe('Invalid Auth token');
|
|
expect(parseProviderError('{"message":"project not found"}')).toBe('project not found');
|
|
});
|
|
test('returns null for anything that is not a provider error shape', () => {
|
|
expect(parseProviderError('<html>502 Bad Gateway</html>')).toBeNull(); // SSRF probe of a LAN service
|
|
expect(parseProviderError('plain text')).toBeNull();
|
|
expect(parseProviderError('{"unrelated":"x"}')).toBeNull();
|
|
expect(parseProviderError('')).toBeNull();
|
|
expect(parseProviderError(null)).toBeNull();
|
|
});
|
|
test('caps message length', () => {
|
|
const long = 'x'.repeat(1000);
|
|
const out = parseProviderError(`{"message":"${long}"}`);
|
|
expect(out!.length).toBeLessThanOrEqual(200);
|
|
});
|
|
});
|
|
|
|
// Guard against a provider silently dropping the wiring: each REST provider's
|
|
// base-URL helper must call assertSafeProviderHost. A source-level check catches a
|
|
// future refactor that forgets one provider (the exact shape of the C1 bug).
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const providerDir = join(here, '..', 'src', 'lib', 'server', 'secretproviders');
|
|
|
|
describe('every REST provider wires the SSRF guard', () => {
|
|
for (const file of ['vault.ts', 'infisical.ts', 'doppler.ts', 'connect.ts']) {
|
|
test(`${file} calls assertSafeProviderHost`, () => {
|
|
const src = readFileSync(join(providerDir, file), 'utf8');
|
|
expect(src).toContain('assertSafeProviderHost(');
|
|
});
|
|
}
|
|
});
|