File size: 2,701 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
import { withSafeFetch } from '../../common/security/ssrf-guard';

/** Default cap on a server-side plugin download: 5 MiB (matches the upload limit). */
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
/** Default timeout for a server-side plugin download: 30s. */
const DEFAULT_TIMEOUT_MS = 30_000;

/**
 * Fetch a remote resource (plugin .zip or catalog JSON) as a Buffer, always behind the SSRF guard:
 * every host (the original URL and every redirect hop) is validated before its socket opens and a hop
 * resolving to an internal/reserved address is refused. Redirects ARE followed — public release hosts
 * (e.g. GitHub Releases) legitimately 302 to a CDN — but each hop is re-validated, so following them
 * cannot reach an internal target. The byte cap is enforced while streaming (Content-Length may be
 * absent or wrong) so a hostile or oversized response can't exhaust memory.
 *
 * Operators must add a non-public catalog/release host to `SSRF_ALLOWED_HOSTS`; public hosts
 * (github.com, objects.githubusercontent.com, raw.githubusercontent.com) resolve and pass normally.
 */
export async function fetchSafeBuffer(
  url: string,
  opts: { maxBytes?: number; timeoutMs?: number } = {},
): Promise<Buffer> {
  // Coerce a missing/non-finite/non-positive cap to the default: `??` alone would let a NaN through
  // (e.g. a misparsed env value), which makes every `> maxBytes` guard below inert.
  const maxBytes =
    Number.isFinite(opts.maxBytes) && (opts.maxBytes as number) > 0 ? (opts.maxBytes as number) : DEFAULT_MAX_BYTES;
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;

  return withSafeFetch(
    url,
    { signal: AbortSignal.timeout(timeoutMs) },
    async response => {
      if (!response.ok) {
        throw new Error(`download failed with status ${response.status}`);
      }

      const declaredLength = Number(response.headers.get('content-length') ?? '');
      if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
        throw new Error(`download exceeds the ${maxBytes}-byte limit`);
      }

      const reader = response.body?.getReader();
      if (!reader) {
        throw new Error('download response has no body');
      }

      const chunks: Buffer[] = [];
      let total = 0;
      for (;;) {
        const { done, value } = (await reader.read()) as { done: boolean; value: Uint8Array };
        if (done) break;
        total += value.byteLength;
        if (total > maxBytes) {
          await reader.cancel();
          throw new Error(`download exceeds the ${maxBytes}-byte limit`);
        }
        chunks.push(Buffer.from(value));
      }

      return Buffer.concat(chunks);
    },
    { followRedirects: true },
  );
}