Spaces:
Runtime error
Runtime error
File size: 7,299 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | 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 (<T>(_url: string, init: RequestInit, use: (r: Response) => Promise<T> | T): Promise<T> => {
sink.init = init;
return Promise.resolve(use(response));
}) as unknown as typeof withSafeFetch;
}
function cannedResponse(body: string, headers: Record<string, string>, 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<void>(r => (release = r));
const blocking = (<T>(_url: string, _init: RequestInit, use: (r: Response) => Promise<T> | T): Promise<T> =>
gate.then(() => use(cannedResponse('{}', {})))) as unknown as typeof withSafeFetch;
const inflight: Promise<unknown>[] = [];
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']);
});
});
|