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,13 @@
1
+ import { type HttpClient } from './types.js';
2
+ /**
3
+ * A client with the 4 controls the range tests.
4
+ *
5
+ * 1. Every hop is judged, not only the first URL.
6
+ * 2. The address is judged where the socket connects, so a second answer
7
+ * cannot differ from the one that passed the check.
8
+ * 3. The body is capped while it is read, after decompression.
9
+ * 4. ONE deadline covers the whole request, the body read included. A socket
10
+ * timeout is not a deadline: a target that sends 1 byte per second keeps
11
+ * the socket busy and holds the connection for as long as it likes.
12
+ */
13
+ export declare const guardedFetch: HttpClient;
@@ -0,0 +1,110 @@
1
+ import { lookup as systemLookup } from 'node:dns';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { isIP } from 'node:net';
5
+ import { classifyAddress, isForbiddenAddress } from '../util/addresses.js';
6
+ import { assertAllowedProtocol, guardedLookup, readCapped, redact } from './internal.js';
7
+ import { PolicyRefusal } from './types.js';
8
+ const DEFAULT_MAX_REDIRECTS = 3;
9
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
10
+ const DEFAULT_TIMEOUT_MS = 3000;
11
+ /**
12
+ * A client with the 4 controls the range tests.
13
+ *
14
+ * 1. Every hop is judged, not only the first URL.
15
+ * 2. The address is judged where the socket connects, so a second answer
16
+ * cannot differ from the one that passed the check.
17
+ * 3. The body is capped while it is read, after decompression.
18
+ * 4. ONE deadline covers the whole request, the body read included. A socket
19
+ * timeout is not a deadline: a target that sends 1 byte per second keeps
20
+ * the socket busy and holds the connection for as long as it likes.
21
+ */
22
+ export const guardedFetch = {
23
+ name: 'guarded client (this repo)',
24
+ supportsConnectHook: true,
25
+ async fetch(url, options = {}) {
26
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
27
+ const deadline = AbortSignal.timeout(timeoutMs);
28
+ try {
29
+ return await follow(url, options, deadline);
30
+ }
31
+ catch (error) {
32
+ throw asDeadline(error, timeoutMs, deadline);
33
+ }
34
+ },
35
+ };
36
+ async function follow(url, options, deadline) {
37
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
38
+ const lookup = guardedLookup(options.lookup);
39
+ const hops = [];
40
+ let target = new URL(url);
41
+ for (let hop = 0; hop <= DEFAULT_MAX_REDIRECTS; hop += 1) {
42
+ const trusted = target.origin === options.trustedOrigin;
43
+ assertAllowedProtocol(target);
44
+ if (!trusted)
45
+ refuseForbiddenLiteral(target);
46
+ hops.push(redact(target));
47
+ const response = await send(target, trusted ? systemLookup : lookup, deadline);
48
+ const location = response.headers.location;
49
+ if (isRedirect(response.statusCode ?? 0) && typeof location === 'string') {
50
+ response.resume();
51
+ target = new URL(location, target);
52
+ continue;
53
+ }
54
+ const body = await readCapped(response, maxBytes);
55
+ return {
56
+ status: response.statusCode ?? 0,
57
+ bytesRead: body.bytes,
58
+ bodySample: body.sample,
59
+ hops,
60
+ stoppedByHarness: false,
61
+ };
62
+ }
63
+ throw new PolicyRefusal('too-many-redirects', `more than ${DEFAULT_MAX_REDIRECTS} hops`);
64
+ }
65
+ function isRedirect(status) {
66
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
67
+ }
68
+ /**
69
+ * Refuse an address literal before any resolver runs.
70
+ *
71
+ * `http://2130706433/` carries no name to resolve, and the URL parser has
72
+ * already turned it into 127.0.0.1 by the time this reads the hostname.
73
+ */
74
+ function refuseForbiddenLiteral(url) {
75
+ const host = url.hostname.replace(/^\[|\]$/g, '');
76
+ if (isIP(host) === 0)
77
+ return;
78
+ if (isForbiddenAddress(host)) {
79
+ throw new PolicyRefusal('forbidden-address', `${host} is ${classifyAddress(host)}`);
80
+ }
81
+ }
82
+ function send(url, lookup, signal) {
83
+ const transport = url.protocol === 'https:' ? httpsRequest : httpRequest;
84
+ return new Promise((resolve, reject) => {
85
+ const request = transport(url, {
86
+ lookup,
87
+ signal,
88
+ headers: { 'user-agent': 'ssrf-lab/0.1 (+https://github.com/roshan-amble/ssrf-lab)' },
89
+ }, resolve);
90
+ request.on('error', reject);
91
+ request.end();
92
+ });
93
+ }
94
+ /**
95
+ * Turn an abort into the refusal it represents.
96
+ *
97
+ * The deadline is ours, so an abort caused by it is a decision this client
98
+ * made, not a network failure. Reporting it as a failure would hide a control.
99
+ */
100
+ function asDeadline(error, timeoutMs, deadline) {
101
+ if (error instanceof PolicyRefusal)
102
+ return error;
103
+ // The signal is the evidence, not the error code. Destroying a socket that
104
+ // is mid-read surfaces as ECONNRESET, and reading only the code turns this
105
+ // client's own deadline into a network failure.
106
+ if (deadline.aborted)
107
+ return new PolicyRefusal('deadline', `${timeoutMs} ms elapsed`);
108
+ return error;
109
+ }
110
+ //# sourceMappingURL=guardedFetch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guardedFetch.js","sourceRoot":"","sources":["../../src/adapters/guardedFetch.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { LookupFunction } from 'node:net';
3
+ export declare const SAMPLE_BYTES = 4096;
4
+ export interface ReadResult {
5
+ bytes: number;
6
+ sample: string;
7
+ }
8
+ /** Protocols a fetcher of untrusted URLs may speak. */
9
+ export declare function assertAllowedProtocol(url: URL): void;
10
+ /**
11
+ * A URL that is safe to print.
12
+ *
13
+ * A redirect target chosen by the remote host may carry credentials, and a
14
+ * message that quotes it puts them in a log file.
15
+ */
16
+ export declare function redact(url: URL): string;
17
+ /**
18
+ * Resolve a name and judge EVERY address in the answer.
19
+ *
20
+ * A mixed answer is refused whole. One public address beside one loopback
21
+ * address is the attack; connecting to the survivor is what it wants.
22
+ */
23
+ export declare function guardedLookup(base?: LookupFunction): LookupFunction;
24
+ /**
25
+ * Read a body against a byte cap, and count the DECOMPRESSED bytes.
26
+ *
27
+ * The cap is applied while reading. A length check after `await body()` is not
28
+ * a cap, because the bytes are already in memory when it runs.
29
+ */
30
+ export declare function readCapped(response: IncomingMessage, maxBytes: number): Promise<ReadResult>;
@@ -0,0 +1,95 @@
1
+ import { lookup as systemLookup } from 'node:dns';
2
+ import { createGunzip } from 'node:zlib';
3
+ import { classifyAddress, isForbiddenAddress } from '../util/addresses.js';
4
+ import { PolicyRefusal } from './types.js';
5
+ export const SAMPLE_BYTES = 4096;
6
+ /** Protocols a fetcher of untrusted URLs may speak. */
7
+ export function assertAllowedProtocol(url) {
8
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
9
+ throw new PolicyRefusal('scheme-not-allowed', url.protocol);
10
+ }
11
+ }
12
+ /**
13
+ * A URL that is safe to print.
14
+ *
15
+ * A redirect target chosen by the remote host may carry credentials, and a
16
+ * message that quotes it puts them in a log file.
17
+ */
18
+ export function redact(url) {
19
+ const copy = new URL(url.toString());
20
+ if (copy.username !== '' || copy.password !== '') {
21
+ copy.username = '***';
22
+ copy.password = '';
23
+ }
24
+ return copy.toString();
25
+ }
26
+ /**
27
+ * Resolve a name and judge EVERY address in the answer.
28
+ *
29
+ * A mixed answer is refused whole. One public address beside one loopback
30
+ * address is the attack; connecting to the survivor is what it wants.
31
+ */
32
+ export function guardedLookup(base = systemLookup) {
33
+ return ((hostname, options, callback) => {
34
+ const done = (typeof options === 'function' ? options : callback);
35
+ // Node asks for every address when it selects a family itself. Answering
36
+ // with a single string there fails with ERR_INVALID_IP_ADDRESS, and the
37
+ // guard then looks like a network fault instead of a control.
38
+ const wantsAll = typeof options === 'object' &&
39
+ options !== null &&
40
+ options.all === true;
41
+ base(hostname, { all: true }, (error, addresses) => {
42
+ if (error) {
43
+ done(error);
44
+ return;
45
+ }
46
+ const list = Array.isArray(addresses) ? addresses : [{ address: String(addresses), family: 4 }];
47
+ const forbidden = list.find((entry) => isForbiddenAddress(entry.address));
48
+ if (forbidden !== undefined) {
49
+ done(new PolicyRefusal('forbidden-address', `${hostname} resolved to ${forbidden.address} (${classifyAddress(forbidden.address)})`));
50
+ return;
51
+ }
52
+ const first = list[0];
53
+ if (first === undefined) {
54
+ done(new PolicyRefusal('forbidden-address', `${hostname} resolved to nothing`));
55
+ return;
56
+ }
57
+ // The socket gets the address this guard judged, and no later answer can
58
+ // replace it. That pinning is what closes the rebinding window.
59
+ if (wantsAll)
60
+ done(null, list);
61
+ else
62
+ done(null, first.address, first.family);
63
+ });
64
+ });
65
+ }
66
+ /**
67
+ * Read a body against a byte cap, and count the DECOMPRESSED bytes.
68
+ *
69
+ * The cap is applied while reading. A length check after `await body()` is not
70
+ * a cap, because the bytes are already in memory when it runs.
71
+ */
72
+ export async function readCapped(response, maxBytes) {
73
+ const gunzip = response.headers['content-encoding'] === 'gzip' ? createGunzip() : null;
74
+ const source = gunzip === null ? response : response.pipe(gunzip);
75
+ let bytes = 0;
76
+ let sample = Buffer.alloc(0);
77
+ try {
78
+ for await (const chunk of source) {
79
+ bytes += chunk.byteLength;
80
+ if (sample.byteLength < SAMPLE_BYTES) {
81
+ sample = Buffer.concat([sample, chunk.subarray(0, SAMPLE_BYTES - sample.byteLength)]);
82
+ }
83
+ if (bytes > maxBytes) {
84
+ response.destroy();
85
+ gunzip?.destroy();
86
+ throw new PolicyRefusal('body-too-large', `stopped after ${bytes} bytes, cap is ${maxBytes}`);
87
+ }
88
+ }
89
+ }
90
+ finally {
91
+ gunzip?.destroy();
92
+ }
93
+ return { bytes, sample: sample.toString('utf8') };
94
+ }
95
+ //# sourceMappingURL=internal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internal.js","sourceRoot":"","sources":["../../src/adapters/internal.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import type { HttpClient } from './types.js';
2
+ /**
3
+ * Third-party npm packages whose stated purpose is to stop SSRF.
4
+ *
5
+ * A library that never claimed to block anything is not interesting here.
6
+ * These 3 do claim it, so a probe that reaches the internal service through
7
+ * one of them is a finding, not a configuration mistake.
8
+ *
9
+ * Each package is loaded on the first request, not at import time, so that
10
+ * ssrf-lab runs without them. See `optional.ts`.
11
+ */
12
+ export declare const requestFilteringAgent: HttpClient;
13
+ export declare const ssrfReqFilter: HttpClient;
14
+ export declare const ssrfAgentClient: HttpClient;
@@ -0,0 +1,47 @@
1
+ import { agentClient } from './agentClient.js';
2
+ import { loadOptional } from './optional.js';
3
+ /**
4
+ * Third-party npm packages whose stated purpose is to stop SSRF.
5
+ *
6
+ * A library that never claimed to block anything is not interesting here.
7
+ * These 3 do claim it, so a probe that reaches the internal service through
8
+ * one of them is a finding, not a configuration mistake.
9
+ *
10
+ * Each package is loaded on the first request, not at import time, so that
11
+ * ssrf-lab runs without them. See `optional.ts`.
12
+ */
13
+ export const requestFilteringAgent = lazyAgentClient('request-filtering-agent (npm)', /not allowed|private|reserved|blocked/i, 'request-filtering-agent', async () => {
14
+ const { useAgent } = await import('request-filtering-agent');
15
+ return (url) => useAgent(url);
16
+ });
17
+ export const ssrfReqFilter = lazyAgentClient('ssrf-req-filter (npm)', /not allowed|private|reserved|blocked|call.*blocked/i, 'ssrf-req-filter', async () => {
18
+ const ssrfFilter = (await import('ssrf-req-filter')).default;
19
+ return (url) => ssrfFilter(url);
20
+ });
21
+ export const ssrfAgentClient = lazyAgentClient('ssrf-agent (npm)', /not allowed|private|reserved|blocked|forbidden/i, 'ssrf-agent', async () => {
22
+ const ssrfAgent = (await import('ssrf-agent')).default;
23
+ return (url) => ssrfAgent(url);
24
+ });
25
+ function lazyAgentClient(name, refusalPattern, packageName, load) {
26
+ let makeAgent;
27
+ const client = agentClient({
28
+ name,
29
+ refusalPattern,
30
+ makeAgent: (url) => {
31
+ if (makeAgent === undefined)
32
+ throw new Error(`${packageName} was not loaded before the request`);
33
+ return makeAgent(url);
34
+ },
35
+ });
36
+ return {
37
+ name: client.name,
38
+ supportsConnectHook: client.supportsConnectHook,
39
+ async fetch(url, options) {
40
+ // Load outside agentClient's error mapping: a missing package must not be
41
+ // mistaken for the library's own refusal wording.
42
+ makeAgent ??= await loadOptional(packageName, load);
43
+ return client.fetch(url, options);
44
+ },
45
+ };
46
+ }
47
+ //# sourceMappingURL=libraries.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"libraries.js","sourceRoot":"","sources":["../../src/adapters/libraries.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,YAAY,EAAwB,MAAM,eAAe,CAAA;AAGlE;;;;;;;;;GASG;AAEH,MAAM,CAAC,MAAM,qBAAqB,GAAe,eAAe,CAC9D,+BAA+B,EAC/B,uCAAuC,EACvC,yBAAyB,EACzB,KAAK,IAAI,EAAE;IACT,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,MAAM,CAAC,yBAAyB,CAAC,CAAA;IAC5D,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAU,CAAA;AACxC,CAAC,CACF,CAAA;AAED,MAAM,CAAC,MAAM,aAAa,GAAe,eAAe,CACtD,uBAAuB,EACvB,qDAAqD,EACrD,iBAAiB,EACjB,KAAK,IAAI,EAAE;IACT,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,iBAAiB,CAAC,CAAC,CAAC,OAAO,CAAA;IAC5D,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,UAAU,CAAC,GAAG,CAAU,CAAA;AAC1C,CAAC,CACF,CAAA;AAED,MAAM,CAAC,MAAM,eAAe,GAAe,eAAe,CACxD,kBAAkB,EAClB,iDAAiD,EACjD,YAAY,EACZ,KAAK,IAAI,EAAE;IACT,MAAM,SAAS,GAAG,CAAC,MAAM,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,OAAO,CAAA;IACtD,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAU,CAAA;AACzC,CAAC,CACF,CAAA;AAID,SAAS,eAAe,CACtB,IAAY,EACZ,cAAsB,EACtB,WAA4B,EAC5B,IAA8B;IAE9B,IAAI,SAAgC,CAAA;IAEpC,MAAM,MAAM,GAAG,WAAW,CAAC;QACzB,IAAI;QACJ,cAAc;QACd,SAAS,EAAE,CAAC,GAAG,EAAE,EAAE;YACjB,IAAI,SAAS,KAAK,SAAS;gBAAE,MAAM,IAAI,KAAK,CAAC,GAAG,WAAW,oCAAoC,CAAC,CAAA;YAChG,OAAO,SAAS,CAAC,GAAG,CAAC,CAAA;QACvB,CAAC;KACF,CAAC,CAAA;IAEF,OAAO;QACL,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,mBAAmB,EAAE,MAAM,CAAC,mBAAmB;QAC/C,KAAK,CAAC,KAAK,CAAC,GAAW,EAAE,OAAuB;YAC9C,0EAA0E;YAC1E,kDAAkD;YAClD,SAAS,KAAK,MAAM,YAAY,CAAC,WAAW,EAAE,IAAI,CAAC,CAAA;YACnD,OAAO,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,CAAA;QACnC,CAAC;KACF,CAAA;AACH,CAAC"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Packages ssrf-lab compares but does not depend on.
3
+ *
4
+ * Installing ssrf-lab must never install a package under test: `ssrf-agent`
5
+ * pulls in `ip` 1.1.9, which carries CVE-2024-29415, and a security tool that
6
+ * makes `npm audit` fail for its users has its priorities backwards. These are
7
+ * devDependencies of this repository and loaded only when a scan selects them.
8
+ */
9
+ export declare const OPTIONAL_PACKAGES: {
10
+ readonly 'request-filtering-agent': "3.2.1";
11
+ readonly 'ssrf-agent': "1.0.5";
12
+ readonly 'ssrf-req-filter': "1.1.1";
13
+ readonly 'guarded-fetch': "0.1.5";
14
+ };
15
+ export type OptionalPackage = keyof typeof OPTIONAL_PACKAGES;
16
+ /**
17
+ * A compared package is not installed.
18
+ *
19
+ * The checker lets this error end a scan instead of turning it into a verdict:
20
+ * a client that never ran has neither blocked nor reached anything.
21
+ */
22
+ export declare class MissingOptionalPackage extends Error {
23
+ readonly packages: readonly OptionalPackage[];
24
+ constructor(packages: readonly OptionalPackage[]);
25
+ }
26
+ /** Load one compared package, or explain how to install it. */
27
+ export declare function loadOptional<T>(name: OptionalPackage, load: () => Promise<T>): Promise<T>;
28
+ /** The compared packages from `names` that cannot be loaded here. */
29
+ export declare function missingOptionalPackages(names: readonly OptionalPackage[]): Promise<OptionalPackage[]>;
30
+ /**
31
+ * True for Node's "package not found" error about `name` itself.
32
+ *
33
+ * Any other failure, including a missing dependency OF that package, is a real
34
+ * error and must surface as one.
35
+ */
36
+ export declare function isMissing(error: unknown, name: string): boolean;
37
+ export declare function installHint(packages: readonly OptionalPackage[]): string;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Packages ssrf-lab compares but does not depend on.
3
+ *
4
+ * Installing ssrf-lab must never install a package under test: `ssrf-agent`
5
+ * pulls in `ip` 1.1.9, which carries CVE-2024-29415, and a security tool that
6
+ * makes `npm audit` fail for its users has its priorities backwards. These are
7
+ * devDependencies of this repository and loaded only when a scan selects them.
8
+ */
9
+ export const OPTIONAL_PACKAGES = {
10
+ 'request-filtering-agent': '3.2.1',
11
+ 'ssrf-agent': '1.0.5',
12
+ 'ssrf-req-filter': '1.1.1',
13
+ 'guarded-fetch': '0.1.5',
14
+ };
15
+ /**
16
+ * A compared package is not installed.
17
+ *
18
+ * The checker lets this error end a scan instead of turning it into a verdict:
19
+ * a client that never ran has neither blocked nor reached anything.
20
+ */
21
+ export class MissingOptionalPackage extends Error {
22
+ packages;
23
+ constructor(packages) {
24
+ super(installHint(packages));
25
+ this.packages = packages;
26
+ this.name = 'MissingOptionalPackage';
27
+ }
28
+ }
29
+ /** Load one compared package, or explain how to install it. */
30
+ export async function loadOptional(name, load) {
31
+ try {
32
+ return await load();
33
+ }
34
+ catch (error) {
35
+ if (isMissing(error, name))
36
+ throw new MissingOptionalPackage([name]);
37
+ throw error;
38
+ }
39
+ }
40
+ /** The compared packages from `names` that cannot be loaded here. */
41
+ export async function missingOptionalPackages(names) {
42
+ const missing = [];
43
+ for (const name of new Set(names)) {
44
+ try {
45
+ await import(name);
46
+ }
47
+ catch (error) {
48
+ if (!isMissing(error, name))
49
+ throw error;
50
+ missing.push(name);
51
+ }
52
+ }
53
+ return missing;
54
+ }
55
+ /**
56
+ * True for Node's "package not found" error about `name` itself.
57
+ *
58
+ * Any other failure, including a missing dependency OF that package, is a real
59
+ * error and must surface as one.
60
+ */
61
+ export function isMissing(error, name) {
62
+ if (!(error instanceof Error))
63
+ return false;
64
+ const code = error.code;
65
+ if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND')
66
+ return false;
67
+ return error.message.includes(`'${name}'`);
68
+ }
69
+ export function installHint(packages) {
70
+ const specs = packages.map((name) => `${name}@${OPTIONAL_PACKAGES[name]}`);
71
+ const npx = specs.map((spec) => `-p ${spec}`).join(' ');
72
+ return [
73
+ `Not installed: ${specs.join(', ')}.`,
74
+ 'ssrf-lab compares these packages but does not depend on them, so installing',
75
+ 'ssrf-lab never installs a package under test. Install them beside ssrf-lab:',
76
+ '',
77
+ ` npm install --save-dev ${specs.join(' ')}`,
78
+ '',
79
+ 'or, with npx:',
80
+ '',
81
+ ` npx -p ssrf-lab ${npx} ssrf-lab scan ...`,
82
+ ].join('\n');
83
+ }
84
+ //# sourceMappingURL=optional.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"optional.js","sourceRoot":"","sources":["../../src/adapters/optional.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,yBAAyB,EAAE,OAAO;IAClC,YAAY,EAAE,OAAO;IACrB,iBAAiB,EAAE,OAAO;IAC1B,eAAe,EAAE,OAAO;CAChB,CAAA;AAIV;;;;;GAKG;AACH,MAAM,OAAO,sBAAuB,SAAQ,KAAK;IAC1B;IAArB,YAAqB,QAAoC;QACvD,KAAK,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAA;QADT,aAAQ,GAAR,QAAQ,CAA4B;QAEvD,IAAI,CAAC,IAAI,GAAG,wBAAwB,CAAA;IACtC,CAAC;CACF;AAED,+DAA+D;AAC/D,MAAM,CAAC,KAAK,UAAU,YAAY,CAAI,IAAqB,EAAE,IAAsB;IACjF,IAAI,CAAC;QACH,OAAO,MAAM,IAAI,EAAE,CAAA;IACrB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,SAAS,CAAC,KAAK,EAAE,IAAI,CAAC;YAAE,MAAM,IAAI,sBAAsB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;QACpE,MAAM,KAAK,CAAA;IACb,CAAC;AACH,CAAC;AAED,qEAAqE;AACrE,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,KAAiC;IAEjC,MAAM,OAAO,GAAsB,EAAE,CAAA;IACrC,KAAK,MAAM,IAAI,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;QAClC,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,IAAI,CAAC,CAAA;QACpB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,CAAC;gBAAE,MAAM,KAAK,CAAA;YACxC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACpB,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,KAAc,EAAE,IAAY;IACpD,IAAI,CAAC,CAAC,KAAK,YAAY,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAC3C,MAAM,IAAI,GAAI,KAA+B,CAAC,IAAI,CAAA;IAClD,IAAI,IAAI,KAAK,sBAAsB,IAAI,IAAI,KAAK,kBAAkB;QAAE,OAAO,KAAK,CAAA;IAChF,OAAO,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,IAAI,GAAG,CAAC,CAAA;AAC5C,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,QAAoC;IAC9D,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,IAAI,IAAI,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAC1E,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;IACvD,OAAO;QACL,kBAAkB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG;QACrC,6EAA6E;QAC7E,6EAA6E;QAC7E,EAAE;QACF,4BAA4B,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE;QAC7C,EAAE;QACF,eAAe;QACf,EAAE;QACF,qBAAqB,GAAG,oBAAoB;KAC7C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACd,CAAC"}
@@ -0,0 +1,9 @@
1
+ import type { HttpClient } from './types.js';
2
+ /**
3
+ * Node's built-in `fetch`, with its defaults.
4
+ *
5
+ * This is the control case. It follows redirects, resolves names once,
6
+ * enforces no size cap and exposes no connect hook. The harness applies its
7
+ * own deadline so a probe cannot hang the run, and reports when it had to.
8
+ */
9
+ export declare const plainFetch: HttpClient;
@@ -0,0 +1,65 @@
1
+ /** How much of the body the harness keeps for canary detection. */
2
+ const SAMPLE_BYTES = 4096;
3
+ /**
4
+ * Node's built-in `fetch`, with its defaults.
5
+ *
6
+ * This is the control case. It follows redirects, resolves names once,
7
+ * enforces no size cap and exposes no connect hook. The harness applies its
8
+ * own deadline so a probe cannot hang the run, and reports when it had to.
9
+ */
10
+ export const plainFetch = {
11
+ name: 'node-fetch (global, defaults)',
12
+ supportsConnectHook: false,
13
+ async fetch(url, options = {}) {
14
+ const timeoutMs = options.timeoutMs ?? 6000;
15
+ const controller = new AbortController();
16
+ const deadline = setTimeout(() => controller.abort(), timeoutMs);
17
+ try {
18
+ const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
19
+ const { bytesRead, sample, stoppedByHarness } = await drain(response, controller.signal);
20
+ return {
21
+ status: response.status,
22
+ bytesRead,
23
+ bodySample: sample,
24
+ // Built-in fetch follows redirects inside itself, so the hops it took
25
+ // are not observable from here. That opacity is the finding.
26
+ hops: [url],
27
+ stoppedByHarness,
28
+ };
29
+ }
30
+ finally {
31
+ clearTimeout(deadline);
32
+ }
33
+ },
34
+ };
35
+ async function drain(response, signal) {
36
+ if (response.body === null)
37
+ return { bytesRead: 0, sample: '', stoppedByHarness: false };
38
+ const reader = response.body.getReader();
39
+ const sample = [];
40
+ let sampleBytes = 0;
41
+ let bytesRead = 0;
42
+ try {
43
+ for (;;) {
44
+ const { done, value } = await reader.read();
45
+ if (done)
46
+ break;
47
+ bytesRead += value.byteLength;
48
+ if (sampleBytes < SAMPLE_BYTES) {
49
+ sample.push(value.subarray(0, SAMPLE_BYTES - sampleBytes));
50
+ sampleBytes += Math.min(value.byteLength, SAMPLE_BYTES - sampleBytes);
51
+ }
52
+ }
53
+ }
54
+ catch (error) {
55
+ if (signal.aborted) {
56
+ return { bytesRead, sample: decode(sample), stoppedByHarness: true };
57
+ }
58
+ throw error;
59
+ }
60
+ return { bytesRead, sample: decode(sample), stoppedByHarness: false };
61
+ }
62
+ function decode(chunks) {
63
+ return Buffer.concat(chunks).toString('utf8');
64
+ }
65
+ //# sourceMappingURL=plainFetch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plainFetch.js","sourceRoot":"","sources":["../../src/adapters/plainFetch.ts"],"names":[],"mappings":"AAEA,mEAAmE;AACnE,MAAM,YAAY,GAAG,IAAI,CAAA;AAEzB;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAe;IACpC,IAAI,EAAE,+BAA+B;IACrC,mBAAmB,EAAE,KAAK;IAE1B,KAAK,CAAC,KAAK,CAAC,GAAW,EAAE,UAAyB,EAAE;QAClD,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,IAAI,CAAA;QAC3C,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAA;QACxC,MAAM,QAAQ,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,SAAS,CAAC,CAAA;QAEhE,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAA;YACpF,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,GAAG,MAAM,KAAK,CAAC,QAAQ,EAAE,UAAU,CAAC,MAAM,CAAC,CAAA;YAExF,OAAO;gBACL,MAAM,EAAE,QAAQ,CAAC,MAAM;gBACvB,SAAS;gBACT,UAAU,EAAE,MAAM;gBAClB,sEAAsE;gBACtE,6DAA6D;gBAC7D,IAAI,EAAE,CAAC,GAAG,CAAC;gBACX,gBAAgB;aACjB,CAAA;QACH,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,QAAQ,CAAC,CAAA;QACxB,CAAC;IACH,CAAC;CACF,CAAA;AAED,KAAK,UAAU,KAAK,CAClB,QAAkB,EAClB,MAAmB;IAEnB,IAAI,QAAQ,CAAC,IAAI,KAAK,IAAI;QAAE,OAAO,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,gBAAgB,EAAE,KAAK,EAAE,CAAA;IAExF,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,CAAA;IACxC,MAAM,MAAM,GAAiB,EAAE,CAAA;IAC/B,IAAI,WAAW,GAAG,CAAC,CAAA;IACnB,IAAI,SAAS,GAAG,CAAC,CAAA;IAEjB,IAAI,CAAC;QACH,SAAS,CAAC;YACR,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAA;YAC3C,IAAI,IAAI;gBAAE,MAAK;YACf,SAAS,IAAI,KAAK,CAAC,UAAU,CAAA;YAC7B,IAAI,WAAW,GAAG,YAAY,EAAE,CAAC;gBAC/B,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,YAAY,GAAG,WAAW,CAAC,CAAC,CAAA;gBAC1D,WAAW,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,EAAE,YAAY,GAAG,WAAW,CAAC,CAAA;YACvE,CAAC;QACH,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAA;QACtE,CAAC;QACD,MAAM,KAAK,CAAA;IACb,CAAC;IAED,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,gBAAgB,EAAE,KAAK,EAAE,CAAA;AACvE,CAAC;AAED,SAAS,MAAM,CAAC,MAAoB;IAClC,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;AAC/C,CAAC"}
@@ -0,0 +1,55 @@
1
+ import type { LookupFunction } from 'node:net';
2
+ /** Why a client refused a request. Each reason names the control that fired. */
3
+ export type RefusalReason = 'forbidden-address' | 'too-many-redirects' | 'redirect-not-allowed' | 'body-too-large' | 'deadline' | 'scheme-not-allowed';
4
+ /**
5
+ * A refusal the CLIENT decided on.
6
+ *
7
+ * This is the type that separates a control from luck. A connection error is
8
+ * not one of these, however convenient its timing.
9
+ */
10
+ export declare class PolicyRefusal extends Error {
11
+ readonly reason: RefusalReason;
12
+ readonly detail: string;
13
+ constructor(reason: RefusalReason, detail: string);
14
+ }
15
+ export interface FetchOutcome {
16
+ status: number;
17
+ /** Bytes of body the client accepted, after any decompression. */
18
+ bytesRead: number;
19
+ /** First bytes of the body, kept so the checker can look for a canary. */
20
+ bodySample: string;
21
+ /** Observed URLs in order. Opaque clients may expose only entry and final URLs. */
22
+ hops: string[];
23
+ /** True when the harness stopped the read because the client would not. */
24
+ stoppedByHarness: boolean;
25
+ }
26
+ export interface ClientOptions {
27
+ /**
28
+ * Replaces name resolution for this request.
29
+ *
30
+ * A probe uses this to answer a public address to the check and a loopback
31
+ * address to the socket, which is DNS rebinding in one process.
32
+ */
33
+ lookup?: LookupFunction;
34
+ timeoutMs?: number;
35
+ maxBytes?: number;
36
+ /**
37
+ * One origin that is treated as the public internet for the FIRST hop only.
38
+ *
39
+ * The range lives on loopback, so without this a guard refuses the range
40
+ * itself and every probe passes while testing nothing. Redirect targets are
41
+ * never covered by it. A production fetcher has no such option.
42
+ */
43
+ trustedOrigin?: string;
44
+ }
45
+ export interface HttpClient {
46
+ readonly name: string;
47
+ /**
48
+ * Whether the adapter can exercise this lab's injected-resolver probe.
49
+ * False does not mean the underlying client lacks its own DNS protection.
50
+ */
51
+ readonly supportsConnectHook: boolean;
52
+ /** False when the adapter cannot exempt the lab's loopback entry origin. */
53
+ readonly supportsLoopbackOrigin?: boolean;
54
+ fetch(url: string, options?: ClientOptions): Promise<FetchOutcome>;
55
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A refusal the CLIENT decided on.
3
+ *
4
+ * This is the type that separates a control from luck. A connection error is
5
+ * not one of these, however convenient its timing.
6
+ */
7
+ export class PolicyRefusal extends Error {
8
+ reason;
9
+ detail;
10
+ constructor(reason, detail) {
11
+ super(`${reason}: ${detail}`);
12
+ this.reason = reason;
13
+ this.detail = detail;
14
+ this.name = 'PolicyRefusal';
15
+ }
16
+ }
17
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/adapters/types.ts"],"names":[],"mappings":"AAWA;;;;;GAKG;AACH,MAAM,OAAO,aAAc,SAAQ,KAAK;IAE3B;IACA;IAFX,YACW,MAAqB,EACrB,MAAc;QAEvB,KAAK,CAAC,GAAG,MAAM,KAAK,MAAM,EAAE,CAAC,CAAA;QAHpB,WAAM,GAAN,MAAM,CAAe;QACrB,WAAM,GAAN,MAAM,CAAQ;QAGvB,IAAI,CAAC,IAAI,GAAG,eAAe,CAAA;IAC7B,CAAC;CACF"}
@@ -0,0 +1,7 @@
1
+ import { type HttpClient } from './types.js';
2
+ /**
3
+ * Use the package's real dispatcher and exported bounded body reader.
4
+ *
5
+ * The package is loaded on the first request, so ssrf-lab runs without it.
6
+ */
7
+ export declare const vercelGuardedFetch: HttpClient;