@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
|
@@ -0,0 +1,412 @@
|
|
|
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
|
+
|
|
28
|
+
import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log";
|
|
29
|
+
import type { FetchLike } from "./fetch";
|
|
30
|
+
import { WebSubLogEvent } from "./log";
|
|
31
|
+
|
|
32
|
+
/** Default cap on redirect hops before a fetch is abandoned. */
|
|
33
|
+
export const DEFAULT_MAX_REDIRECTS = 5;
|
|
34
|
+
/** Default overall timeout (ms) bounding a fetch, redirects included. */
|
|
35
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
36
|
+
|
|
37
|
+
/** HTTP status codes that carry a `Location` we may follow. */
|
|
38
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Machine-readable cause of an {@link SsrfError}, suitable for logging as a
|
|
42
|
+
* structured field (no free-text parsing required).
|
|
43
|
+
*/
|
|
44
|
+
export type SsrfReason =
|
|
45
|
+
| "invalid_url"
|
|
46
|
+
| "disallowed_scheme"
|
|
47
|
+
| "blocked_host"
|
|
48
|
+
| "too_many_redirects";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Raised when a request is refused on SSRF grounds (blocked host, disallowed
|
|
52
|
+
* scheme, or too many redirects). Callers catch this exactly like a network
|
|
53
|
+
* failure — a blocked attempt looks the same as an unreachable host — but
|
|
54
|
+
* {@link safeFetch} logs it first (event `websub.ssrf.blocked`) so the single
|
|
55
|
+
* most security-relevant event in the package still produces a signal.
|
|
56
|
+
*
|
|
57
|
+
* Carries the structured {@link reason} and, when known, the sanitized
|
|
58
|
+
* {@link host} so a logger can record them as queryable fields.
|
|
59
|
+
*/
|
|
60
|
+
export class SsrfError extends Error {
|
|
61
|
+
/** Machine-readable cause. */
|
|
62
|
+
readonly reason: SsrfReason;
|
|
63
|
+
/** The offending host (name plus any port), when one is known. */
|
|
64
|
+
readonly host?: string;
|
|
65
|
+
constructor(message: string, reason: SsrfReason, host?: string) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "SsrfError";
|
|
68
|
+
this.reason = reason;
|
|
69
|
+
this.host = host;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Parse a canonical dotted-decimal IPv4 host into its four octets. */
|
|
74
|
+
function parseIPv4(host: string): [number, number, number, number] | null {
|
|
75
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
76
|
+
if (match === null) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const octets: number[] = [];
|
|
80
|
+
for (let group = 1; group <= 4; group++) {
|
|
81
|
+
const part = match[group];
|
|
82
|
+
if (part === undefined) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const octet = Number.parseInt(part, 10);
|
|
86
|
+
if (octet > 255) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
octets.push(octet);
|
|
90
|
+
}
|
|
91
|
+
return octets as [number, number, number, number];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True when `octets` falls in a range that must never be fetched from inside
|
|
96
|
+
* the Worker's network: this-network, loopback, link-local (incl. the cloud
|
|
97
|
+
* metadata IP), the RFC 1918 private blocks, CGNAT, IETF protocol/benchmark
|
|
98
|
+
* assignments, and the multicast/reserved/broadcast space.
|
|
99
|
+
*/
|
|
100
|
+
function isPrivateIPv4(octets: [number, number, number, number]): boolean {
|
|
101
|
+
const [a, b, c] = octets;
|
|
102
|
+
if (a === 0) return true; // 0.0.0.0/8 ("this network", incl. 0.0.0.0)
|
|
103
|
+
if (a === 10) return true; // 10.0.0.0/8 private
|
|
104
|
+
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
105
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
|
106
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (metadata)
|
|
107
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
|
108
|
+
if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24 IETF protocol
|
|
109
|
+
if (a === 192 && b === 0 && c === 2) return true; // 192.0.2.0/24 TEST-NET-1
|
|
110
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
|
111
|
+
if (a === 198 && b === 51 && c === 100) return true; // 198.51.100.0/24 TEST-NET-2
|
|
112
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmark
|
|
113
|
+
if (a === 203 && b === 0 && c === 113) return true; // 203.0.113.0/24 TEST-NET-3
|
|
114
|
+
if (a >= 224) return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + broadcast
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse an IPv6 host (brackets already stripped) into its eight 16-bit groups,
|
|
120
|
+
* expanding `::` compression and any trailing embedded IPv4 literal. Returns
|
|
121
|
+
* `null` when `host` is not a valid IPv6 address.
|
|
122
|
+
*/
|
|
123
|
+
function parseIPv6(host: string): number[] | null {
|
|
124
|
+
if (!host.includes(":")) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
let str = host;
|
|
128
|
+
|
|
129
|
+
// Fold a trailing embedded IPv4 literal (e.g. ::ffff:127.0.0.1) into two
|
|
130
|
+
// hex groups so the rest can be parsed uniformly.
|
|
131
|
+
const v4Match = /(?:^|:)((?:\d{1,3}\.){3}\d{1,3})$/.exec(str);
|
|
132
|
+
const v4Str = v4Match?.[1];
|
|
133
|
+
if (v4Str !== undefined) {
|
|
134
|
+
const v4 = parseIPv4(v4Str);
|
|
135
|
+
if (v4 === null) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const hi = ((v4[0] << 8) | v4[1]).toString(16);
|
|
139
|
+
const lo = ((v4[2] << 8) | v4[3]).toString(16);
|
|
140
|
+
str = `${str.slice(0, str.length - v4Str.length)}${hi}:${lo}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// At most one "::" compression marker is allowed.
|
|
144
|
+
if (str.indexOf("::") !== str.lastIndexOf("::")) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const toGroups = (part: string): number[] | null => {
|
|
149
|
+
if (part === "") {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
const groups: number[] = [];
|
|
153
|
+
for (const token of part.split(":")) {
|
|
154
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(token)) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
groups.push(Number.parseInt(token, 16));
|
|
158
|
+
}
|
|
159
|
+
return groups;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (str.includes("::")) {
|
|
163
|
+
const parts = str.split("::");
|
|
164
|
+
const left = toGroups(parts[0] ?? "");
|
|
165
|
+
const right = toGroups(parts[1] ?? "");
|
|
166
|
+
if (left === null || right === null) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const missing = 8 - left.length - right.length;
|
|
170
|
+
if (missing < 1) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return [...left, ...new Array<number>(missing).fill(0), ...right];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const all = toGroups(str);
|
|
177
|
+
if (all === null || all.length !== 8) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
return all;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* True when `groups` (eight 16-bit values) is an IPv6 address that must never
|
|
185
|
+
* be fetched: unspecified, loopback, link-local, site-local, unique-local,
|
|
186
|
+
* multicast, the documentation prefix, or an address that embeds an IPv4
|
|
187
|
+
* (IPv4-mapped `::ffff:0:0/96`, deprecated IPv4-compatible `::/96`, or NAT64
|
|
188
|
+
* `64:ff9b::/96`) whose embedded IPv4 is itself private.
|
|
189
|
+
*/
|
|
190
|
+
function isPrivateIPv6(groups: number[]): boolean {
|
|
191
|
+
const first = groups[0] ?? 0;
|
|
192
|
+
const g6 = groups[6] ?? 0;
|
|
193
|
+
const g7 = groups[7] ?? 0;
|
|
194
|
+
if (groups.every((group) => group === 0)) return true; // :: unspecified
|
|
195
|
+
if (groups.slice(0, 7).every((group) => group === 0) && g7 === 1) return true; // ::1 loopback
|
|
196
|
+
if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
|
197
|
+
if ((first & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local (deprecated)
|
|
198
|
+
if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
|
|
199
|
+
if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast
|
|
200
|
+
if (first === 0x2001 && groups[1] === 0x0db8) return true; // 2001:db8::/32 documentation
|
|
201
|
+
|
|
202
|
+
// Extract the IPv4 embedded in the low 32 bits.
|
|
203
|
+
const embeddedV4: [number, number, number, number] = [
|
|
204
|
+
g6 >> 8,
|
|
205
|
+
g6 & 0xff,
|
|
206
|
+
g7 >> 8,
|
|
207
|
+
g7 & 0xff,
|
|
208
|
+
];
|
|
209
|
+
// ::ffff:0:0/96 IPv4-mapped and ::/96 deprecated IPv4-compatible.
|
|
210
|
+
if (
|
|
211
|
+
groups.slice(0, 5).every((group) => group === 0) &&
|
|
212
|
+
(groups[5] === 0xffff || groups[5] === 0x0000)
|
|
213
|
+
) {
|
|
214
|
+
return isPrivateIPv4(embeddedV4);
|
|
215
|
+
}
|
|
216
|
+
// 64:ff9b::/96 NAT64 well-known prefix.
|
|
217
|
+
if (
|
|
218
|
+
first === 0x0064 &&
|
|
219
|
+
groups[1] === 0xff9b &&
|
|
220
|
+
groups.slice(2, 6).every((group) => group === 0)
|
|
221
|
+
) {
|
|
222
|
+
return isPrivateIPv4(embeddedV4);
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Hostnames (non-IP) that are never public and must never be fetched. */
|
|
228
|
+
function isBlockedHostname(host: string): boolean {
|
|
229
|
+
const lower = host.toLowerCase();
|
|
230
|
+
return (
|
|
231
|
+
lower === "localhost" ||
|
|
232
|
+
lower.endsWith(".localhost") ||
|
|
233
|
+
lower.endsWith(".local") ||
|
|
234
|
+
lower.endsWith(".internal")
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Decide whether a URL host is private, loopback, link-local, or otherwise
|
|
240
|
+
* not safe to fetch from inside the Worker's network. Accepts the raw
|
|
241
|
+
* `URL.hostname` form (IPv6 hosts may arrive wrapped in `[...]`).
|
|
242
|
+
*/
|
|
243
|
+
export function isPrivateOrReservedHost(hostname: string): boolean {
|
|
244
|
+
if (hostname === "") {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
// Strip IPv6 brackets and a trailing dot. A trailing dot makes a name an
|
|
248
|
+
// FQDN that still resolves (e.g. `localhost.` → 127.0.0.1) but would slip
|
|
249
|
+
// past the string checks below if left in place.
|
|
250
|
+
const host = (
|
|
251
|
+
hostname.startsWith("[") && hostname.endsWith("]")
|
|
252
|
+
? hostname.slice(1, -1)
|
|
253
|
+
: hostname
|
|
254
|
+
).replace(/\.$/, "");
|
|
255
|
+
|
|
256
|
+
const v4 = parseIPv4(host);
|
|
257
|
+
if (v4 !== null) {
|
|
258
|
+
return isPrivateIPv4(v4);
|
|
259
|
+
}
|
|
260
|
+
const v6 = parseIPv6(host);
|
|
261
|
+
if (v6 !== null) {
|
|
262
|
+
return isPrivateIPv6(v6);
|
|
263
|
+
}
|
|
264
|
+
return isBlockedHostname(host);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Validate that `rawUrl` is a fetchable public `http(s)` URL, returning the
|
|
269
|
+
* parsed {@link URL}. Throws {@link SsrfError} for an unparseable URL, a
|
|
270
|
+
* non-`http(s)` scheme (e.g. `file:`, `javascript:`), or a private/reserved
|
|
271
|
+
* host.
|
|
272
|
+
*/
|
|
273
|
+
export function assertPublicUrl(rawUrl: string): URL {
|
|
274
|
+
let url: URL;
|
|
275
|
+
try {
|
|
276
|
+
url = new URL(rawUrl);
|
|
277
|
+
} catch {
|
|
278
|
+
throw new SsrfError(`invalid URL: ${rawUrl}`, "invalid_url");
|
|
279
|
+
}
|
|
280
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
281
|
+
throw new SsrfError(
|
|
282
|
+
`disallowed scheme: ${url.protocol}`,
|
|
283
|
+
"disallowed_scheme",
|
|
284
|
+
url.hostname,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (isPrivateOrReservedHost(url.hostname)) {
|
|
288
|
+
throw new SsrfError(
|
|
289
|
+
`blocked host: ${url.hostname}`,
|
|
290
|
+
"blocked_host",
|
|
291
|
+
url.hostname,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return url;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Tunables for {@link safeFetch}. */
|
|
298
|
+
export interface SafeFetchOptions {
|
|
299
|
+
/** Maximum redirect hops to follow (default {@link DEFAULT_MAX_REDIRECTS}). */
|
|
300
|
+
readonly maxRedirects?: number;
|
|
301
|
+
/** Overall timeout in ms, redirects included (default {@link DEFAULT_TIMEOUT_MS}). */
|
|
302
|
+
readonly timeoutMs?: number;
|
|
303
|
+
/** Logger for SSRF blocks; defaults to a no-op (see `@dwk/log`). */
|
|
304
|
+
readonly logger?: Logger;
|
|
305
|
+
/** Metrics sink for SSRF-block counters; defaults to a no-op (see `@dwk/log`). */
|
|
306
|
+
readonly metrics?: Metrics;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A completed {@link safeFetch}: the final response and the URL it came from. */
|
|
310
|
+
export interface SafeFetchResult {
|
|
311
|
+
/** The final, non-redirect response. */
|
|
312
|
+
readonly response: Response;
|
|
313
|
+
/** The fully-resolved URL the response came from. */
|
|
314
|
+
readonly url: string;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Fetch `rawUrl` through `doFetch` with SSRF guardrails.
|
|
319
|
+
*
|
|
320
|
+
* The initial host and every redirect target are validated with
|
|
321
|
+
* {@link assertPublicUrl}; redirects are followed manually (`redirect:
|
|
322
|
+
* "manual"`) up to `maxRedirects` hops; and a single {@link AbortSignal.timeout}
|
|
323
|
+
* bounds the whole chain. The request method, headers, and body from `init` are
|
|
324
|
+
* preserved across hops — a redirected `POST` notification re-POSTs to the
|
|
325
|
+
* (re-validated) new location rather than silently degrading to `GET`.
|
|
326
|
+
*
|
|
327
|
+
* @throws {SsrfError} when a host is blocked, a scheme is disallowed, or the
|
|
328
|
+
* redirect cap is exceeded. Other failures (network, timeout) propagate as the
|
|
329
|
+
* underlying fetch rejection. Callers treat any throw as "fetch failed".
|
|
330
|
+
*/
|
|
331
|
+
export async function safeFetch(
|
|
332
|
+
doFetch: FetchLike,
|
|
333
|
+
rawUrl: string,
|
|
334
|
+
init: RequestInit,
|
|
335
|
+
options?: SafeFetchOptions,
|
|
336
|
+
): Promise<SafeFetchResult> {
|
|
337
|
+
const maxRedirects = options?.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
338
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
339
|
+
const logger = options?.logger ?? noopLogger;
|
|
340
|
+
const metrics = options?.metrics ?? noopMetrics;
|
|
341
|
+
// Bound the chain with our own timeout, but don't clobber a caller's signal
|
|
342
|
+
// (e.g. a worker-shutdown abort): combine them so either can cancel.
|
|
343
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
344
|
+
const signal =
|
|
345
|
+
init.signal != null
|
|
346
|
+
? AbortSignal.any([init.signal, timeoutSignal])
|
|
347
|
+
: timeoutSignal;
|
|
348
|
+
|
|
349
|
+
// A blocked request is the single most security-relevant event here, so log
|
|
350
|
+
// it (with its structured reason + sanitized host) before re-throwing — an
|
|
351
|
+
// operator being actively probed sees a distinct signal instead of silence.
|
|
352
|
+
try {
|
|
353
|
+
let currentUrl = assertPublicUrl(rawUrl).toString();
|
|
354
|
+
let currentInit: RequestInit = { ...init };
|
|
355
|
+
for (let hop = 0; ; hop++) {
|
|
356
|
+
const response = await doFetch(currentUrl, {
|
|
357
|
+
...currentInit,
|
|
358
|
+
redirect: "manual",
|
|
359
|
+
signal,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
363
|
+
return { response, url: currentUrl };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const location = response.headers.get("location");
|
|
367
|
+
if (location === null || location === "") {
|
|
368
|
+
// A redirect with nothing to follow — hand back as the final response.
|
|
369
|
+
return { response, url: currentUrl };
|
|
370
|
+
}
|
|
371
|
+
if (hop >= maxRedirects) {
|
|
372
|
+
throw new SsrfError(
|
|
373
|
+
`too many redirects (> ${maxRedirects})`,
|
|
374
|
+
"too_many_redirects",
|
|
375
|
+
new URL(currentUrl).host,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Resolve the next hop against the current URL and re-validate its host
|
|
380
|
+
// before following — a public host must not be able to bounce us inward.
|
|
381
|
+
const next = assertPublicUrl(new URL(location, currentUrl).toString());
|
|
382
|
+
// Drain the redirect body so the connection can be reused/closed.
|
|
383
|
+
await response.body?.cancel().catch(() => undefined);
|
|
384
|
+
|
|
385
|
+
// Strip credential-bearing headers on a cross-origin hop, matching what a
|
|
386
|
+
// browser's `fetch` does, so a redirect cannot leak them to a new origin.
|
|
387
|
+
if (currentInit.headers && new URL(currentUrl).origin !== next.origin) {
|
|
388
|
+
const headers = new Headers(currentInit.headers as HeadersInit);
|
|
389
|
+
for (const name of [
|
|
390
|
+
"authorization",
|
|
391
|
+
"cookie",
|
|
392
|
+
"cookie2",
|
|
393
|
+
"proxy-authorization",
|
|
394
|
+
"set-cookie",
|
|
395
|
+
"x-hub-signature",
|
|
396
|
+
]) {
|
|
397
|
+
headers.delete(name);
|
|
398
|
+
}
|
|
399
|
+
currentInit = { ...currentInit, headers };
|
|
400
|
+
}
|
|
401
|
+
currentUrl = next.toString();
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (err instanceof SsrfError) {
|
|
405
|
+
const fields = { reason: err.reason, host: err.host };
|
|
406
|
+
logger.warn(WebSubLogEvent.SsrfBlocked, fields);
|
|
407
|
+
// Mirror the log as a counter so "SSRF blocks/min by reason" is chartable.
|
|
408
|
+
metrics.count(WebSubLogEvent.SsrfBlocked, fields);
|
|
409
|
+
}
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — D1-backed subscription store.
|
|
3
|
+
*
|
|
4
|
+
* The set of active subscriptions is authoritative state: a stale or lost row
|
|
5
|
+
* means a subscriber stops receiving pushes (or keeps receiving them past lease
|
|
6
|
+
* expiry), which is a correctness bug, not a safe-to-be-stale cache. It therefore
|
|
7
|
+
* lives in **D1 (strongly consistent), never KV** — see
|
|
8
|
+
* `spec/non-functional-requirements.md`. A subscription is keyed on the
|
|
9
|
+
* `(callback, topic)` pair so re-subscribing renews the lease in place. Rows are
|
|
10
|
+
* written only **after** intent verification succeeds. See
|
|
11
|
+
* `spec/packages/websub.md`.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
17
|
+
|
|
18
|
+
/** A verified, active subscription. */
|
|
19
|
+
export interface Subscription {
|
|
20
|
+
readonly callback: string;
|
|
21
|
+
readonly topic: string;
|
|
22
|
+
/** HMAC secret to sign deliveries with, or `null` when none was registered. */
|
|
23
|
+
readonly secret: string | null;
|
|
24
|
+
/** Granted lease length, in seconds. */
|
|
25
|
+
readonly leaseSeconds: number;
|
|
26
|
+
/** Lease expiry, epoch milliseconds. After this the subscription is dead. */
|
|
27
|
+
readonly expiresAt: number;
|
|
28
|
+
/** Creation/last-renewal time, epoch milliseconds. */
|
|
29
|
+
readonly createdAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Fields needed to upsert (create or renew) a subscription. */
|
|
33
|
+
export interface SubscriptionUpsert {
|
|
34
|
+
readonly callback: string;
|
|
35
|
+
readonly topic: string;
|
|
36
|
+
readonly secret?: string | undefined;
|
|
37
|
+
readonly leaseSeconds: number;
|
|
38
|
+
/** Now, epoch milliseconds; `expiresAt` is derived as `now + leaseSeconds*1000`. */
|
|
39
|
+
readonly now: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Persistence surface for subscriptions. */
|
|
43
|
+
export interface SubscriptionStore {
|
|
44
|
+
/** Upsert (create or renew) a verified subscription, keyed on `(callback, topic)`. */
|
|
45
|
+
upsert(subscription: SubscriptionUpsert): Promise<void>;
|
|
46
|
+
/** Remove a subscription; no-op when absent. */
|
|
47
|
+
remove(callback: string, topic: string): Promise<void>;
|
|
48
|
+
/** List subscriptions for `topic` that are still within their lease at `now`. */
|
|
49
|
+
listActive(topic: string, now: number): Promise<Subscription[]>;
|
|
50
|
+
/** Look up one subscription, or `null` when absent. */
|
|
51
|
+
get(callback: string, topic: string): Promise<Subscription | null>;
|
|
52
|
+
/** Delete every subscription whose lease expired at or before `now`; returns the count removed. */
|
|
53
|
+
pruneExpired(now: number): Promise<number>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options for {@link createD1SubscriptionStore}. */
|
|
57
|
+
export interface D1StoreOptions {
|
|
58
|
+
/** Table name to use; created if absent. Defaults to `websub_subscriptions`. */
|
|
59
|
+
readonly table?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface SubscriptionRow {
|
|
63
|
+
readonly callback: string;
|
|
64
|
+
readonly topic: string;
|
|
65
|
+
readonly secret: string | null;
|
|
66
|
+
readonly lease_seconds: number;
|
|
67
|
+
readonly expires_at: number;
|
|
68
|
+
readonly created_at: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rowToSubscription(row: SubscriptionRow): Subscription {
|
|
72
|
+
return {
|
|
73
|
+
callback: row.callback,
|
|
74
|
+
topic: row.topic,
|
|
75
|
+
secret: row.secret,
|
|
76
|
+
leaseSeconds: row.lease_seconds,
|
|
77
|
+
expiresAt: row.expires_at,
|
|
78
|
+
createdAt: row.created_at,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a D1-backed {@link SubscriptionStore}. The backing table is created on
|
|
84
|
+
* first use if it does not already exist.
|
|
85
|
+
*/
|
|
86
|
+
export function createD1SubscriptionStore(
|
|
87
|
+
db: D1Database,
|
|
88
|
+
options?: D1StoreOptions,
|
|
89
|
+
): SubscriptionStore {
|
|
90
|
+
const table = options?.table ?? "websub_subscriptions";
|
|
91
|
+
// Guard the identifier: it is interpolated into DDL, so only allow a safe
|
|
92
|
+
// set of characters rather than trusting the caller blindly.
|
|
93
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
|
|
94
|
+
throw new Error(`@dwk/websub: invalid subscription table name "${table}".`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let ready: Promise<void> | null = null;
|
|
98
|
+
const ensureSchema = (): Promise<void> => {
|
|
99
|
+
// Clear the cached promise on failure so a transient D1 error during the
|
|
100
|
+
// first call doesn't permanently wedge the store: a later operation retries
|
|
101
|
+
// the DDL instead of inheriting the cached rejection.
|
|
102
|
+
ready ??= db
|
|
103
|
+
.prepare(
|
|
104
|
+
`CREATE TABLE IF NOT EXISTS ${table} (` +
|
|
105
|
+
`callback TEXT NOT NULL, ` +
|
|
106
|
+
`topic TEXT NOT NULL, ` +
|
|
107
|
+
`secret TEXT, ` +
|
|
108
|
+
`lease_seconds INTEGER NOT NULL, ` +
|
|
109
|
+
`expires_at INTEGER NOT NULL, ` +
|
|
110
|
+
`created_at INTEGER NOT NULL, ` +
|
|
111
|
+
`PRIMARY KEY (callback, topic))`,
|
|
112
|
+
)
|
|
113
|
+
.run()
|
|
114
|
+
.then(() => undefined)
|
|
115
|
+
.catch((err: unknown) => {
|
|
116
|
+
ready = null;
|
|
117
|
+
throw err;
|
|
118
|
+
});
|
|
119
|
+
return ready;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
async upsert(subscription) {
|
|
124
|
+
await ensureSchema();
|
|
125
|
+
const expiresAt = subscription.now + subscription.leaseSeconds * 1000;
|
|
126
|
+
await db
|
|
127
|
+
.prepare(
|
|
128
|
+
`INSERT INTO ${table} ` +
|
|
129
|
+
`(callback, topic, secret, lease_seconds, expires_at, created_at) ` +
|
|
130
|
+
`VALUES (?1, ?2, ?3, ?4, ?5, ?6) ` +
|
|
131
|
+
`ON CONFLICT (callback, topic) DO UPDATE SET ` +
|
|
132
|
+
`secret = excluded.secret, ` +
|
|
133
|
+
`lease_seconds = excluded.lease_seconds, ` +
|
|
134
|
+
`expires_at = excluded.expires_at, ` +
|
|
135
|
+
`created_at = excluded.created_at`,
|
|
136
|
+
)
|
|
137
|
+
.bind(
|
|
138
|
+
subscription.callback,
|
|
139
|
+
subscription.topic,
|
|
140
|
+
subscription.secret ?? null,
|
|
141
|
+
subscription.leaseSeconds,
|
|
142
|
+
expiresAt,
|
|
143
|
+
subscription.now,
|
|
144
|
+
)
|
|
145
|
+
.run();
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async remove(callback, topic) {
|
|
149
|
+
await ensureSchema();
|
|
150
|
+
await db
|
|
151
|
+
.prepare(`DELETE FROM ${table} WHERE callback = ?1 AND topic = ?2`)
|
|
152
|
+
.bind(callback, topic)
|
|
153
|
+
.run();
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async listActive(topic, now) {
|
|
157
|
+
await ensureSchema();
|
|
158
|
+
const { results } = await db
|
|
159
|
+
.prepare(
|
|
160
|
+
`SELECT callback, topic, secret, lease_seconds, expires_at, created_at ` +
|
|
161
|
+
`FROM ${table} WHERE topic = ?1 AND expires_at > ?2 ` +
|
|
162
|
+
`ORDER BY created_at ASC`,
|
|
163
|
+
)
|
|
164
|
+
.bind(topic, now)
|
|
165
|
+
.all<SubscriptionRow>();
|
|
166
|
+
return results.map(rowToSubscription);
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async get(callback, topic) {
|
|
170
|
+
await ensureSchema();
|
|
171
|
+
const row = await db
|
|
172
|
+
.prepare(
|
|
173
|
+
`SELECT callback, topic, secret, lease_seconds, expires_at, created_at ` +
|
|
174
|
+
`FROM ${table} WHERE callback = ?1 AND topic = ?2`,
|
|
175
|
+
)
|
|
176
|
+
.bind(callback, topic)
|
|
177
|
+
.first<SubscriptionRow>();
|
|
178
|
+
return row === null ? null : rowToSubscription(row);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async pruneExpired(now) {
|
|
182
|
+
await ensureSchema();
|
|
183
|
+
const result = await db
|
|
184
|
+
.prepare(`DELETE FROM ${table} WHERE expires_at <= ?1`)
|
|
185
|
+
.bind(now)
|
|
186
|
+
.run();
|
|
187
|
+
return result.meta.changes ?? 0;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|