import { effectiveNetAllow, isNetHostAllowed, performPluginFetch } from './plugin-net'; import type { withSafeFetch } from '../../common/security/ssrf-guard'; /** A stand-in for withSafeFetch that hands `use` a canned Response and records the init it was given. */ function fakeSafeFetch(response: Response, sink: { init?: RequestInit }): typeof withSafeFetch { return ((_url: string, init: RequestInit, use: (r: Response) => Promise | T): Promise => { sink.init = init; return Promise.resolve(use(response)); }) as unknown as typeof withSafeFetch; } function cannedResponse(body: string, headers: Record, status = 200): Response { const bytes = new TextEncoder().encode(body); let read = false; return { ok: status >= 200 && status < 300, status, statusText: status === 200 ? 'OK' : 'ERR', headers: new Headers(headers), // Minimal ReadableStream-like body: yields the bytes once, then done. body: { getReader: () => ({ read: () => Promise.resolve(read ? { done: true, value: undefined } : ((read = true), { done: false, value: bytes })), cancel: () => Promise.resolve(), }), }, } as unknown as Response; } describe('isNetHostAllowed', () => { it('denies by default (no allowlist)', () => { expect(isNetHostAllowed(undefined, 'https://api.example.com/x')).toBe(false); expect(isNetHostAllowed([], 'https://api.example.com/x')).toBe(false); }); it("'*' allows any public host (the SSRF guard still blocks internal IPs at fetch time)", () => { expect(isNetHostAllowed(['*'], 'https://api.example.com/x')).toBe(true); expect(isNetHostAllowed(['*'], 'http://other.example.org:8080/y')).toBe(true); }); it('matches host:port, defaulting the port from the scheme', () => { expect(isNetHostAllowed(['api.example.com:443'], 'https://api.example.com/x')).toBe(true); expect(isNetHostAllowed(['api.example.com:80'], 'http://api.example.com/x')).toBe(true); expect(isNetHostAllowed(['api.example.com:443'], 'https://api.example.com:8443/x')).toBe(false); }); it('a bare host (no port) allows any port on that host', () => { expect(isNetHostAllowed(['api.example.com'], 'https://api.example.com:8443/x')).toBe(true); expect(isNetHostAllowed(['api.example.com'], 'https://other.example.com/x')).toBe(false); }); it('rejects non-http(s) schemes and unparseable URLs', () => { expect(isNetHostAllowed(['*'], 'ftp://api.example.com/x')).toBe(false); expect(isNetHostAllowed(['*'], 'file:///etc/passwd')).toBe(false); expect(isNetHostAllowed(['*'], 'not a url')).toBe(false); }); }); describe('performPluginFetch', () => { it('routes through the safe-fetch guard and serializes the response', async () => { const sink: { init?: RequestInit } = {}; const fetcher = fakeSafeFetch(cannedResponse('{"hello":"hi"}', { 'content-type': 'application/json' }), sink); const res = await performPluginFetch( 'https://api.example.com/t', { method: 'POST', body: '{}', headers: { 'x-k': 'v' } }, { fetch: fetcher }, ); expect(res.ok).toBe(true); expect(res.status).toBe(200); expect(JSON.parse(res.body)).toEqual({ hello: 'hi' }); expect(res.headers['content-type']).toBe('application/json'); // method/headers/body are passed to the guarded fetch (which does the SSRF pinning). expect(sink.init?.method).toBe('POST'); expect(sink.init?.body).toBe('{}'); }); it('coerces a non-numeric timeoutMs to the default instead of throwing a RangeError', async () => { const sink: { init?: RequestInit } = {}; const fetcher = fakeSafeFetch(cannedResponse('{}', { 'content-type': 'application/json' }), sink); // A string 'abc' would make AbortSignal.timeout(NaN) throw before the request runs; the coercion // must fall back to the default so the documented timeout clamp holds and the fetch proceeds. await expect( performPluginFetch('https://api.example.com/t', { timeoutMs: 'abc' as unknown as number }, { fetch: fetcher }), ).resolves.toMatchObject({ ok: true }); expect(sink.init?.signal).toBeInstanceOf(AbortSignal); }); it('rejects a response whose declared content-length exceeds the cap', async () => { const sink: { init?: RequestInit } = {}; const big = String(11 * 1024 * 1024); const fetcher = fakeSafeFetch(cannedResponse('x', { 'content-length': big }), sink); await expect(performPluginFetch('https://api.example.com/t', {}, { fetch: fetcher })).rejects.toThrow(/cap/i); }); it('rejects once the global concurrent-fetch cap is reached, and recovers after slots free up', async () => { // Each in-flight fetch buffers up to the body cap host-side, so total buffering must stay bounded. // Hold all slots open with a gated fetch, prove the next call rejects fast, then drain and recover. let release!: () => void; const gate = new Promise(r => (release = r)); const blocking = ((_url: string, _init: RequestInit, use: (r: Response) => Promise | T): Promise => gate.then(() => use(cannedResponse('{}', {})))) as unknown as typeof withSafeFetch; const inflight: Promise[] = []; for (let i = 0; i < 16; i++) { inflight.push(performPluginFetch('https://api.example.com/t', {}, { fetch: blocking })); } // The 17th call reserves no slot — it rejects immediately without awaiting the gate. await expect(performPluginFetch('https://api.example.com/t', {}, { fetch: blocking })).rejects.toThrow( /too many concurrent/i, ); release(); await Promise.all(inflight); // Slots freed → a fresh fetch succeeds again. const sink: { init?: RequestInit } = {}; await expect( performPluginFetch('https://api.example.com/t', {}, { fetch: fakeSafeFetch(cannedResponse('{}', {}), sink) }), ).resolves.toMatchObject({ ok: true }); }); }); describe('effectiveNetAllow', () => { it('adds the host of each named config URL to the static allowlist', () => { expect(effectiveNetAllow(['api.static.com'], ['baseUrl'], { baseUrl: 'https://chat.acme.com' })).toEqual([ 'api.static.com', 'chat.acme.com', ]); }); it('ignores missing / non-string / non-https / credentialed config values', () => { expect(effectiveNetAllow([], ['baseUrl'], {})).toEqual([]); expect(effectiveNetAllow([], ['baseUrl'], { baseUrl: 42 })).toEqual([]); expect(effectiveNetAllow([], ['baseUrl'], { baseUrl: 'not a url' })).toEqual([]); expect(effectiveNetAllow([], ['baseUrl'], { baseUrl: 'http://x' })).toEqual([]); // https-only expect(effectiveNetAllow([], ['baseUrl'], { baseUrl: 'https://u:p@x' })).toEqual([]); // no credentials }); it("never admits the '*' wildcard sentinel from a config value, and preserves an explicit port", () => { expect(effectiveNetAllow([], ['baseUrl'], { baseUrl: 'https://*' })).toEqual([]); // bare '*' would open all hosts expect(effectiveNetAllow([], ['baseUrl'], { baseUrl: 'https://%2A' })).toEqual([]); // encoded '*' too expect(effectiveNetAllow([], ['baseUrl'], { baseUrl: 'https://*:443/x' })).toEqual([]); expect(effectiveNetAllow([], ['baseUrl'], { baseUrl: 'https://host.com:8443' })).toEqual(['host.com:8443']); }); });