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
152 changes: 152 additions & 0 deletions backend/shared/security/__tests__/outboundUrlGuard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Tests for outboundUrlGuard — SSRF protection for server-side fetches.
*
* @see backend/shared/security/outboundUrlGuard.ts
* @see https://github.com/Smartdevs17/SubTrackr/issues/1686
*/

import {
OutboundUrlError,
isPrivateHostname,
validateOutboundUrl,
} from '../outboundUrlGuard';

describe('isPrivateHostname', () => {
it('flags loopback and metadata hostnames', () => {
expect(isPrivateHostname('localhost')).toBe(true);
expect(isPrivateHostname('LOCALHOST')).toBe(true);
expect(isPrivateHostname('metadata.google.internal')).toBe(true);
expect(isPrivateHostname('instance-data')).toBe(true);
});

it('flags internal-looking suffixes', () => {
expect(isPrivateHostname('db.local')).toBe(true);
expect(isPrivateHostname('svc.internal')).toBe(true);
expect(isPrivateHostname('api.localhost')).toBe(true);
});

it('flags loopback and RFC1918 IPv4 literals', () => {
expect(isPrivateHostname('127.0.0.1')).toBe(true);
expect(isPrivateHostname('10.1.2.3')).toBe(true);
expect(isPrivateHostname('172.16.0.1')).toBe(true);
expect(isPrivateHostname('192.168.1.1')).toBe(true);
expect(isPrivateHostname('0.0.0.0')).toBe(true);
expect(isPrivateHostname('255.255.255.255')).toBe(true);
});

it('flags the cloud metadata address specifically', () => {
expect(isPrivateHostname('169.254.169.254')).toBe(true);
});

it('flags CGNAT space', () => {
expect(isPrivateHostname('100.64.0.1')).toBe(true);
});

it('flags private IPv6 literals', () => {
expect(isPrivateHostname('::1')).toBe(true);
expect(isPrivateHostname('fd00::1')).toBe(true);
expect(isPrivateHostname('fe80::1')).toBe(true);
});

it('flags IPv4-mapped IPv6 loopback', () => {
expect(isPrivateHostname('::ffff:127.0.0.1')).toBe(true);
});

it('allows ordinary public hosts', () => {
expect(isPrivateHostname('example.com')).toBe(false);
expect(isPrivateHostname('hooks.zapier.com')).toBe(false);
expect(isPrivateHostname('8.8.8.8')).toBe(false);
expect(isPrivateHostname('172.32.0.1')).toBe(false);
expect(isPrivateHostname('172.15.0.1')).toBe(false);
});

it('treats a malformed dotted quad as unsafe', () => {
expect(isPrivateHostname('999.1.1.1')).toBe(true);
expect(isPrivateHostname('127.0.0')).toBe(true);
});

it('treats an empty host as unsafe', () => {
expect(isPrivateHostname('')).toBe(true);
});
});

describe('validateOutboundUrl', () => {
it('accepts a normal public https URL', () => {
// A real Zapier host, but deliberately *not* a `/hooks/catch/<n>/<n>/`
// path: that shape is indistinguishable from a live Zapier hook URL and
// trips secret detectors in CI (the assertion only needs "public host").
const result = validateOutboundUrl('https://hooks.zapier.com/example-endpoint');
expect(result.protocol).toBe('https:');
expect(result.hostname).toBe('hooks.zapier.com');
});

it('accepts plain http', () => {
expect(validateOutboundUrl('http://example.com/hook').protocol).toBe('http:');
});

it('rejects non-HTTP schemes', () => {
for (const url of [
'file:///etc/passwd',
'gopher://example.com',
'ftp://example.com',
'javascript:alert(1)',
'data:text/plain,hi',
]) {
expect(() => validateOutboundUrl(url)).toThrow(OutboundUrlError);
}
});

it('rejects relative or malformed values', () => {
expect(() => validateOutboundUrl('/relative/path')).toThrow(OutboundUrlError);
expect(() => validateOutboundUrl('not a url')).toThrow(OutboundUrlError);
expect(() => validateOutboundUrl('')).toThrow(OutboundUrlError);
expect(() => validateOutboundUrl(undefined)).toThrow(OutboundUrlError);
expect(() => validateOutboundUrl(null)).toThrow(OutboundUrlError);
expect(() => validateOutboundUrl(42)).toThrow(OutboundUrlError);
});

it('rejects loopback and private targets', () => {
expect(() => validateOutboundUrl('http://localhost/hook')).toThrow(/non-public/);
expect(() => validateOutboundUrl('http://127.0.0.1:8080/hook')).toThrow(/non-public/);
expect(() => validateOutboundUrl('http://169.254.169.254/latest/meta-data/')).toThrow(
/non-public/
);
expect(() => validateOutboundUrl('http://10.0.0.5/admin')).toThrow(/non-public/);
});

it('rejects embedded credentials', () => {
expect(() => validateOutboundUrl('https://user:pass@example.com/')).toThrow(
/embedded credentials/
);
});

it('enforces an allowlist when one is supplied', () => {
expect(
validateOutboundUrl('https://hooks.zapier.com/x', { allowedHosts: ['hooks.zapier.com'] })
.hostname
).toBe('hooks.zapier.com');

// Subdomains of an allowlisted host are permitted.
expect(() =>
validateOutboundUrl('https://eu.hooks.zapier.com/x', { allowedHosts: ['hooks.zapier.com'] })
).not.toThrow();

expect(() =>
validateOutboundUrl('https://evil.example.com/x', { allowedHosts: ['hooks.zapier.com'] })
).toThrow(/allowlist/);
});

it('does not treat a lookalike host as allowlisted', () => {
expect(() =>
validateOutboundUrl('https://hooks.zapier.com.evil.test/x', {
allowedHosts: ['hooks.zapier.com'],
})
).toThrow(/allowlist/);
});

it('still applies the private-address rules when an allowlist is present', () => {
expect(() =>
validateOutboundUrl('http://127.0.0.1/x', { allowedHosts: ['127.0.0.1'] })
).toThrow(OutboundUrlError);
});
});
166 changes: 166 additions & 0 deletions src/services/__tests__/rpcProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Tests for the resilient RPC provider's registry and error surface.
*
* These deliberately avoid exercising real network I/O: the behaviour that
* matters for this change (which errors count against a circuit, URL-swap
* serialisation) is covered by `isTransportFailure` tests in
* `transactionConfirmation.test.ts` and by the backend integration suite.
*
* @see src/services/rpcProvider.ts
* @see https://github.com/Smartdevs17/SubTrackr/issues/1683
*/

import {
AllRpcProvidersFailedError,
RpcProviderCircuitOpenError,
RpcProviderTimeoutError,
clearResilientProviderRegistry,
defaultChainTimeoutMs,
getOrCreateResilientProvider,
isTransportFailure,
} from '../rpcProvider';

const PRIMARY = 'https://primary.example/rpc';
const FALLBACK = 'https://fallback.example/rpc';

describe('defaultChainTimeoutMs', () => {
it('allows more headroom on high-variance L2 chains', () => {
expect(defaultChainTimeoutMs(10)).toBe(15_000);
expect(defaultChainTimeoutMs(137)).toBe(15_000);
expect(defaultChainTimeoutMs(8453)).toBe(15_000);
});

it('uses the tighter default on Ethereum mainnet', () => {
expect(defaultChainTimeoutMs(1)).toBe(10_000);
});

it('falls back to the default for unknown chains', () => {
expect(defaultChainTimeoutMs(999999)).toBe(10_000);
});
});

describe('getOrCreateResilientProvider', () => {
beforeEach(() => {
clearResilientProviderRegistry();
});

afterEach(() => {
clearResilientProviderRegistry();
});

it('returns the same instance for the same chain and URL set', () => {
const first = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]);
const second = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]);
expect(second).toBe(first);
});

it('ignores URL order so circuit state is not silently reset', () => {
const first = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]);
const second = getOrCreateResilientProvider(1, [FALLBACK, PRIMARY]);
expect(second).toBe(first);
});

it('keeps separate instances per chain', () => {
const mainnet = getOrCreateResilientProvider(1, [PRIMARY]);
const polygon = getOrCreateResilientProvider(137, [PRIMARY]);
expect(mainnet).not.toBe(polygon);
});

it('keeps separate instances for different URL sets', () => {
const first = getOrCreateResilientProvider(1, [PRIMARY]);
const second = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]);
expect(first).not.toBe(second);
});

it('starts every circuit closed', () => {
const provider = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]);
const states = provider.getCircuitStates();

expect(states[PRIMARY]).toEqual({ state: 'closed', consecutiveFailures: 0 });
expect(states[FALLBACK]).toEqual({ state: 'closed', consecutiveFailures: 0 });
});

it('resets every circuit on demand', () => {
const provider = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]);
provider.resetAllCircuits();

const states = provider.getCircuitStates();
expect(states[PRIMARY].state).toBe('closed');
expect(states[FALLBACK].state).toBe('closed');
});

it('drops every cached provider when the registry is cleared', () => {
const first = getOrCreateResilientProvider(1, [PRIMARY]);
clearResilientProviderRegistry();
const second = getOrCreateResilientProvider(1, [PRIMARY]);

expect(second).not.toBe(first);
});
});

describe('isTransportFailure', () => {
const withCode = (message: string, code: unknown): Error =>
Object.assign(new Error(message), { code });

it('classifies an explicit timeout as a transport failure', () => {
expect(isTransportFailure(withCode('nope', 'TIMEOUT'))).toBe(true);
});

it('classifies connection-level errors as transport failures', () => {
for (const code of ['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ERR_NETWORK']) {
expect(isTransportFailure(withCode('x', code))).toBe(true);
}
});

it('does not treat a JSON-RPC application error as a transport failure', () => {
// A revert carries a numeric JSON-RPC code. The node answered correctly,
// so counting it as a circuit failure would evict a healthy endpoint.
expect(isTransportFailure(withCode('execution reverted', 3))).toBe(false);
});

it('does not treat insufficient funds as a transport failure', () => {
const nested = Object.assign(new Error('insufficient funds'), {
error: { code: -32000 },
});
expect(isTransportFailure(nested)).toBe(false);
});

it('treats fetch-level errors as transport failures', () => {
expect(isTransportFailure(new TypeError('failed to fetch'))).toBe(true);
});

it('returns false for non-error values', () => {
expect(isTransportFailure(null)).toBe(false);
expect(isTransportFailure(undefined)).toBe(false);
expect(isTransportFailure('nope')).toBe(false);
});
});

describe('provider error types', () => {
it('exposes a stable code and context for timeouts', () => {
const error = new RpcProviderTimeoutError(PRIMARY, 100);
expect(error.code).toBe('RPC_PROVIDER_TIMEOUT');
expect(error.endpointUrl).toBe(PRIMARY);
expect(error.timeoutMs).toBe(100);
expect(error).toBeInstanceOf(Error);
});

it('exposes a stable code and context for an open circuit', () => {
const error = new RpcProviderCircuitOpenError(FALLBACK);
expect(error.code).toBe('RPC_PROVIDER_CIRCUIT_OPEN');
expect(error.endpointUrl).toBe(FALLBACK);
});

it('reports every attempted endpoint when all of them fail', () => {
const error = new AllRpcProvidersFailedError(1, [
{ url: PRIMARY, message: 'failed to fetch' },
{ url: FALLBACK, message: 'timed out' },
]);

expect(error.code).toBe('ALL_RPC_PROVIDERS_FAILED');
expect(error.chainId).toBe(1);
expect(error.errors).toHaveLength(2);
expect(error.message).toContain(PRIMARY);
expect(error.message).toContain(FALLBACK);
});
});
Loading
Loading