@dwk/webmention 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 +140 -0
- package/dist/discovery.d.ts +43 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +128 -0
- package/dist/discovery.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/html.d.ts +68 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +183 -0
- package/dist/html.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +73 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +161 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +42 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +40 -0
- package/dist/log.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 +348 -0
- package/dist/safe-fetch.js.map +1 -0
- package/dist/sender.d.ts +43 -0
- package/dist/sender.d.ts.map +1 -0
- package/dist/sender.js +80 -0
- package/dist/sender.js.map +1 -0
- package/dist/validate.d.ts +47 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +76 -0
- package/dist/validate.js.map +1 -0
- package/dist/verify.d.ts +61 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +216 -0
- package/dist/verify.js.map +1 -0
- package/package.json +45 -0
- package/src/discovery.ts +167 -0
- package/src/fetch.ts +84 -0
- package/src/html.ts +206 -0
- package/src/inbox.ts +121 -0
- package/src/index.ts +297 -0
- package/src/log.ts +44 -0
- package/src/safe-fetch.ts +405 -0
- package/src/sender.ts +131 -0
- package/src/validate.ts +116 -0
- package/src/verify.ts +294 -0
package/dist/log.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — structured observability event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* The package's logging and metrics are opt-in via an injected {@link Logger}
|
|
5
|
+
* and {@link Metrics} (see `@dwk/log`), and **share this one vocabulary**: the
|
|
6
|
+
* same dotted event name is passed to `logger.warn(...)` and
|
|
7
|
+
* `metrics.count(...)` so a log line and its counter line up. Event names are
|
|
8
|
+
* stable, dotted, and queryable — operators filter on these rather than grepping
|
|
9
|
+
* free text. Security-relevant events (a blocked SSRF attempt, a receiver
|
|
10
|
+
* rejection, a poison-message retry) are first-class here so that being actively
|
|
11
|
+
* probed produces a distinct signal instead of looking like a dead link. See
|
|
12
|
+
* `spec/observability.md`.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Stable event names emitted by `@dwk/webmention`. Fields logged alongside each
|
|
18
|
+
* event use `hostFromUrl` for any URL so an attacker-supplied path/query is
|
|
19
|
+
* never recorded; tokens and bodies are never logged.
|
|
20
|
+
*/
|
|
21
|
+
export const WebmentionLogEvent = {
|
|
22
|
+
/**
|
|
23
|
+
* An outbound fetch was refused on SSRF grounds. Fields: `reason`
|
|
24
|
+
* (machine-readable cause), `host` (sanitized, when known).
|
|
25
|
+
*/
|
|
26
|
+
SsrfBlocked: "webmention.ssrf.blocked",
|
|
27
|
+
/** A received mention passed validation and was enqueued. */
|
|
28
|
+
ReceiveAccepted: "webmention.receive.accepted",
|
|
29
|
+
/** A received mention was rejected at validation. Field: `reason`. */
|
|
30
|
+
ReceiveRejected: "webmention.receive.rejected",
|
|
31
|
+
/** Source verification finished. Fields: `links`, `status`. */
|
|
32
|
+
VerifyCompleted: "webmention.verify.completed",
|
|
33
|
+
/** The source fetch failed/was blocked during verification. Field: `error`. */
|
|
34
|
+
VerifyFetchFailed: "webmention.verify.fetch_failed",
|
|
35
|
+
/** A queue message threw and is being retried. Field: `error`. */
|
|
36
|
+
QueueRetry: "webmention.queue.retry",
|
|
37
|
+
/** A send attempt finished. Fields: `endpointHost`, `delivered`, `status`. */
|
|
38
|
+
SendCompleted: "webmention.send.completed",
|
|
39
|
+
};
|
|
40
|
+
//# 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;;;;;;;;;;;;;;GAcG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG;IAChC;;;OAGG;IACH,WAAW,EAAE,yBAAyB;IACtC,6DAA6D;IAC7D,eAAe,EAAE,6BAA6B;IAC9C,sEAAsE;IACtE,eAAe,EAAE,6BAA6B;IAC9C,+DAA+D;IAC/D,eAAe,EAAE,6BAA6B;IAC9C,+EAA+E;IAC/E,iBAAiB,EAAE,gCAAgC;IACnD,kEAAkE;IAClE,UAAU,EAAE,wBAAwB;IACpC,8EAA8E;IAC9E,aAAa,EAAE,2BAA2B;CAClC,CAAC"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — SSRF-safe outbound fetch.
|
|
3
|
+
*
|
|
4
|
+
* A Webmention implementation's whole job is fetching attacker-supplied URLs:
|
|
5
|
+
* the receiver fetches `source`, and the sender fetches `target` plus the
|
|
6
|
+
* endpoint it discovers there. Without guardrails an attacker can point any of
|
|
7
|
+
* these at the Worker's own network — loopback, the link-local cloud metadata
|
|
8
|
+
* IP (`169.254.169.254`), or RFC 1918 ranges — to exfiltrate credentials or
|
|
9
|
+
* probe internal services. This module is the single choke point every
|
|
10
|
+
* outbound fetch 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 target
|
|
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/webmention.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 `webmention.ssrf.blocked`) so the
|
|
43
|
+
* single 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 (the base for relative links). */
|
|
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,mFAAmF;IACnF,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,CAqE1B"}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — SSRF-safe outbound fetch.
|
|
3
|
+
*
|
|
4
|
+
* A Webmention implementation's whole job is fetching attacker-supplied URLs:
|
|
5
|
+
* the receiver fetches `source`, and the sender fetches `target` plus the
|
|
6
|
+
* endpoint it discovers there. Without guardrails an attacker can point any of
|
|
7
|
+
* these at the Worker's own network — loopback, the link-local cloud metadata
|
|
8
|
+
* IP (`169.254.169.254`), or RFC 1918 ranges — to exfiltrate credentials or
|
|
9
|
+
* probe internal services. This module is the single choke point every
|
|
10
|
+
* outbound fetch 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 target
|
|
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/webmention.md`
|
|
23
|
+
* and `spec/non-functional-requirements.md`.
|
|
24
|
+
*
|
|
25
|
+
* @packageDocumentation
|
|
26
|
+
*/
|
|
27
|
+
import { noopLogger, noopMetrics } from "@dwk/log";
|
|
28
|
+
import { WebmentionLogEvent } 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 `webmention.ssrf.blocked`) so the
|
|
40
|
+
* single 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
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
292
|
+
// A blocked request is the single most security-relevant event here, so log
|
|
293
|
+
// it (with its structured reason + sanitized host) before re-throwing — an
|
|
294
|
+
// operator being actively probed sees a distinct signal instead of silence.
|
|
295
|
+
try {
|
|
296
|
+
let currentUrl = assertPublicUrl(rawUrl).toString();
|
|
297
|
+
let currentInit = { ...init };
|
|
298
|
+
for (let hop = 0;; hop++) {
|
|
299
|
+
const response = await doFetch(currentUrl, {
|
|
300
|
+
...currentInit,
|
|
301
|
+
redirect: "manual",
|
|
302
|
+
signal,
|
|
303
|
+
});
|
|
304
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
305
|
+
return { response, url: currentUrl };
|
|
306
|
+
}
|
|
307
|
+
const location = response.headers.get("location");
|
|
308
|
+
if (location === null || location === "") {
|
|
309
|
+
// A redirect with nothing to follow — hand back as the final response.
|
|
310
|
+
return { response, url: currentUrl };
|
|
311
|
+
}
|
|
312
|
+
if (hop >= maxRedirects) {
|
|
313
|
+
throw new SsrfError(`too many redirects (> ${maxRedirects})`, "too_many_redirects", new URL(currentUrl).host);
|
|
314
|
+
}
|
|
315
|
+
// Resolve the next hop against the current URL and re-validate its host
|
|
316
|
+
// before following — a public host must not be able to bounce us inward.
|
|
317
|
+
const next = assertPublicUrl(new URL(location, currentUrl).toString());
|
|
318
|
+
// Drain the redirect body so the connection can be reused/closed.
|
|
319
|
+
await response.body?.cancel().catch(() => undefined);
|
|
320
|
+
// Strip credential-bearing headers on a cross-origin hop, matching what a
|
|
321
|
+
// browser's `fetch` does, so a redirect cannot leak them to a new origin.
|
|
322
|
+
if (currentInit.headers && new URL(currentUrl).origin !== next.origin) {
|
|
323
|
+
const headers = new Headers(currentInit.headers);
|
|
324
|
+
for (const name of [
|
|
325
|
+
"authorization",
|
|
326
|
+
"cookie",
|
|
327
|
+
"cookie2",
|
|
328
|
+
"proxy-authorization",
|
|
329
|
+
"set-cookie",
|
|
330
|
+
]) {
|
|
331
|
+
headers.delete(name);
|
|
332
|
+
}
|
|
333
|
+
currentInit = { ...currentInit, headers };
|
|
334
|
+
}
|
|
335
|
+
currentUrl = next.toString();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
if (err instanceof SsrfError) {
|
|
340
|
+
const fields = { reason: err.reason, host: err.host };
|
|
341
|
+
logger.warn(WebmentionLogEvent.SsrfBlocked, fields);
|
|
342
|
+
// Mirror the log as a counter so "SSRF blocks/min by reason" is chartable.
|
|
343
|
+
metrics.count(WebmentionLogEvent.SsrfBlocked, fields);
|
|
344
|
+
}
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
//# 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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"}
|
package/dist/sender.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — sender.
|
|
3
|
+
*
|
|
4
|
+
* On publish, notify each outbound target: discover its Webmention endpoint
|
|
5
|
+
* (see {@link discoverEndpoint}) and POST a `source`/`target` notification as
|
|
6
|
+
* `application/x-www-form-urlencoded`. See `spec/packages/webmention.md`.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
import { type Logger, type Metrics } from "@dwk/log";
|
|
11
|
+
import type { FetchLike } from "./fetch";
|
|
12
|
+
/** Options for {@link sendWebmention} / {@link sendWebmentions}. */
|
|
13
|
+
export interface SendOptions {
|
|
14
|
+
/** `fetch` implementation to use; defaults to the global `fetch`. */
|
|
15
|
+
readonly fetch?: FetchLike;
|
|
16
|
+
/** Logger for send outcomes; defaults to a no-op (see `@dwk/log`). */
|
|
17
|
+
readonly logger?: Logger;
|
|
18
|
+
/** Metrics sink for send-outcome counters; defaults to a no-op (see `@dwk/log`). */
|
|
19
|
+
readonly metrics?: Metrics;
|
|
20
|
+
}
|
|
21
|
+
/** Outcome of attempting to notify a single target. */
|
|
22
|
+
export interface SendResult {
|
|
23
|
+
/** The target URL that was notified. */
|
|
24
|
+
readonly target: string;
|
|
25
|
+
/** The discovered endpoint, or `null` when the target declares none. */
|
|
26
|
+
readonly endpoint: string | null;
|
|
27
|
+
/** Whether the endpoint accepted the notification (2xx response). */
|
|
28
|
+
readonly delivered: boolean;
|
|
29
|
+
/** The notification's HTTP status (`0` when not sent or the POST threw). */
|
|
30
|
+
readonly status: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Discover `target`'s Webmention endpoint and notify it that `source` links to
|
|
34
|
+
* it. Targets that declare no endpoint are skipped (`delivered: false`).
|
|
35
|
+
*/
|
|
36
|
+
export declare function sendWebmention(source: string, target: string, options?: SendOptions): Promise<SendResult>;
|
|
37
|
+
/**
|
|
38
|
+
* Notify many targets, one {@link SendResult} per target (input order
|
|
39
|
+
* preserved). Failures are reported, never thrown, so one dead target does not
|
|
40
|
+
* sink the rest.
|
|
41
|
+
*/
|
|
42
|
+
export declare function sendWebmentions(source: string, targets: readonly string[], options?: SendOptions): Promise<SendResult[]>;
|
|
43
|
+
//# sourceMappingURL=sender.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../src/sender.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAIL,KAAK,MAAM,EACX,KAAK,OAAO,EACb,MAAM,UAAU,CAAC;AAElB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAIzC,oEAAoE;AACpE,MAAM,WAAW,WAAW;IAC1B,qEAAqE;IACrE,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B,sEAAsE;IACtE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,oFAAoF;IACpF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,uDAAuD;AACvD,MAAM,WAAW,UAAU;IACzB,wCAAwC;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,qEAAqE;IACrE,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,WAAW,GACpB,OAAO,CAAC,UAAU,CAAC,CA+DrB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,CAAC,EAAE,WAAW,GACpB,OAAO,CAAC,UAAU,EAAE,CAAC,CAIvB"}
|
package/dist/sender.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — sender.
|
|
3
|
+
*
|
|
4
|
+
* On publish, notify each outbound target: discover its Webmention endpoint
|
|
5
|
+
* (see {@link discoverEndpoint}) and POST a `source`/`target` notification as
|
|
6
|
+
* `application/x-www-form-urlencoded`. See `spec/packages/webmention.md`.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
import { hostFromUrl, noopLogger, noopMetrics, } from "@dwk/log";
|
|
11
|
+
import { discoverEndpoint } from "./discovery";
|
|
12
|
+
import { WebmentionLogEvent } from "./log";
|
|
13
|
+
import { safeFetch } from "./safe-fetch";
|
|
14
|
+
/**
|
|
15
|
+
* Discover `target`'s Webmention endpoint and notify it that `source` links to
|
|
16
|
+
* it. Targets that declare no endpoint are skipped (`delivered: false`).
|
|
17
|
+
*/
|
|
18
|
+
export async function sendWebmention(source, target, options) {
|
|
19
|
+
const doFetch = options?.fetch ?? ((input, init) => fetch(input, init));
|
|
20
|
+
const logger = options?.logger ?? noopLogger;
|
|
21
|
+
const metrics = options?.metrics ?? noopMetrics;
|
|
22
|
+
const logOutcome = (result) => {
|
|
23
|
+
const fields = {
|
|
24
|
+
targetHost: hostFromUrl(target),
|
|
25
|
+
endpointHost: result.endpoint === null ? undefined : hostFromUrl(result.endpoint),
|
|
26
|
+
delivered: result.delivered,
|
|
27
|
+
status: result.status,
|
|
28
|
+
};
|
|
29
|
+
logger.info(WebmentionLogEvent.SendCompleted, fields);
|
|
30
|
+
// Mirror the log as a counter so "deliveries (by delivered/status)" charts.
|
|
31
|
+
metrics.count(WebmentionLogEvent.SendCompleted, fields);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
const endpoint = await discoverEndpoint(target, {
|
|
35
|
+
fetch: doFetch,
|
|
36
|
+
logger,
|
|
37
|
+
metrics,
|
|
38
|
+
});
|
|
39
|
+
// Only notify http(s) endpoints: a page could advertise a `javascript:`,
|
|
40
|
+
// `file:`, or `mailto:` endpoint, which we must never fetch. `URL.protocol`
|
|
41
|
+
// is already lowercased and includes the trailing colon.
|
|
42
|
+
if (endpoint === null) {
|
|
43
|
+
return logOutcome({ target, endpoint: null, delivered: false, status: 0 });
|
|
44
|
+
}
|
|
45
|
+
const protocol = new URL(endpoint).protocol;
|
|
46
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
47
|
+
return logOutcome({ target, endpoint: null, delivered: false, status: 0 });
|
|
48
|
+
}
|
|
49
|
+
const body = new URLSearchParams({ source, target }).toString();
|
|
50
|
+
let response;
|
|
51
|
+
try {
|
|
52
|
+
// Notify through the SSRF-safe wrapper: the discovered endpoint host (and
|
|
53
|
+
// any redirect hop) is validated against private/loopback ranges and the
|
|
54
|
+
// POST is bounded by a timeout.
|
|
55
|
+
const result = await safeFetch(doFetch, endpoint, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
58
|
+
body,
|
|
59
|
+
}, { logger });
|
|
60
|
+
response = result.response;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return logOutcome({ target, endpoint, delivered: false, status: 0 });
|
|
64
|
+
}
|
|
65
|
+
return logOutcome({
|
|
66
|
+
target,
|
|
67
|
+
endpoint,
|
|
68
|
+
delivered: response.ok,
|
|
69
|
+
status: response.status,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Notify many targets, one {@link SendResult} per target (input order
|
|
74
|
+
* preserved). Failures are reported, never thrown, so one dead target does not
|
|
75
|
+
* sink the rest.
|
|
76
|
+
*/
|
|
77
|
+
export function sendWebmentions(source, targets, options) {
|
|
78
|
+
return Promise.all(targets.map((target) => sendWebmention(source, target, options)));
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=sender.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sender.js","sourceRoot":"","sources":["../src/sender.ts"],"names":[],"mappings":"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"}
|