@dwk/websub 0.1.0-beta.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.
- package/LICENSE +15 -0
- package/README.md +155 -0
- package/dist/config.d.ts +122 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +96 -0
- package/dist/config.js.map +1 -0
- package/dist/consumer.d.ts +39 -0
- package/dist/consumer.d.ts.map +1 -0
- package/dist/consumer.js +146 -0
- package/dist/consumer.js.map +1 -0
- package/dist/distribute.d.ts +109 -0
- package/dist/distribute.d.ts.map +1 -0
- package/dist/distribute.js +140 -0
- package/dist/distribute.js.map +1 -0
- package/dist/fetch.d.ts +28 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +73 -0
- package/dist/fetch.js.map +1 -0
- package/dist/handler.d.ts +43 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +127 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +54 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +52 -0
- package/dist/log.js.map +1 -0
- package/dist/queue.d.ts +38 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +12 -0
- package/dist/queue.js.map +1 -0
- package/dist/safe-fetch.d.ts +101 -0
- package/dist/safe-fetch.d.ts.map +1 -0
- package/dist/safe-fetch.js +354 -0
- package/dist/safe-fetch.js.map +1 -0
- package/dist/store.d.ts +61 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +110 -0
- package/dist/store.js.map +1 -0
- package/dist/validate.d.ts +67 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +106 -0
- package/dist/validate.js.map +1 -0
- package/dist/verify.d.ts +85 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +149 -0
- package/dist/verify.js.map +1 -0
- package/package.json +46 -0
- package/src/config.ts +199 -0
- package/src/consumer.ts +187 -0
- package/src/distribute.ts +257 -0
- package/src/fetch.ts +84 -0
- package/src/handler.ts +163 -0
- package/src/index.ts +98 -0
- package/src/log.ts +56 -0
- package/src/queue.ts +40 -0
- package/src/safe-fetch.ts +412 -0
- package/src/store.ts +190 -0
- package/src/validate.ts +179 -0
- package/src/verify.ts +229 -0
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — structured observability event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* Like the rest of the cohort, this package's logging and metrics are opt-in via
|
|
5
|
+
* an injected {@link Logger} and {@link Metrics} (see `@dwk/log`) and **share one
|
|
6
|
+
* vocabulary**: the same dotted event name is passed to `logger.*(...)` and
|
|
7
|
+
* `metrics.count(...)` so a log line and its counter line up. Event names are
|
|
8
|
+
* stable, dotted, and queryable. Security-relevant events (a blocked SSRF
|
|
9
|
+
* attempt, a verification rejection) are first-class so being actively probed
|
|
10
|
+
* produces a distinct signal rather than silence. See `spec/observability.md`.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Stable event names emitted by `@dwk/websub`. Fields logged alongside each
|
|
16
|
+
* event use `hostFromUrl` for any URL so an attacker-supplied path/query is
|
|
17
|
+
* never recorded; secrets and bodies are never logged.
|
|
18
|
+
*/
|
|
19
|
+
export declare const WebSubLogEvent: {
|
|
20
|
+
/**
|
|
21
|
+
* An outbound fetch was refused on SSRF grounds. Fields: `reason`
|
|
22
|
+
* (machine-readable cause), `host` (sanitized, when known).
|
|
23
|
+
*/
|
|
24
|
+
readonly SsrfBlocked: "websub.ssrf.blocked";
|
|
25
|
+
/** A subscribe/unsubscribe request passed validation and was enqueued. Fields: `mode`, `callbackHost`, `topicHost`. */
|
|
26
|
+
readonly RequestAccepted: "websub.request.accepted";
|
|
27
|
+
/** A subscribe/unsubscribe request was rejected at validation. Field: `reason`. */
|
|
28
|
+
readonly RequestRejected: "websub.request.rejected";
|
|
29
|
+
/** A publish ping passed validation and distribution was enqueued. Field: `topicHost`. */
|
|
30
|
+
readonly PublishAccepted: "websub.publish.accepted";
|
|
31
|
+
/** A publish ping was rejected at validation. Field: `reason`. */
|
|
32
|
+
readonly PublishRejected: "websub.publish.rejected";
|
|
33
|
+
/** Intent verification of a callback completed. Fields: `mode`, `callbackHost`, `confirmed`, `status`. */
|
|
34
|
+
readonly VerifyCompleted: "websub.verify.completed";
|
|
35
|
+
/** The verification GET to the callback failed/was blocked. Field: `error`. */
|
|
36
|
+
readonly VerifyFetchFailed: "websub.verify.fetch_failed";
|
|
37
|
+
/** A subscription was activated (verified subscribe). Fields: `callbackHost`, `topicHost`, `leaseSeconds`. */
|
|
38
|
+
readonly SubscriptionActivated: "websub.subscription.activated";
|
|
39
|
+
/** A subscription was removed (verified unsubscribe or pruned). Fields: `callbackHost`, `topicHost`, `reason`. */
|
|
40
|
+
readonly SubscriptionRemoved: "websub.subscription.removed";
|
|
41
|
+
/** A subscription was denied (`hub.mode=denied`). Emitted whether or not the callback was reachable; `notified` records whether it accepted the GET. Fields: `callbackHost`, `topicHost`, `notified`, `reason`. */
|
|
42
|
+
readonly SubscriptionDenied: "websub.subscription.denied";
|
|
43
|
+
/** A content-distribution delivery to one subscriber finished. Fields: `callbackHost`, `delivered`, `status`. */
|
|
44
|
+
readonly DeliveryCompleted: "websub.delivery.completed";
|
|
45
|
+
/** A distribution job could not fetch the topic content. Fields: `topicHost`, `status`. */
|
|
46
|
+
readonly TopicFetchFailed: "websub.topic.fetch_failed";
|
|
47
|
+
/** The topic declared no `Content-Type` and no fallback was configured, so distribution was refused rather than mislabeled (WebSub §7). Fields: `topicHost`, `status`. */
|
|
48
|
+
readonly TopicContentTypeMissing: "websub.topic.content_type_missing";
|
|
49
|
+
/** A queue message threw and is being retried. Fields: `kind`, `error`. */
|
|
50
|
+
readonly QueueRetry: "websub.queue.retry";
|
|
51
|
+
};
|
|
52
|
+
/** Union of the event-name string literals in {@link WebSubLogEvent}. */
|
|
53
|
+
export type WebSubLogEvent = (typeof WebSubLogEvent)[keyof typeof WebSubLogEvent];
|
|
54
|
+
//# sourceMappingURL=log.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log.d.ts","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB;;;OAGG;;IAEH,uHAAuH;;IAEvH,mFAAmF;;IAEnF,0FAA0F;;IAE1F,kEAAkE;;IAElE,0GAA0G;;IAE1G,+EAA+E;;IAE/E,8GAA8G;;IAE9G,kHAAkH;;IAElH,mNAAmN;;IAEnN,iHAAiH;;IAEjH,2FAA2F;;IAE3F,0KAA0K;;IAE1K,2EAA2E;;CAEnE,CAAC;AAEX,yEAAyE;AACzE,MAAM,MAAM,cAAc,GACxB,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,OAAO,cAAc,CAAC,CAAC"}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — structured observability event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* Like the rest of the cohort, this package's logging and metrics are opt-in via
|
|
5
|
+
* an injected {@link Logger} and {@link Metrics} (see `@dwk/log`) and **share one
|
|
6
|
+
* vocabulary**: the same dotted event name is passed to `logger.*(...)` and
|
|
7
|
+
* `metrics.count(...)` so a log line and its counter line up. Event names are
|
|
8
|
+
* stable, dotted, and queryable. Security-relevant events (a blocked SSRF
|
|
9
|
+
* attempt, a verification rejection) are first-class so being actively probed
|
|
10
|
+
* produces a distinct signal rather than silence. See `spec/observability.md`.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Stable event names emitted by `@dwk/websub`. Fields logged alongside each
|
|
16
|
+
* event use `hostFromUrl` for any URL so an attacker-supplied path/query is
|
|
17
|
+
* never recorded; secrets and bodies are never logged.
|
|
18
|
+
*/
|
|
19
|
+
export const WebSubLogEvent = {
|
|
20
|
+
/**
|
|
21
|
+
* An outbound fetch was refused on SSRF grounds. Fields: `reason`
|
|
22
|
+
* (machine-readable cause), `host` (sanitized, when known).
|
|
23
|
+
*/
|
|
24
|
+
SsrfBlocked: "websub.ssrf.blocked",
|
|
25
|
+
/** A subscribe/unsubscribe request passed validation and was enqueued. Fields: `mode`, `callbackHost`, `topicHost`. */
|
|
26
|
+
RequestAccepted: "websub.request.accepted",
|
|
27
|
+
/** A subscribe/unsubscribe request was rejected at validation. Field: `reason`. */
|
|
28
|
+
RequestRejected: "websub.request.rejected",
|
|
29
|
+
/** A publish ping passed validation and distribution was enqueued. Field: `topicHost`. */
|
|
30
|
+
PublishAccepted: "websub.publish.accepted",
|
|
31
|
+
/** A publish ping was rejected at validation. Field: `reason`. */
|
|
32
|
+
PublishRejected: "websub.publish.rejected",
|
|
33
|
+
/** Intent verification of a callback completed. Fields: `mode`, `callbackHost`, `confirmed`, `status`. */
|
|
34
|
+
VerifyCompleted: "websub.verify.completed",
|
|
35
|
+
/** The verification GET to the callback failed/was blocked. Field: `error`. */
|
|
36
|
+
VerifyFetchFailed: "websub.verify.fetch_failed",
|
|
37
|
+
/** A subscription was activated (verified subscribe). Fields: `callbackHost`, `topicHost`, `leaseSeconds`. */
|
|
38
|
+
SubscriptionActivated: "websub.subscription.activated",
|
|
39
|
+
/** A subscription was removed (verified unsubscribe or pruned). Fields: `callbackHost`, `topicHost`, `reason`. */
|
|
40
|
+
SubscriptionRemoved: "websub.subscription.removed",
|
|
41
|
+
/** A subscription was denied (`hub.mode=denied`). Emitted whether or not the callback was reachable; `notified` records whether it accepted the GET. Fields: `callbackHost`, `topicHost`, `notified`, `reason`. */
|
|
42
|
+
SubscriptionDenied: "websub.subscription.denied",
|
|
43
|
+
/** A content-distribution delivery to one subscriber finished. Fields: `callbackHost`, `delivered`, `status`. */
|
|
44
|
+
DeliveryCompleted: "websub.delivery.completed",
|
|
45
|
+
/** A distribution job could not fetch the topic content. Fields: `topicHost`, `status`. */
|
|
46
|
+
TopicFetchFailed: "websub.topic.fetch_failed",
|
|
47
|
+
/** The topic declared no `Content-Type` and no fallback was configured, so distribution was refused rather than mislabeled (WebSub §7). Fields: `topicHost`, `status`. */
|
|
48
|
+
TopicContentTypeMissing: "websub.topic.content_type_missing",
|
|
49
|
+
/** A queue message threw and is being retried. Fields: `kind`, `error`. */
|
|
50
|
+
QueueRetry: "websub.queue.retry",
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=log.js.map
|
package/dist/log.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log.js","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B;;;OAGG;IACH,WAAW,EAAE,qBAAqB;IAClC,uHAAuH;IACvH,eAAe,EAAE,yBAAyB;IAC1C,mFAAmF;IACnF,eAAe,EAAE,yBAAyB;IAC1C,0FAA0F;IAC1F,eAAe,EAAE,yBAAyB;IAC1C,kEAAkE;IAClE,eAAe,EAAE,yBAAyB;IAC1C,0GAA0G;IAC1G,eAAe,EAAE,yBAAyB;IAC1C,+EAA+E;IAC/E,iBAAiB,EAAE,4BAA4B;IAC/C,8GAA8G;IAC9G,qBAAqB,EAAE,+BAA+B;IACtD,kHAAkH;IAClH,mBAAmB,EAAE,6BAA6B;IAClD,mNAAmN;IACnN,kBAAkB,EAAE,4BAA4B;IAChD,iHAAiH;IACjH,iBAAiB,EAAE,2BAA2B;IAC9C,2FAA2F;IAC3F,gBAAgB,EAAE,2BAA2B;IAC7C,0KAA0K;IAC1K,uBAAuB,EAAE,mCAAmC;IAC5D,2EAA2E;IAC3E,UAAU,EAAE,oBAAoB;CACxB,CAAC"}
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — queued job shapes.
|
|
3
|
+
*
|
|
4
|
+
* The hub does its slow, failure-prone work — the verification-of-intent GET and
|
|
5
|
+
* content-distribution fan-out — off the request path, on a queue with retries
|
|
6
|
+
* and backoff (`spec/packages/websub.md`). Two job kinds flow through the one
|
|
7
|
+
* queue, discriminated by `kind`.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Verify a subscriber's intent: GET the callback with `hub.challenge` and,
|
|
13
|
+
* on a confirming 2xx echo, activate (subscribe) or remove (unsubscribe) the
|
|
14
|
+
* subscription. Carries everything needed to persist the subscription so the
|
|
15
|
+
* store write happens only after verification succeeds.
|
|
16
|
+
*/
|
|
17
|
+
export interface VerifyJob {
|
|
18
|
+
readonly kind: "verify";
|
|
19
|
+
readonly mode: "subscribe" | "unsubscribe";
|
|
20
|
+
readonly callback: string;
|
|
21
|
+
readonly topic: string;
|
|
22
|
+
/** Lease to grant on a confirmed subscribe (seconds); ignored for unsubscribe. */
|
|
23
|
+
readonly leaseSeconds: number;
|
|
24
|
+
/** Optional HMAC secret registered by the subscriber, stored on activation. */
|
|
25
|
+
readonly secret?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Distribute a topic's current content to every active subscriber: fetch the
|
|
29
|
+
* topic, then POST the body (signed per-subscriber when a secret is set) to each
|
|
30
|
+
* verified callback.
|
|
31
|
+
*/
|
|
32
|
+
export interface DistributeJob {
|
|
33
|
+
readonly kind: "distribute";
|
|
34
|
+
readonly topic: string;
|
|
35
|
+
}
|
|
36
|
+
/** A job on the WebSub queue: either intent verification or content distribution. */
|
|
37
|
+
export type WebSubJob = VerifyJob | DistributeJob;
|
|
38
|
+
//# sourceMappingURL=queue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,WAAW,GAAG,aAAa,CAAC;IAC3C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,kFAAkF;IAClF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,qFAAqF;AACrF,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC"}
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — queued job shapes.
|
|
3
|
+
*
|
|
4
|
+
* The hub does its slow, failure-prone work — the verification-of-intent GET and
|
|
5
|
+
* content-distribution fan-out — off the request path, on a queue with retries
|
|
6
|
+
* and backoff (`spec/packages/websub.md`). Two job kinds flow through the one
|
|
7
|
+
* queue, discriminated by `kind`.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=queue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queue.js","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — SSRF-safe outbound fetch.
|
|
3
|
+
*
|
|
4
|
+
* A WebSub hub fetches attacker-supplied URLs: the verification GET hits a
|
|
5
|
+
* subscriber-chosen `hub.callback`, and content distribution POSTs to every
|
|
6
|
+
* registered callback. Without guardrails a subscriber could point a callback at
|
|
7
|
+
* the Worker's own network — loopback, the link-local cloud metadata IP
|
|
8
|
+
* (`169.254.169.254`), or RFC 1918 ranges — to exfiltrate credentials or probe
|
|
9
|
+
* internal services. This module is the single choke point every outbound fetch
|
|
10
|
+
* in the package goes through. It:
|
|
11
|
+
*
|
|
12
|
+
* 1. rejects URLs whose host is a private, loopback, link-local, or otherwise
|
|
13
|
+
* non-public address (or a name like `localhost` / `*.internal`),
|
|
14
|
+
* 2. follows redirects manually, re-validating the host on every `Location`
|
|
15
|
+
* hop so a public-looking host cannot 302 to an internal one, and capping
|
|
16
|
+
* the hop count, and
|
|
17
|
+
* 3. bounds the whole operation with a single timeout, so a slow-loris callback
|
|
18
|
+
* cannot pin a queue-consumer invocation.
|
|
19
|
+
*
|
|
20
|
+
* Host validation is purely syntactic on the URL host — DNS rebinding (a name
|
|
21
|
+
* that resolves to a private IP) is out of scope here, as the Workers runtime
|
|
22
|
+
* does not expose name resolution to user code. See `spec/packages/websub.md`
|
|
23
|
+
* and `spec/non-functional-requirements.md`.
|
|
24
|
+
*
|
|
25
|
+
* @packageDocumentation
|
|
26
|
+
*/
|
|
27
|
+
import { type Logger, type Metrics } from "@dwk/log";
|
|
28
|
+
import type { FetchLike } from "./fetch";
|
|
29
|
+
/** Default cap on redirect hops before a fetch is abandoned. */
|
|
30
|
+
export declare const DEFAULT_MAX_REDIRECTS = 5;
|
|
31
|
+
/** Default overall timeout (ms) bounding a fetch, redirects included. */
|
|
32
|
+
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
33
|
+
/**
|
|
34
|
+
* Machine-readable cause of an {@link SsrfError}, suitable for logging as a
|
|
35
|
+
* structured field (no free-text parsing required).
|
|
36
|
+
*/
|
|
37
|
+
export type SsrfReason = "invalid_url" | "disallowed_scheme" | "blocked_host" | "too_many_redirects";
|
|
38
|
+
/**
|
|
39
|
+
* Raised when a request is refused on SSRF grounds (blocked host, disallowed
|
|
40
|
+
* scheme, or too many redirects). Callers catch this exactly like a network
|
|
41
|
+
* failure — a blocked attempt looks the same as an unreachable host — but
|
|
42
|
+
* {@link safeFetch} logs it first (event `websub.ssrf.blocked`) so the single
|
|
43
|
+
* most security-relevant event in the package still produces a signal.
|
|
44
|
+
*
|
|
45
|
+
* Carries the structured {@link reason} and, when known, the sanitized
|
|
46
|
+
* {@link host} so a logger can record them as queryable fields.
|
|
47
|
+
*/
|
|
48
|
+
export declare class SsrfError extends Error {
|
|
49
|
+
/** Machine-readable cause. */
|
|
50
|
+
readonly reason: SsrfReason;
|
|
51
|
+
/** The offending host (name plus any port), when one is known. */
|
|
52
|
+
readonly host?: string;
|
|
53
|
+
constructor(message: string, reason: SsrfReason, host?: string);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Decide whether a URL host is private, loopback, link-local, or otherwise
|
|
57
|
+
* not safe to fetch from inside the Worker's network. Accepts the raw
|
|
58
|
+
* `URL.hostname` form (IPv6 hosts may arrive wrapped in `[...]`).
|
|
59
|
+
*/
|
|
60
|
+
export declare function isPrivateOrReservedHost(hostname: string): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Validate that `rawUrl` is a fetchable public `http(s)` URL, returning the
|
|
63
|
+
* parsed {@link URL}. Throws {@link SsrfError} for an unparseable URL, a
|
|
64
|
+
* non-`http(s)` scheme (e.g. `file:`, `javascript:`), or a private/reserved
|
|
65
|
+
* host.
|
|
66
|
+
*/
|
|
67
|
+
export declare function assertPublicUrl(rawUrl: string): URL;
|
|
68
|
+
/** Tunables for {@link safeFetch}. */
|
|
69
|
+
export interface SafeFetchOptions {
|
|
70
|
+
/** Maximum redirect hops to follow (default {@link DEFAULT_MAX_REDIRECTS}). */
|
|
71
|
+
readonly maxRedirects?: number;
|
|
72
|
+
/** Overall timeout in ms, redirects included (default {@link DEFAULT_TIMEOUT_MS}). */
|
|
73
|
+
readonly timeoutMs?: number;
|
|
74
|
+
/** Logger for SSRF blocks; defaults to a no-op (see `@dwk/log`). */
|
|
75
|
+
readonly logger?: Logger;
|
|
76
|
+
/** Metrics sink for SSRF-block counters; defaults to a no-op (see `@dwk/log`). */
|
|
77
|
+
readonly metrics?: Metrics;
|
|
78
|
+
}
|
|
79
|
+
/** A completed {@link safeFetch}: the final response and the URL it came from. */
|
|
80
|
+
export interface SafeFetchResult {
|
|
81
|
+
/** The final, non-redirect response. */
|
|
82
|
+
readonly response: Response;
|
|
83
|
+
/** The fully-resolved URL the response came from. */
|
|
84
|
+
readonly url: string;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Fetch `rawUrl` through `doFetch` with SSRF guardrails.
|
|
88
|
+
*
|
|
89
|
+
* The initial host and every redirect target are validated with
|
|
90
|
+
* {@link assertPublicUrl}; redirects are followed manually (`redirect:
|
|
91
|
+
* "manual"`) up to `maxRedirects` hops; and a single {@link AbortSignal.timeout}
|
|
92
|
+
* bounds the whole chain. The request method, headers, and body from `init` are
|
|
93
|
+
* preserved across hops — a redirected `POST` notification re-POSTs to the
|
|
94
|
+
* (re-validated) new location rather than silently degrading to `GET`.
|
|
95
|
+
*
|
|
96
|
+
* @throws {SsrfError} when a host is blocked, a scheme is disallowed, or the
|
|
97
|
+
* redirect cap is exceeded. Other failures (network, timeout) propagate as the
|
|
98
|
+
* underlying fetch rejection. Callers treat any throw as "fetch failed".
|
|
99
|
+
*/
|
|
100
|
+
export declare function safeFetch(doFetch: FetchLike, rawUrl: string, init: RequestInit, options?: SafeFetchOptions): Promise<SafeFetchResult>;
|
|
101
|
+
//# sourceMappingURL=safe-fetch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safe-fetch.d.ts","sourceRoot":"","sources":["../src/safe-fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAA2B,KAAK,MAAM,EAAE,KAAK,OAAO,EAAE,MAAM,UAAU,CAAC;AAC9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAGzC,gEAAgE;AAChE,eAAO,MAAM,qBAAqB,IAAI,CAAC;AACvC,yEAAyE;AACzE,eAAO,MAAM,kBAAkB,QAAS,CAAC;AAKzC;;;GAGG;AACH,MAAM,MAAM,UAAU,GAClB,aAAa,GACb,mBAAmB,GACnB,cAAc,GACd,oBAAoB,CAAC;AAEzB;;;;;;;;;GASG;AACH,qBAAa,SAAU,SAAQ,KAAK;IAClC,8BAA8B;IAC9B,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBACX,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM;CAM/D;AAuKD;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAsBjE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAsBnD;AAED,sCAAsC;AACtC,MAAM,WAAW,gBAAgB;IAC/B,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,sFAAsF;IACtF,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,oEAAoE;IACpE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,kFAAkF;AAClF,MAAM,WAAW,eAAe;IAC9B,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,qDAAqD;IACrD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,SAAS,CAC7B,OAAO,EAAE,SAAS,EAClB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,WAAW,EACjB,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,eAAe,CAAC,CA4E1B"}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — SSRF-safe outbound fetch.
|
|
3
|
+
*
|
|
4
|
+
* A WebSub hub fetches attacker-supplied URLs: the verification GET hits a
|
|
5
|
+
* subscriber-chosen `hub.callback`, and content distribution POSTs to every
|
|
6
|
+
* registered callback. Without guardrails a subscriber could point a callback at
|
|
7
|
+
* the Worker's own network — loopback, the link-local cloud metadata IP
|
|
8
|
+
* (`169.254.169.254`), or RFC 1918 ranges — to exfiltrate credentials or probe
|
|
9
|
+
* internal services. This module is the single choke point every outbound fetch
|
|
10
|
+
* in the package goes through. It:
|
|
11
|
+
*
|
|
12
|
+
* 1. rejects URLs whose host is a private, loopback, link-local, or otherwise
|
|
13
|
+
* non-public address (or a name like `localhost` / `*.internal`),
|
|
14
|
+
* 2. follows redirects manually, re-validating the host on every `Location`
|
|
15
|
+
* hop so a public-looking host cannot 302 to an internal one, and capping
|
|
16
|
+
* the hop count, and
|
|
17
|
+
* 3. bounds the whole operation with a single timeout, so a slow-loris callback
|
|
18
|
+
* cannot pin a queue-consumer invocation.
|
|
19
|
+
*
|
|
20
|
+
* Host validation is purely syntactic on the URL host — DNS rebinding (a name
|
|
21
|
+
* that resolves to a private IP) is out of scope here, as the Workers runtime
|
|
22
|
+
* does not expose name resolution to user code. See `spec/packages/websub.md`
|
|
23
|
+
* and `spec/non-functional-requirements.md`.
|
|
24
|
+
*
|
|
25
|
+
* @packageDocumentation
|
|
26
|
+
*/
|
|
27
|
+
import { noopLogger, noopMetrics } from "@dwk/log";
|
|
28
|
+
import { WebSubLogEvent } from "./log";
|
|
29
|
+
/** Default cap on redirect hops before a fetch is abandoned. */
|
|
30
|
+
export const DEFAULT_MAX_REDIRECTS = 5;
|
|
31
|
+
/** Default overall timeout (ms) bounding a fetch, redirects included. */
|
|
32
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
33
|
+
/** HTTP status codes that carry a `Location` we may follow. */
|
|
34
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
35
|
+
/**
|
|
36
|
+
* Raised when a request is refused on SSRF grounds (blocked host, disallowed
|
|
37
|
+
* scheme, or too many redirects). Callers catch this exactly like a network
|
|
38
|
+
* failure — a blocked attempt looks the same as an unreachable host — but
|
|
39
|
+
* {@link safeFetch} logs it first (event `websub.ssrf.blocked`) so the single
|
|
40
|
+
* most security-relevant event in the package still produces a signal.
|
|
41
|
+
*
|
|
42
|
+
* Carries the structured {@link reason} and, when known, the sanitized
|
|
43
|
+
* {@link host} so a logger can record them as queryable fields.
|
|
44
|
+
*/
|
|
45
|
+
export class SsrfError extends Error {
|
|
46
|
+
/** Machine-readable cause. */
|
|
47
|
+
reason;
|
|
48
|
+
/** The offending host (name plus any port), when one is known. */
|
|
49
|
+
host;
|
|
50
|
+
constructor(message, reason, host) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "SsrfError";
|
|
53
|
+
this.reason = reason;
|
|
54
|
+
this.host = host;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Parse a canonical dotted-decimal IPv4 host into its four octets. */
|
|
58
|
+
function parseIPv4(host) {
|
|
59
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
60
|
+
if (match === null) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const octets = [];
|
|
64
|
+
for (let group = 1; group <= 4; group++) {
|
|
65
|
+
const part = match[group];
|
|
66
|
+
if (part === undefined) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const octet = Number.parseInt(part, 10);
|
|
70
|
+
if (octet > 255) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
octets.push(octet);
|
|
74
|
+
}
|
|
75
|
+
return octets;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* True when `octets` falls in a range that must never be fetched from inside
|
|
79
|
+
* the Worker's network: this-network, loopback, link-local (incl. the cloud
|
|
80
|
+
* metadata IP), the RFC 1918 private blocks, CGNAT, IETF protocol/benchmark
|
|
81
|
+
* assignments, and the multicast/reserved/broadcast space.
|
|
82
|
+
*/
|
|
83
|
+
function isPrivateIPv4(octets) {
|
|
84
|
+
const [a, b, c] = octets;
|
|
85
|
+
if (a === 0)
|
|
86
|
+
return true; // 0.0.0.0/8 ("this network", incl. 0.0.0.0)
|
|
87
|
+
if (a === 10)
|
|
88
|
+
return true; // 10.0.0.0/8 private
|
|
89
|
+
if (a === 127)
|
|
90
|
+
return true; // 127.0.0.0/8 loopback
|
|
91
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
92
|
+
return true; // 100.64.0.0/10 CGNAT
|
|
93
|
+
if (a === 169 && b === 254)
|
|
94
|
+
return true; // 169.254.0.0/16 link-local (metadata)
|
|
95
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
96
|
+
return true; // 172.16.0.0/12 private
|
|
97
|
+
if (a === 192 && b === 0 && c === 0)
|
|
98
|
+
return true; // 192.0.0.0/24 IETF protocol
|
|
99
|
+
if (a === 192 && b === 0 && c === 2)
|
|
100
|
+
return true; // 192.0.2.0/24 TEST-NET-1
|
|
101
|
+
if (a === 192 && b === 168)
|
|
102
|
+
return true; // 192.168.0.0/16 private
|
|
103
|
+
if (a === 198 && b === 51 && c === 100)
|
|
104
|
+
return true; // 198.51.100.0/24 TEST-NET-2
|
|
105
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
106
|
+
return true; // 198.18.0.0/15 benchmark
|
|
107
|
+
if (a === 203 && b === 0 && c === 113)
|
|
108
|
+
return true; // 203.0.113.0/24 TEST-NET-3
|
|
109
|
+
if (a >= 224)
|
|
110
|
+
return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + broadcast
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Parse an IPv6 host (brackets already stripped) into its eight 16-bit groups,
|
|
115
|
+
* expanding `::` compression and any trailing embedded IPv4 literal. Returns
|
|
116
|
+
* `null` when `host` is not a valid IPv6 address.
|
|
117
|
+
*/
|
|
118
|
+
function parseIPv6(host) {
|
|
119
|
+
if (!host.includes(":")) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
let str = host;
|
|
123
|
+
// Fold a trailing embedded IPv4 literal (e.g. ::ffff:127.0.0.1) into two
|
|
124
|
+
// hex groups so the rest can be parsed uniformly.
|
|
125
|
+
const v4Match = /(?:^|:)((?:\d{1,3}\.){3}\d{1,3})$/.exec(str);
|
|
126
|
+
const v4Str = v4Match?.[1];
|
|
127
|
+
if (v4Str !== undefined) {
|
|
128
|
+
const v4 = parseIPv4(v4Str);
|
|
129
|
+
if (v4 === null) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const hi = ((v4[0] << 8) | v4[1]).toString(16);
|
|
133
|
+
const lo = ((v4[2] << 8) | v4[3]).toString(16);
|
|
134
|
+
str = `${str.slice(0, str.length - v4Str.length)}${hi}:${lo}`;
|
|
135
|
+
}
|
|
136
|
+
// At most one "::" compression marker is allowed.
|
|
137
|
+
if (str.indexOf("::") !== str.lastIndexOf("::")) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const toGroups = (part) => {
|
|
141
|
+
if (part === "") {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const groups = [];
|
|
145
|
+
for (const token of part.split(":")) {
|
|
146
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(token)) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
groups.push(Number.parseInt(token, 16));
|
|
150
|
+
}
|
|
151
|
+
return groups;
|
|
152
|
+
};
|
|
153
|
+
if (str.includes("::")) {
|
|
154
|
+
const parts = str.split("::");
|
|
155
|
+
const left = toGroups(parts[0] ?? "");
|
|
156
|
+
const right = toGroups(parts[1] ?? "");
|
|
157
|
+
if (left === null || right === null) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const missing = 8 - left.length - right.length;
|
|
161
|
+
if (missing < 1) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return [...left, ...new Array(missing).fill(0), ...right];
|
|
165
|
+
}
|
|
166
|
+
const all = toGroups(str);
|
|
167
|
+
if (all === null || all.length !== 8) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return all;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* True when `groups` (eight 16-bit values) is an IPv6 address that must never
|
|
174
|
+
* be fetched: unspecified, loopback, link-local, site-local, unique-local,
|
|
175
|
+
* multicast, the documentation prefix, or an address that embeds an IPv4
|
|
176
|
+
* (IPv4-mapped `::ffff:0:0/96`, deprecated IPv4-compatible `::/96`, or NAT64
|
|
177
|
+
* `64:ff9b::/96`) whose embedded IPv4 is itself private.
|
|
178
|
+
*/
|
|
179
|
+
function isPrivateIPv6(groups) {
|
|
180
|
+
const first = groups[0] ?? 0;
|
|
181
|
+
const g6 = groups[6] ?? 0;
|
|
182
|
+
const g7 = groups[7] ?? 0;
|
|
183
|
+
if (groups.every((group) => group === 0))
|
|
184
|
+
return true; // :: unspecified
|
|
185
|
+
if (groups.slice(0, 7).every((group) => group === 0) && g7 === 1)
|
|
186
|
+
return true; // ::1 loopback
|
|
187
|
+
if ((first & 0xffc0) === 0xfe80)
|
|
188
|
+
return true; // fe80::/10 link-local
|
|
189
|
+
if ((first & 0xffc0) === 0xfec0)
|
|
190
|
+
return true; // fec0::/10 site-local (deprecated)
|
|
191
|
+
if ((first & 0xfe00) === 0xfc00)
|
|
192
|
+
return true; // fc00::/7 unique local
|
|
193
|
+
if ((first & 0xff00) === 0xff00)
|
|
194
|
+
return true; // ff00::/8 multicast
|
|
195
|
+
if (first === 0x2001 && groups[1] === 0x0db8)
|
|
196
|
+
return true; // 2001:db8::/32 documentation
|
|
197
|
+
// Extract the IPv4 embedded in the low 32 bits.
|
|
198
|
+
const embeddedV4 = [
|
|
199
|
+
g6 >> 8,
|
|
200
|
+
g6 & 0xff,
|
|
201
|
+
g7 >> 8,
|
|
202
|
+
g7 & 0xff,
|
|
203
|
+
];
|
|
204
|
+
// ::ffff:0:0/96 IPv4-mapped and ::/96 deprecated IPv4-compatible.
|
|
205
|
+
if (groups.slice(0, 5).every((group) => group === 0) &&
|
|
206
|
+
(groups[5] === 0xffff || groups[5] === 0x0000)) {
|
|
207
|
+
return isPrivateIPv4(embeddedV4);
|
|
208
|
+
}
|
|
209
|
+
// 64:ff9b::/96 NAT64 well-known prefix.
|
|
210
|
+
if (first === 0x0064 &&
|
|
211
|
+
groups[1] === 0xff9b &&
|
|
212
|
+
groups.slice(2, 6).every((group) => group === 0)) {
|
|
213
|
+
return isPrivateIPv4(embeddedV4);
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
/** Hostnames (non-IP) that are never public and must never be fetched. */
|
|
218
|
+
function isBlockedHostname(host) {
|
|
219
|
+
const lower = host.toLowerCase();
|
|
220
|
+
return (lower === "localhost" ||
|
|
221
|
+
lower.endsWith(".localhost") ||
|
|
222
|
+
lower.endsWith(".local") ||
|
|
223
|
+
lower.endsWith(".internal"));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Decide whether a URL host is private, loopback, link-local, or otherwise
|
|
227
|
+
* not safe to fetch from inside the Worker's network. Accepts the raw
|
|
228
|
+
* `URL.hostname` form (IPv6 hosts may arrive wrapped in `[...]`).
|
|
229
|
+
*/
|
|
230
|
+
export function isPrivateOrReservedHost(hostname) {
|
|
231
|
+
if (hostname === "") {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
// Strip IPv6 brackets and a trailing dot. A trailing dot makes a name an
|
|
235
|
+
// FQDN that still resolves (e.g. `localhost.` → 127.0.0.1) but would slip
|
|
236
|
+
// past the string checks below if left in place.
|
|
237
|
+
const host = (hostname.startsWith("[") && hostname.endsWith("]")
|
|
238
|
+
? hostname.slice(1, -1)
|
|
239
|
+
: hostname).replace(/\.$/, "");
|
|
240
|
+
const v4 = parseIPv4(host);
|
|
241
|
+
if (v4 !== null) {
|
|
242
|
+
return isPrivateIPv4(v4);
|
|
243
|
+
}
|
|
244
|
+
const v6 = parseIPv6(host);
|
|
245
|
+
if (v6 !== null) {
|
|
246
|
+
return isPrivateIPv6(v6);
|
|
247
|
+
}
|
|
248
|
+
return isBlockedHostname(host);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Validate that `rawUrl` is a fetchable public `http(s)` URL, returning the
|
|
252
|
+
* parsed {@link URL}. Throws {@link SsrfError} for an unparseable URL, a
|
|
253
|
+
* non-`http(s)` scheme (e.g. `file:`, `javascript:`), or a private/reserved
|
|
254
|
+
* host.
|
|
255
|
+
*/
|
|
256
|
+
export function assertPublicUrl(rawUrl) {
|
|
257
|
+
let url;
|
|
258
|
+
try {
|
|
259
|
+
url = new URL(rawUrl);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
throw new SsrfError(`invalid URL: ${rawUrl}`, "invalid_url");
|
|
263
|
+
}
|
|
264
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
265
|
+
throw new SsrfError(`disallowed scheme: ${url.protocol}`, "disallowed_scheme", url.hostname);
|
|
266
|
+
}
|
|
267
|
+
if (isPrivateOrReservedHost(url.hostname)) {
|
|
268
|
+
throw new SsrfError(`blocked host: ${url.hostname}`, "blocked_host", url.hostname);
|
|
269
|
+
}
|
|
270
|
+
return url;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Fetch `rawUrl` through `doFetch` with SSRF guardrails.
|
|
274
|
+
*
|
|
275
|
+
* The initial host and every redirect target are validated with
|
|
276
|
+
* {@link assertPublicUrl}; redirects are followed manually (`redirect:
|
|
277
|
+
* "manual"`) up to `maxRedirects` hops; and a single {@link AbortSignal.timeout}
|
|
278
|
+
* bounds the whole chain. The request method, headers, and body from `init` are
|
|
279
|
+
* preserved across hops — a redirected `POST` notification re-POSTs to the
|
|
280
|
+
* (re-validated) new location rather than silently degrading to `GET`.
|
|
281
|
+
*
|
|
282
|
+
* @throws {SsrfError} when a host is blocked, a scheme is disallowed, or the
|
|
283
|
+
* redirect cap is exceeded. Other failures (network, timeout) propagate as the
|
|
284
|
+
* underlying fetch rejection. Callers treat any throw as "fetch failed".
|
|
285
|
+
*/
|
|
286
|
+
export async function safeFetch(doFetch, rawUrl, init, options) {
|
|
287
|
+
const maxRedirects = options?.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
288
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
289
|
+
const logger = options?.logger ?? noopLogger;
|
|
290
|
+
const metrics = options?.metrics ?? noopMetrics;
|
|
291
|
+
// Bound the chain with our own timeout, but don't clobber a caller's signal
|
|
292
|
+
// (e.g. a worker-shutdown abort): combine them so either can cancel.
|
|
293
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
294
|
+
const signal = init.signal != null
|
|
295
|
+
? AbortSignal.any([init.signal, timeoutSignal])
|
|
296
|
+
: timeoutSignal;
|
|
297
|
+
// A blocked request is the single most security-relevant event here, so log
|
|
298
|
+
// it (with its structured reason + sanitized host) before re-throwing — an
|
|
299
|
+
// operator being actively probed sees a distinct signal instead of silence.
|
|
300
|
+
try {
|
|
301
|
+
let currentUrl = assertPublicUrl(rawUrl).toString();
|
|
302
|
+
let currentInit = { ...init };
|
|
303
|
+
for (let hop = 0;; hop++) {
|
|
304
|
+
const response = await doFetch(currentUrl, {
|
|
305
|
+
...currentInit,
|
|
306
|
+
redirect: "manual",
|
|
307
|
+
signal,
|
|
308
|
+
});
|
|
309
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
310
|
+
return { response, url: currentUrl };
|
|
311
|
+
}
|
|
312
|
+
const location = response.headers.get("location");
|
|
313
|
+
if (location === null || location === "") {
|
|
314
|
+
// A redirect with nothing to follow — hand back as the final response.
|
|
315
|
+
return { response, url: currentUrl };
|
|
316
|
+
}
|
|
317
|
+
if (hop >= maxRedirects) {
|
|
318
|
+
throw new SsrfError(`too many redirects (> ${maxRedirects})`, "too_many_redirects", new URL(currentUrl).host);
|
|
319
|
+
}
|
|
320
|
+
// Resolve the next hop against the current URL and re-validate its host
|
|
321
|
+
// before following — a public host must not be able to bounce us inward.
|
|
322
|
+
const next = assertPublicUrl(new URL(location, currentUrl).toString());
|
|
323
|
+
// Drain the redirect body so the connection can be reused/closed.
|
|
324
|
+
await response.body?.cancel().catch(() => undefined);
|
|
325
|
+
// Strip credential-bearing headers on a cross-origin hop, matching what a
|
|
326
|
+
// browser's `fetch` does, so a redirect cannot leak them to a new origin.
|
|
327
|
+
if (currentInit.headers && new URL(currentUrl).origin !== next.origin) {
|
|
328
|
+
const headers = new Headers(currentInit.headers);
|
|
329
|
+
for (const name of [
|
|
330
|
+
"authorization",
|
|
331
|
+
"cookie",
|
|
332
|
+
"cookie2",
|
|
333
|
+
"proxy-authorization",
|
|
334
|
+
"set-cookie",
|
|
335
|
+
"x-hub-signature",
|
|
336
|
+
]) {
|
|
337
|
+
headers.delete(name);
|
|
338
|
+
}
|
|
339
|
+
currentInit = { ...currentInit, headers };
|
|
340
|
+
}
|
|
341
|
+
currentUrl = next.toString();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
if (err instanceof SsrfError) {
|
|
346
|
+
const fields = { reason: err.reason, host: err.host };
|
|
347
|
+
logger.warn(WebSubLogEvent.SsrfBlocked, fields);
|
|
348
|
+
// Mirror the log as a counter so "SSRF blocks/min by reason" is chartable.
|
|
349
|
+
metrics.count(WebSubLogEvent.SsrfBlocked, fields);
|
|
350
|
+
}
|
|
351
|
+
throw err;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
//# sourceMappingURL=safe-fetch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safe-fetch.js","sourceRoot":"","sources":["../src/safe-fetch.ts"],"names":[],"mappings":"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"}
|