ssrf-lab 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -2
  3. package/dist/adapters/agentClient.d.ts +21 -0
  4. package/dist/adapters/agentClient.js +78 -0
  5. package/dist/adapters/agentClient.js.map +1 -0
  6. package/dist/adapters/checkThenConnect.d.ts +12 -0
  7. package/dist/adapters/checkThenConnect.js +94 -0
  8. package/dist/adapters/checkThenConnect.js.map +1 -0
  9. package/dist/adapters/guardedFetch.d.ts +13 -0
  10. package/dist/adapters/guardedFetch.js +110 -0
  11. package/dist/adapters/guardedFetch.js.map +1 -0
  12. package/dist/adapters/internal.d.ts +30 -0
  13. package/dist/adapters/internal.js +95 -0
  14. package/dist/adapters/internal.js.map +1 -0
  15. package/dist/adapters/libraries.d.ts +14 -0
  16. package/dist/adapters/libraries.js +47 -0
  17. package/dist/adapters/libraries.js.map +1 -0
  18. package/dist/adapters/optional.d.ts +37 -0
  19. package/dist/adapters/optional.js +84 -0
  20. package/dist/adapters/optional.js.map +1 -0
  21. package/dist/adapters/plainFetch.d.ts +9 -0
  22. package/dist/adapters/plainFetch.js +65 -0
  23. package/dist/adapters/plainFetch.js.map +1 -0
  24. package/dist/adapters/types.d.ts +55 -0
  25. package/dist/adapters/types.js +17 -0
  26. package/dist/adapters/types.js.map +1 -0
  27. package/dist/adapters/vercelGuardedFetch.d.ts +7 -0
  28. package/dist/adapters/vercelGuardedFetch.js +65 -0
  29. package/dist/adapters/vercelGuardedFetch.js.map +1 -0
  30. package/dist/checker/probes.d.ts +34 -0
  31. package/dist/checker/probes.js +124 -0
  32. package/dist/checker/probes.js.map +1 -0
  33. package/dist/checker/report.d.ts +5 -0
  34. package/dist/checker/report.js +26 -0
  35. package/dist/checker/report.js.map +1 -0
  36. package/dist/checker/run.d.ts +27 -0
  37. package/dist/checker/run.js +108 -0
  38. package/dist/checker/run.js.map +1 -0
  39. package/dist/cli.d.ts +2 -0
  40. package/dist/cli.js +120 -0
  41. package/dist/cli.js.map +1 -0
  42. package/dist/index.d.ts +16 -0
  43. package/dist/index.js +17 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/range/bomb.d.ts +13 -0
  46. package/dist/range/bomb.js +24 -0
  47. package/dist/range/bomb.js.map +1 -0
  48. package/dist/range/canaries.d.ts +12 -0
  49. package/dist/range/canaries.js +13 -0
  50. package/dist/range/canaries.js.map +1 -0
  51. package/dist/range/isolation.d.ts +4 -0
  52. package/dist/range/isolation.js +24 -0
  53. package/dist/range/isolation.js.map +1 -0
  54. package/dist/range/server.d.ts +51 -0
  55. package/dist/range/server.js +190 -0
  56. package/dist/range/server.js.map +1 -0
  57. package/dist/util/addresses.d.ts +21 -0
  58. package/dist/util/addresses.js +143 -0
  59. package/dist/util/addresses.js.map +1 -0
  60. package/dist/util/flipLookup.d.ts +11 -0
  61. package/dist/util/flipLookup.js +32 -0
  62. package/dist/util/flipLookup.js.map +1 -0
  63. package/package.json +57 -4
@@ -0,0 +1,65 @@
1
+ import { redact } from './internal.js';
2
+ import { loadOptional } from './optional.js';
3
+ import { PolicyRefusal } from './types.js';
4
+ const REFUSALS = {
5
+ hostname_unsafe: 'forbidden-address',
6
+ host_not_allowed: 'forbidden-address',
7
+ redirect_to_unsafe_host: 'forbidden-address',
8
+ protocol_not_allowed: 'scheme-not-allowed',
9
+ too_many_redirects: 'too-many-redirects',
10
+ response_too_large: 'body-too-large',
11
+ timeout: 'deadline',
12
+ };
13
+ /**
14
+ * Use the package's real dispatcher and exported bounded body reader.
15
+ *
16
+ * The package is loaded on the first request, so ssrf-lab runs without it.
17
+ */
18
+ export const vercelGuardedFetch = {
19
+ name: 'guarded-fetch 0.1.5 (guardedFetch + readBodyAsText)',
20
+ // The package has its own guarded lookup, but no injectable base resolver.
21
+ supportsConnectHook: false,
22
+ supportsLoopbackOrigin: false,
23
+ async fetch(url, options = {}) {
24
+ const { createGuardedDispatcher, guardedFetch: upstreamFetch, GuardedFetchError: UpstreamError, readBodyAsText, } = await loadOptional('guarded-fetch', () => import('guarded-fetch'));
25
+ const timeoutMs = options.timeoutMs ?? 5000;
26
+ const deadlineAt = Date.now() + timeoutMs;
27
+ const dispatcher = createGuardedDispatcher();
28
+ try {
29
+ const response = await upstreamFetch(url, { timeoutMs, dispatcher, opaqueErrors: true });
30
+ let bytesRead = 0;
31
+ // Count wire-decoded bytes without supplying a replacement security control.
32
+ const measuredBody = response.body?.pipeThrough(new TransformStream({
33
+ transform(chunk, controller) {
34
+ bytesRead += chunk.byteLength;
35
+ controller.enqueue(chunk);
36
+ },
37
+ })) ?? null;
38
+ const measured = new Response(measuredBody, { status: response.status, headers: response.headers });
39
+ const body = await readBodyAsText(measured, {
40
+ maxResponseBytes: options.maxBytes ?? 1024 * 1024,
41
+ deadlineAt,
42
+ opaqueErrors: true,
43
+ });
44
+ return {
45
+ status: response.status,
46
+ bytesRead,
47
+ bodySample: body.slice(0, 4096),
48
+ hops: [redact(new URL(url)), ...(response.url && response.url !== url ? [redact(new URL(response.url))] : [])],
49
+ stoppedByHarness: false,
50
+ };
51
+ }
52
+ catch (error) {
53
+ if (error instanceof UpstreamError) {
54
+ const reason = REFUSALS[error.code];
55
+ if (reason !== undefined)
56
+ throw new PolicyRefusal(reason, `guarded-fetch: ${error.code}`);
57
+ }
58
+ throw error;
59
+ }
60
+ finally {
61
+ await dispatcher.destroy();
62
+ }
63
+ },
64
+ };
65
+ //# sourceMappingURL=vercelGuardedFetch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vercelGuardedFetch.js","sourceRoot":"","sources":["../../src/adapters/vercelGuardedFetch.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import type { ClientOptions, FetchOutcome } from '../adapters/types.js';
2
+ import { type AttackRange } from '../range/server.js';
3
+ export interface Probe {
4
+ readonly id: string;
5
+ readonly title: string;
6
+ /** What the range does. */
7
+ readonly attack: string;
8
+ /** What a client must do about it. */
9
+ readonly control: string;
10
+ readonly start: (range: AttackRange) => string;
11
+ readonly options?: (range: AttackRange) => ClientOptions;
12
+ /** True when the outcome shows the client went where it should not have. */
13
+ readonly reached: (outcome: FetchOutcome) => boolean;
14
+ /**
15
+ * True when the probe targets the internal service, so the checker can
16
+ * MEASURE whether the attack arrived instead of reading it from an error.
17
+ */
18
+ readonly internalTarget?: boolean;
19
+ /**
20
+ * True when an ordinary error is in fact the client's own limit.
21
+ * The argument is the error and its causes, flattened into 1 line.
22
+ */
23
+ readonly recognizes?: (detail: string) => boolean;
24
+ readonly needsConnectHook?: boolean;
25
+ }
26
+ /**
27
+ * Address spellings that can reach the local internal service.
28
+ *
29
+ * Several normalize to 127.0.0.1; 0.0.0.0 is an unspecified address and
30
+ * IPv4-mapped IPv6 uses a different address family. These probes need no
31
+ * redirect, so they also test guards applied at the first hop.
32
+ */
33
+ export declare const DIRECT_PROBES: readonly Probe[];
34
+ export declare const PROBES: readonly Probe[];
@@ -0,0 +1,124 @@
1
+ import { BOMB_EXPANDED_BYTES } from '../range/bomb.js';
2
+ import { CANARY_INTERNAL_SECRET } from '../range/canaries.js';
3
+ import { INTERNAL_PATH, RANGE_PATHS } from '../range/server.js';
4
+ import { flipLookup } from '../util/flipLookup.js';
5
+ const gotSecret = (outcome) => outcome.bodySample.includes(CANARY_INTERNAL_SECRET);
6
+ /**
7
+ * Address spellings that can reach the local internal service.
8
+ *
9
+ * Several normalize to 127.0.0.1; 0.0.0.0 is an unspecified address and
10
+ * IPv4-mapped IPv6 uses a different address family. These probes need no
11
+ * redirect, so they also test guards applied at the first hop.
12
+ */
13
+ export const DIRECT_PROBES = [
14
+ directProbe('direct-dotted', 'Plain 127.0.0.1', (port) => `http://127.0.0.1:${port}`),
15
+ directProbe('direct-decimal', 'Decimal 2130706433', (port) => `http://2130706433:${port}`),
16
+ directProbe('direct-octal', 'Octal 0177.0.0.1', (port) => `http://0177.0.0.1:${port}`),
17
+ directProbe('direct-hex', 'Hexadecimal 0x7f.0.0.1', (port) => `http://0x7f.0.0.1:${port}`),
18
+ directProbe('direct-zero-host', 'The unspecified address 0.0.0.0', (port) => `http://0.0.0.0:${port}`),
19
+ directProbe('direct-ipv6-mapped', 'IPv4-mapped IPv6 [::ffff:127.0.0.1]', (port) => `http://[::ffff:127.0.0.1]:${port}`),
20
+ directProbe('direct-credentials', 'Credentials in front of the host', (port) => `http://lab:s3cr3t@127.0.0.1:${port}`),
21
+ ];
22
+ function directProbe(id, title, origin) {
23
+ return {
24
+ id,
25
+ title,
26
+ attack: 'one request straight at the internal service',
27
+ control: 'Classify the parsed address, whatever spelling reached the parser.',
28
+ start: (range) => `${origin(range.internalPort)}${INTERNAL_PATH}`,
29
+ internalTarget: true,
30
+ reached: gotSecret,
31
+ };
32
+ }
33
+ export const PROBES = [
34
+ {
35
+ id: 'redirect-loopback',
36
+ title: 'Redirect into loopback',
37
+ attack: '302 to http://127.0.0.1:<range>/internal/secret',
38
+ control: 'Judge every hop, not only the first URL.',
39
+ start: (range) => range.url(RANGE_PATHS.redirectLoopback),
40
+ internalTarget: true,
41
+ reached: gotSecret,
42
+ },
43
+ {
44
+ id: 'redirect-decimal-loopback',
45
+ title: 'Redirect into loopback, written as a decimal',
46
+ attack: '302 to http://2130706433:<range>/internal/secret',
47
+ control: 'Classify the parsed host, never the raw string.',
48
+ start: (range) => range.url(RANGE_PATHS.redirectDecimalLoopback),
49
+ internalTarget: true,
50
+ reached: gotSecret,
51
+ },
52
+ {
53
+ id: 'redirect-credentials',
54
+ title: 'Redirect that carries credentials',
55
+ attack: '302 to http://lab:s3cr3t@127.0.0.1:<range>/internal/secret',
56
+ control: 'Refuse the address, and never echo the credentials.',
57
+ start: (range) => range.url(RANGE_PATHS.redirectCredentials),
58
+ internalTarget: true,
59
+ reached: gotSecret,
60
+ },
61
+ {
62
+ id: 'redirect-link-local',
63
+ title: 'Redirect into cloud metadata',
64
+ attack: '302 to http://169.254.169.254/latest/meta-data/',
65
+ control: 'Refuse link-local addresses.',
66
+ start: (range) => range.url(RANGE_PATHS.redirectLinkLocal),
67
+ reached: (outcome) => outcome.status > 0,
68
+ },
69
+ {
70
+ // Keep the ID stable for consumers of existing JSON reports.
71
+ id: 'redirect-private',
72
+ title: 'Redirect into TEST-NET-2 (reserved)',
73
+ attack: '302 to http://198.51.100.1/',
74
+ control: 'Refuse reserved documentation addresses; this is not an RFC 1918 probe.',
75
+ start: (range) => range.url(RANGE_PATHS.redirectPrivate),
76
+ reached: (outcome) => outcome.status > 0,
77
+ },
78
+ {
79
+ id: 'redirect-loop',
80
+ title: 'Endless redirect chain',
81
+ attack: '302 to itself, for ever',
82
+ control: 'Cap the number of hops.',
83
+ start: (range) => range.url(RANGE_PATHS.redirectLoop),
84
+ reached: (outcome) => outcome.status > 0 && outcome.hops.length > 10,
85
+ recognizes: (detail) => /redirect/i.test(detail),
86
+ },
87
+ {
88
+ id: 'decompression-bomb',
89
+ title: 'Decompression bomb',
90
+ attack: 'about 64 KiB on the wire, 64 MiB once expanded',
91
+ control: 'Count bytes AFTER decompression, while reading.',
92
+ start: (range) => range.url(RANGE_PATHS.payloadBomb),
93
+ reached: (outcome) => outcome.bytesRead >= BOMB_EXPANDED_BYTES / 2,
94
+ },
95
+ {
96
+ id: 'endless-body',
97
+ title: 'Body with no end and no length',
98
+ attack: 'chunked response that never finishes',
99
+ control: 'Cap received bytes with a running total.',
100
+ start: (range) => range.url(RANGE_PATHS.payloadEndless),
101
+ reached: (outcome) => outcome.bytesRead > 1024 * 1024 || outcome.stoppedByHarness,
102
+ },
103
+ {
104
+ id: 'slow-drip',
105
+ title: 'One byte per second',
106
+ attack: 'a response that stays open for hours',
107
+ control: 'Bound the whole request with a deadline.',
108
+ start: (range) => range.url(RANGE_PATHS.payloadSlow),
109
+ options: () => ({ timeoutMs: 4000 }),
110
+ reached: (outcome) => outcome.stoppedByHarness || outcome.status === 200,
111
+ },
112
+ {
113
+ id: 'dns-rebind',
114
+ title: 'DNS rebinding (simulated)',
115
+ attack: 'first answer 93.184.216.34, every later answer 127.0.0.1',
116
+ control: 'Judge the address the SOCKET uses, not an earlier answer.',
117
+ needsConnectHook: true,
118
+ internalTarget: true,
119
+ start: (range) => `http://rebind.test:${range.internalPort}${INTERNAL_PATH}`,
120
+ options: () => ({ lookup: flipLookup(['93.184.216.34', '127.0.0.1']) }),
121
+ reached: gotSecret,
122
+ },
123
+ ];
124
+ //# sourceMappingURL=probes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"probes.js","sourceRoot":"","sources":["../../src/checker/probes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,WAAW,EAAoB,MAAM,oBAAoB,CAAA;AACjF,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAA;AA0BlD,MAAM,SAAS,GAAG,CAAC,OAAqB,EAAW,EAAE,CACnD,OAAO,CAAC,UAAU,CAAC,QAAQ,CAAC,sBAAsB,CAAC,CAAA;AAErD;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,aAAa,GAAqB;IAC7C,WAAW,CAAC,eAAe,EAAE,iBAAiB,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,oBAAoB,IAAI,EAAE,CAAC;IACrF,WAAW,CAAC,gBAAgB,EAAE,oBAAoB,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,qBAAqB,IAAI,EAAE,CAAC;IAC1F,WAAW,CAAC,cAAc,EAAE,kBAAkB,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,qBAAqB,IAAI,EAAE,CAAC;IACtF,WAAW,CAAC,YAAY,EAAE,wBAAwB,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,qBAAqB,IAAI,EAAE,CAAC;IAC1F,WAAW,CACT,kBAAkB,EAClB,iCAAiC,EACjC,CAAC,IAAI,EAAE,EAAE,CAAC,kBAAkB,IAAI,EAAE,CACnC;IACD,WAAW,CACT,oBAAoB,EACpB,qCAAqC,EACrC,CAAC,IAAI,EAAE,EAAE,CAAC,6BAA6B,IAAI,EAAE,CAC9C;IACD,WAAW,CACT,oBAAoB,EACpB,kCAAkC,EAClC,CAAC,IAAI,EAAE,EAAE,CAAC,+BAA+B,IAAI,EAAE,CAChD;CACF,CAAA;AAED,SAAS,WAAW,CAClB,EAAU,EACV,KAAa,EACb,MAAgC;IAEhC,OAAO;QACL,EAAE;QACF,KAAK;QACL,MAAM,EAAE,8CAA8C;QACtD,OAAO,EAAE,oEAAoE;QAC7E,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,GAAG,aAAa,EAAE;QACjE,cAAc,EAAE,IAAI;QACpB,OAAO,EAAE,SAAS;KACnB,CAAA;AACH,CAAC;AAED,MAAM,CAAC,MAAM,MAAM,GAAqB;IACtC;QACE,EAAE,EAAE,mBAAmB;QACvB,KAAK,EAAE,wBAAwB;QAC/B,MAAM,EAAE,iDAAiD;QACzD,OAAO,EAAE,0CAA0C;QACnD,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,gBAAgB,CAAC;QACzD,cAAc,EAAE,IAAI;QACpB,OAAO,EAAE,SAAS;KACnB;IACD;QACE,EAAE,EAAE,2BAA2B;QAC/B,KAAK,EAAE,8CAA8C;QACrD,MAAM,EAAE,kDAAkD;QAC1D,OAAO,EAAE,iDAAiD;QAC1D,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,uBAAuB,CAAC;QAChE,cAAc,EAAE,IAAI;QACpB,OAAO,EAAE,SAAS;KACnB;IACD;QACE,EAAE,EAAE,sBAAsB;QAC1B,KAAK,EAAE,mCAAmC;QAC1C,MAAM,EAAE,4DAA4D;QACpE,OAAO,EAAE,qDAAqD;QAC9D,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,mBAAmB,CAAC;QAC5D,cAAc,EAAE,IAAI;QACpB,OAAO,EAAE,SAAS;KACnB;IACD;QACE,EAAE,EAAE,qBAAqB;QACzB,KAAK,EAAE,8BAA8B;QACrC,MAAM,EAAE,iDAAiD;QACzD,OAAO,EAAE,8BAA8B;QACvC,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,iBAAiB,CAAC;QAC1D,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC;KACzC;IACD;QACE,6DAA6D;QAC7D,EAAE,EAAE,kBAAkB;QACtB,KAAK,EAAE,qCAAqC;QAC5C,MAAM,EAAE,6BAA6B;QACrC,OAAO,EAAE,yEAAyE;QAClF,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,eAAe,CAAC;QACxD,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC;KACzC;IACD;QACE,EAAE,EAAE,eAAe;QACnB,KAAK,EAAE,wBAAwB;QAC/B,MAAM,EAAE,yBAAyB;QACjC,OAAO,EAAE,yBAAyB;QAClC,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,YAAY,CAAC;QACrD,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,MAAM,GAAG,EAAE;QACpE,UAAU,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC;KACjD;IACD;QACE,EAAE,EAAE,oBAAoB;QACxB,KAAK,EAAE,oBAAoB;QAC3B,MAAM,EAAE,gDAAgD;QACxD,OAAO,EAAE,iDAAiD;QAC1D,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,WAAW,CAAC;QACpD,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,SAAS,IAAI,mBAAmB,GAAG,CAAC;KACnE;IACD;QACE,EAAE,EAAE,cAAc;QAClB,KAAK,EAAE,gCAAgC;QACvC,MAAM,EAAE,sCAAsC;QAC9C,OAAO,EAAE,0CAA0C;QACnD,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,cAAc,CAAC;QACvD,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,SAAS,GAAG,IAAI,GAAG,IAAI,IAAI,OAAO,CAAC,gBAAgB;KAClF;IACD;QACE,EAAE,EAAE,WAAW;QACf,KAAK,EAAE,qBAAqB;QAC5B,MAAM,EAAE,sCAAsC;QAC9C,OAAO,EAAE,0CAA0C;QACnD,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,WAAW,CAAC;QACpD,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;QACpC,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,gBAAgB,IAAI,OAAO,CAAC,MAAM,KAAK,GAAG;KACzE;IACD;QACE,EAAE,EAAE,YAAY;QAChB,KAAK,EAAE,2BAA2B;QAClC,MAAM,EAAE,0DAA0D;QAClE,OAAO,EAAE,2DAA2D;QACpE,gBAAgB,EAAE,IAAI;QACtB,cAAc,EAAE,IAAI;QACpB,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,sBAAsB,KAAK,CAAC,YAAY,GAAG,aAAa,EAAE;QAC5E,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,UAAU,CAAC,CAAC,eAAe,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC;QACvE,OAAO,EAAE,SAAS;KACnB;CACF,CAAA"}
@@ -0,0 +1,5 @@
1
+ import type { ScanResult } from './run.js';
2
+ export declare const LEGEND: string;
3
+ /** Render 1 scan as a table for a terminal. */
4
+ export declare function renderTable(scan: ScanResult): string;
5
+ export declare function renderJson(scans: readonly ScanResult[]): string;
@@ -0,0 +1,26 @@
1
+ const MARKS = {
2
+ BLOCKED: '[+] BLOCKED ',
3
+ PREVENTED: '[=] PREVENTED',
4
+ REACHED: '[!] REACHED ',
5
+ LUCKY: '[~] LUCKY ',
6
+ 'NOT-APPLICABLE': '[-] N/A ',
7
+ };
8
+ export const LEGEND = [
9
+ '[+] BLOCKED the client refused, and named the control that refused',
10
+ '[=] PREVENTED the attack never arrived, but the client named no control',
11
+ '[!] REACHED the client arrived, or never stopped reading',
12
+ '[~] LUCKY nothing refused it; something else failed first',
13
+ '[-] N/A the client has no hook this probe can use',
14
+ ].join('\n');
15
+ /** Render 1 scan as a table for a terminal. */
16
+ export function renderTable(scan) {
17
+ const titleWidth = Math.max(...scan.results.map((row) => row.title.length));
18
+ const lines = scan.results.map((row) => ` ${MARKS[row.verdict]} ${row.title.padEnd(titleWidth)} ${row.note}`);
19
+ const summary = ` ${scan.blocked} blocked · ${scan.prevented} prevented · ${scan.reached} reached · ` +
20
+ `${scan.lucky} lucky · ${scan.notApplicable} not applicable`;
21
+ return [`\n${scan.client}`, '-'.repeat(scan.client.length), ...lines, '', summary].join('\n');
22
+ }
23
+ export function renderJson(scans) {
24
+ return JSON.stringify({ scans }, null, 2);
25
+ }
26
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.js","sourceRoot":"","sources":["../../src/checker/report.ts"],"names":[],"mappings":"AAEA,MAAM,KAAK,GAA4B;IACrC,OAAO,EAAE,eAAe;IACxB,SAAS,EAAE,eAAe;IAC1B,OAAO,EAAE,eAAe;IACxB,KAAK,EAAE,eAAe;IACtB,gBAAgB,EAAE,eAAe;CAClC,CAAA;AAED,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,uEAAuE;IACvE,0EAA0E;IAC1E,6DAA6D;IAC7D,gEAAgE;IAChE,0DAA0D;CAC3D,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AAEZ,+CAA+C;AAC/C,MAAM,UAAU,WAAW,CAAC,IAAgB;IAC1C,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAA;IAC3E,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAC5B,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,UAAU,CAAC,KAAK,GAAG,CAAC,IAAI,EAAE,CACjF,CAAA;IAED,MAAM,OAAO,GACX,KAAK,IAAI,CAAC,OAAO,cAAc,IAAI,CAAC,SAAS,gBAAgB,IAAI,CAAC,OAAO,aAAa;QACtF,GAAG,IAAI,CAAC,KAAK,YAAY,IAAI,CAAC,aAAa,iBAAiB,CAAA;IAE9D,OAAO,CAAC,KAAK,IAAI,CAAC,MAAM,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,GAAG,KAAK,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AAC/F,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,KAA4B;IACrD,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;AAC3C,CAAC"}
@@ -0,0 +1,27 @@
1
+ import { type HttpClient } from '../adapters/types.js';
2
+ import type { AttackRange } from '../range/server.js';
3
+ import { type Probe } from './probes.js';
4
+ /**
5
+ * What one probe learned about one client.
6
+ *
7
+ * `LUCKY` is the verdict that this whole tool exists for. The request failed,
8
+ * the client has no control, and the difference is invisible in a log.
9
+ */
10
+ export type Verdict = 'BLOCKED' | 'PREVENTED' | 'REACHED' | 'LUCKY' | 'NOT-APPLICABLE';
11
+ export interface ProbeResult {
12
+ readonly probeId: string;
13
+ readonly title: string;
14
+ readonly verdict: Verdict;
15
+ readonly note: string;
16
+ readonly durationMs: number;
17
+ }
18
+ export interface ScanResult {
19
+ readonly client: string;
20
+ readonly results: readonly ProbeResult[];
21
+ readonly blocked: number;
22
+ readonly prevented: number;
23
+ readonly reached: number;
24
+ readonly lucky: number;
25
+ readonly notApplicable: number;
26
+ }
27
+ export declare function runScan(client: HttpClient, range: AttackRange, probes?: readonly Probe[]): Promise<ScanResult>;
@@ -0,0 +1,108 @@
1
+ import { MissingOptionalPackage } from '../adapters/optional.js';
2
+ import { PolicyRefusal } from '../adapters/types.js';
3
+ import { PROBES } from './probes.js';
4
+ export async function runScan(client, range, probes = PROBES) {
5
+ const results = [];
6
+ for (const probe of probes) {
7
+ results.push(await runProbe(client, range, probe));
8
+ }
9
+ const count = (verdict) => results.filter((row) => row.verdict === verdict).length;
10
+ return {
11
+ client: client.name,
12
+ results,
13
+ blocked: count('BLOCKED'),
14
+ prevented: count('PREVENTED'),
15
+ reached: count('REACHED'),
16
+ lucky: count('LUCKY'),
17
+ notApplicable: count('NOT-APPLICABLE'),
18
+ };
19
+ }
20
+ async function runProbe(client, range, probe) {
21
+ const started = Date.now();
22
+ const hitsBefore = range.internalHits();
23
+ const hitInternal = () => probe.internalTarget === true && range.internalHits() > hitsBefore;
24
+ const missed = () => probe.internalTarget === true && range.internalHits() === hitsBefore;
25
+ const finish = (verdict, note) => ({
26
+ probeId: probe.id,
27
+ title: probe.title,
28
+ verdict,
29
+ note,
30
+ durationMs: Date.now() - started,
31
+ });
32
+ if (probe.needsConnectHook === true && !client.supportsConnectHook) {
33
+ return finish('NOT-APPLICABLE', 'the client exposes no hook on the address it connects to');
34
+ }
35
+ if (client.supportsLoopbackOrigin === false &&
36
+ new URL(range.origin).hostname === '127.0.0.1' &&
37
+ new URL(probe.start(range)).origin === range.origin) {
38
+ return finish('NOT-APPLICABLE', 'requires the isolated public-address entry server; loopback would be refused before the probe');
39
+ }
40
+ try {
41
+ // The range itself lives on loopback. Without this the guards refuse the
42
+ // entry URL and every probe passes while testing nothing.
43
+ const outcome = await client.fetch(probe.start(range), {
44
+ trustedOrigin: range.origin,
45
+ ...(probe.options?.(range) ?? {}),
46
+ });
47
+ // SSRF has already happened if the internal service saw the request,
48
+ // even when an adapter drops the body or omits the canary from its result.
49
+ if (hitInternal()) {
50
+ return finish('REACHED', 'the internal service recorded a request, independent of the returned body');
51
+ }
52
+ if (probe.reached(outcome)) {
53
+ return finish('REACHED', describeReach(outcome.status, outcome.bytesRead, outcome.stoppedByHarness));
54
+ }
55
+ if (missed()) {
56
+ return finish('PREVENTED', 'no refusal, and the internal service saw no request');
57
+ }
58
+ return finish('LUCKY', `no refusal; status ${outcome.status}, ${outcome.bytesRead} bytes`);
59
+ }
60
+ catch (error) {
61
+ // A client whose package is not installed never ran. Reporting that as
62
+ // LUCKY or PREVENTED would score a request that was never made.
63
+ if (error instanceof MissingOptionalPackage && !hitInternal())
64
+ throw error;
65
+ // A late body cap, timeout, or network error cannot undo an internal hit.
66
+ // Independent server-side evidence takes priority over a client's claim.
67
+ if (hitInternal()) {
68
+ return finish('REACHED', 'the internal service recorded a request before the client reported an error');
69
+ }
70
+ if (error instanceof PolicyRefusal) {
71
+ return finish('BLOCKED', `${error.reason}: ${error.detail}`);
72
+ }
73
+ const failure = error instanceof Error ? error : new Error(String(error));
74
+ const detail = flatten(failure);
75
+ if (probe.recognizes?.(detail) === true) {
76
+ return finish('BLOCKED', `the client's own limit: ${detail}`);
77
+ }
78
+ if (missed()) {
79
+ return finish('PREVENTED', `the internal service saw no request; the attempt failed with ${detail}`);
80
+ }
81
+ return finish('LUCKY', `no policy; the request failed with ${detail}`);
82
+ }
83
+ }
84
+ /**
85
+ * Flatten an error and its causes into one line.
86
+ *
87
+ * Node's fetch reports "fetch failed" and keeps the reason in `cause`, so a
88
+ * check that reads only the message cannot tell a redirect cap from a DNS
89
+ * failure.
90
+ */
91
+ function flatten(error) {
92
+ const parts = [];
93
+ let current = error;
94
+ while (current instanceof Error && parts.length < 4) {
95
+ parts.push(codeOf(current));
96
+ current = current.cause;
97
+ }
98
+ return parts.join(' <- ');
99
+ }
100
+ function describeReach(status, bytes, stoppedByHarness) {
101
+ const base = `status ${status}, ${bytes} bytes`;
102
+ return stoppedByHarness ? `${base}; the harness had to stop the read` : base;
103
+ }
104
+ function codeOf(error) {
105
+ const code = error.code;
106
+ return code ?? error.message;
107
+ }
108
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/checker/run.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ import { checkThenConnect } from './adapters/checkThenConnect.js';
3
+ import { guardedFetch } from './adapters/guardedFetch.js';
4
+ import { requestFilteringAgent, ssrfAgentClient, ssrfReqFilter } from './adapters/libraries.js';
5
+ import { MissingOptionalPackage, missingOptionalPackages, } from './adapters/optional.js';
6
+ import { plainFetch } from './adapters/plainFetch.js';
7
+ import { vercelGuardedFetch } from './adapters/vercelGuardedFetch.js';
8
+ import { DIRECT_PROBES, PROBES } from './checker/probes.js';
9
+ import { LEGEND, renderJson, renderTable } from './checker/report.js';
10
+ import { runScan } from './checker/run.js';
11
+ import { startAttackRange } from './range/server.js';
12
+ const CLIENTS = {
13
+ plain: plainFetch,
14
+ naive: checkThenConnect,
15
+ guarded: guardedFetch,
16
+ rfa: requestFilteringAgent,
17
+ 'ssrf-req-filter': ssrfReqFilter,
18
+ 'ssrf-agent': ssrfAgentClient,
19
+ 'vercel-guarded': vercelGuardedFetch,
20
+ };
21
+ /** Clients that compare a package ssrf-lab does not install. */
22
+ const REQUIRES = {
23
+ rfa: ['request-filtering-agent'],
24
+ 'ssrf-req-filter': ['ssrf-req-filter'],
25
+ 'ssrf-agent': ['ssrf-agent'],
26
+ 'vercel-guarded': ['guarded-fetch'],
27
+ };
28
+ const GROUPS = {
29
+ all: ['plain', 'naive', 'guarded'],
30
+ libs: ['rfa', 'ssrf-req-filter', 'ssrf-agent'],
31
+ };
32
+ const SUITES = {
33
+ redirect: PROBES,
34
+ direct: DIRECT_PROBES,
35
+ all: [...PROBES, ...DIRECT_PROBES],
36
+ };
37
+ const USAGE = `ssrf-lab — run a local attack range against an HTTP client
38
+
39
+ ssrf-lab scan [--client <name|all|libs>] [--suite redirect|direct|all]
40
+ [--json] [--fail-on-reach] [--public-address]
41
+
42
+ --client all (the 3 built-in clients), libs (the npm SSRF packages),
43
+ or one of: ${Object.keys(CLIENTS).join(', ')}
44
+ libs, rfa, ssrf-req-filter, ssrf-agent and vercel-guarded
45
+ need the compared package installed beside ssrf-lab; the
46
+ scan prints the install command when one is missing
47
+ --suite redirect: attacks that start on the public range (default)
48
+ direct: one request at the internal service, in every
49
+ spelling a URL parser accepts
50
+ all: both
51
+ --json print machine-readable results
52
+ --fail-on-reach exit 1 when any probe reports REACHED
53
+ --public-address use the isolated entry fixture; requires only lo with
54
+ 93.184.216.34/32 and no external routes (Linux only)
55
+ `;
56
+ async function main() {
57
+ const argv = process.argv.slice(2);
58
+ if (argv.includes('--help') || argv.includes('-h') || argv[0] === undefined) {
59
+ process.stdout.write(USAGE);
60
+ return;
61
+ }
62
+ const json = argv.includes('--json');
63
+ const failOnReach = argv.includes('--fail-on-reach');
64
+ const which = flagValue(argv, '--client') ?? 'all';
65
+ const suiteName = flagValue(argv, '--suite') ?? 'redirect';
66
+ const suite = SUITES[suiteName];
67
+ const names = GROUPS[which] ?? [which];
68
+ const selected = names.map((name) => CLIENTS[name]);
69
+ if (suite === undefined) {
70
+ process.stderr.write(`unknown suite: ${suiteName}\n${USAGE}`);
71
+ process.exitCode = 2;
72
+ return;
73
+ }
74
+ if (selected.some((client) => client === undefined)) {
75
+ process.stderr.write(`unknown client: ${which}\n${USAGE}`);
76
+ process.exitCode = 2;
77
+ return;
78
+ }
79
+ const missing = await missingOptionalPackages(names.flatMap((name) => REQUIRES[name] ?? []));
80
+ if (missing.length > 0) {
81
+ process.stderr.write(`${new MissingOptionalPackage(missing).message}\n`);
82
+ process.exitCode = 2;
83
+ return;
84
+ }
85
+ const range = await startAttackRange({ publicAddress: argv.includes('--public-address') });
86
+ const scans = [];
87
+ try {
88
+ for (const client of selected) {
89
+ if (client === undefined)
90
+ continue;
91
+ scans.push(await runScan(client, range, suite));
92
+ }
93
+ }
94
+ finally {
95
+ await range.close();
96
+ }
97
+ if (json) {
98
+ process.stdout.write(`${renderJson(scans)}\n`);
99
+ }
100
+ else {
101
+ process.stdout.write(`${LEGEND}\n`);
102
+ for (const scan of scans)
103
+ process.stdout.write(`${renderTable(scan)}\n`);
104
+ process.stdout.write('\n');
105
+ }
106
+ if (failOnReach && scans.some((scan) => scan.reached > 0))
107
+ process.exitCode = 1;
108
+ }
109
+ function flagValue(argv, flag) {
110
+ const index = argv.indexOf(flag);
111
+ if (index >= 0)
112
+ return argv[index + 1];
113
+ const inline = argv.find((entry) => entry.startsWith(`${flag}=`));
114
+ return inline?.slice(flag.length + 1);
115
+ }
116
+ main().catch((error) => {
117
+ process.stderr.write(`${String(error)}\n`);
118
+ process.exitCode = 1;
119
+ });
120
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ export { agentClient, type AgentClientSpec } from './adapters/agentClient.js';
2
+ export { checkThenConnect } from './adapters/checkThenConnect.js';
3
+ export { requestFilteringAgent, ssrfAgentClient, ssrfReqFilter } from './adapters/libraries.js';
4
+ export { guardedFetch } from './adapters/guardedFetch.js';
5
+ export { plainFetch } from './adapters/plainFetch.js';
6
+ export { vercelGuardedFetch } from './adapters/vercelGuardedFetch.js';
7
+ export { assertIsolatedNetwork, ISOLATED_PUBLIC_ADDRESS } from './range/isolation.js';
8
+ export { PolicyRefusal, type ClientOptions, type FetchOutcome, type HttpClient, type RefusalReason, } from './adapters/types.js';
9
+ export { DIRECT_PROBES, PROBES, type Probe } from './checker/probes.js';
10
+ export { LEGEND, renderJson, renderTable } from './checker/report.js';
11
+ export { runScan, type ProbeResult, type ScanResult, type Verdict } from './checker/run.js';
12
+ export { decompressionBomb, BOMB_EXPANDED_BYTES } from './range/bomb.js';
13
+ export { CANARY_BOMB_TAIL, CANARY_ENDLESS_CHUNK, CANARY_INTERNAL_SECRET, } from './range/canaries.js';
14
+ export { startAttackRange, INTERNAL_PATH, RANGE_PATHS, type AttackRange } from './range/server.js';
15
+ export { classifyAddress, isForbiddenAddress, METADATA_ADDRESS, type AddressClass, } from './util/addresses.js';
16
+ export { flipLookup } from './util/flipLookup.js';
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ export { agentClient } from './adapters/agentClient.js';
2
+ export { checkThenConnect } from './adapters/checkThenConnect.js';
3
+ export { requestFilteringAgent, ssrfAgentClient, ssrfReqFilter } from './adapters/libraries.js';
4
+ export { guardedFetch } from './adapters/guardedFetch.js';
5
+ export { plainFetch } from './adapters/plainFetch.js';
6
+ export { vercelGuardedFetch } from './adapters/vercelGuardedFetch.js';
7
+ export { assertIsolatedNetwork, ISOLATED_PUBLIC_ADDRESS } from './range/isolation.js';
8
+ export { PolicyRefusal, } from './adapters/types.js';
9
+ export { DIRECT_PROBES, PROBES } from './checker/probes.js';
10
+ export { LEGEND, renderJson, renderTable } from './checker/report.js';
11
+ export { runScan } from './checker/run.js';
12
+ export { decompressionBomb, BOMB_EXPANDED_BYTES } from './range/bomb.js';
13
+ export { CANARY_BOMB_TAIL, CANARY_ENDLESS_CHUNK, CANARY_INTERNAL_SECRET, } from './range/canaries.js';
14
+ export { startAttackRange, INTERNAL_PATH, RANGE_PATHS } from './range/server.js';
15
+ export { classifyAddress, isForbiddenAddress, METADATA_ADDRESS, } from './util/addresses.js';
16
+ export { flipLookup } from './util/flipLookup.js';
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAwB,MAAM,2BAA2B,CAAA;AAC7E,OAAO,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAA;AACjE,OAAO,EAAE,qBAAqB,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AAC/F,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAA;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAA;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAA;AACrE,OAAO,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAA;AACrF,OAAO,EACL,aAAa,GAKd,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,aAAa,EAAE,MAAM,EAAc,MAAM,qBAAqB,CAAA;AACvE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACrE,OAAO,EAAE,OAAO,EAAmD,MAAM,kBAAkB,CAAA;AAC3F,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AACxE,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAoB,MAAM,mBAAmB,CAAA;AAClG,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,gBAAgB,GAEjB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA"}
@@ -0,0 +1,13 @@
1
+ /** Expanded size of the decompression bomb: 64 MiB. */
2
+ export declare const BOMB_EXPANDED_BYTES: number;
3
+ /**
4
+ * Build a gzip body that is small on the wire and large in memory.
5
+ *
6
+ * 64 MiB of zeros compresses to about 64 KiB, a ratio of about 1000:1. A
7
+ * client that decompresses before it counts bytes allocates the whole 64 MiB,
8
+ * and a real attacker picks a much larger number.
9
+ *
10
+ * The tail carries a canary, so a client that streamed the WHOLE body can be
11
+ * told apart from one that stopped at a cap.
12
+ */
13
+ export declare function decompressionBomb(): Buffer;
@@ -0,0 +1,24 @@
1
+ import { gzipSync } from 'node:zlib';
2
+ import { CANARY_BOMB_TAIL } from './canaries.js';
3
+ /** Expanded size of the decompression bomb: 64 MiB. */
4
+ export const BOMB_EXPANDED_BYTES = 64 * 1024 * 1024;
5
+ let cached = null;
6
+ /**
7
+ * Build a gzip body that is small on the wire and large in memory.
8
+ *
9
+ * 64 MiB of zeros compresses to about 64 KiB, a ratio of about 1000:1. A
10
+ * client that decompresses before it counts bytes allocates the whole 64 MiB,
11
+ * and a real attacker picks a much larger number.
12
+ *
13
+ * The tail carries a canary, so a client that streamed the WHOLE body can be
14
+ * told apart from one that stopped at a cap.
15
+ */
16
+ export function decompressionBomb() {
17
+ if (cached !== null)
18
+ return cached;
19
+ const zeros = Buffer.alloc(BOMB_EXPANDED_BYTES - CANARY_BOMB_TAIL.length, 0x30);
20
+ const payload = Buffer.concat([zeros, Buffer.from(CANARY_BOMB_TAIL, 'utf8')]);
21
+ cached = gzipSync(payload, { level: 9 });
22
+ return cached;
23
+ }
24
+ //# sourceMappingURL=bomb.js.map