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']);
  });
});