@fgv/ts-extras 5.1.0-46 → 5.1.0-47
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/README.md +90 -0
- package/dist/index.browser.js +3 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/packlets/safer-fetch/addressClassification.js +438 -0
- package/dist/packlets/safer-fetch/addressClassification.js.map +1 -0
- package/dist/packlets/safer-fetch/addressPolicy.js +119 -0
- package/dist/packlets/safer-fetch/addressPolicy.js.map +1 -0
- package/dist/packlets/safer-fetch/contentType.js +120 -0
- package/dist/packlets/safer-fetch/contentType.js.map +1 -0
- package/dist/packlets/safer-fetch/deadline.js +254 -0
- package/dist/packlets/safer-fetch/deadline.js.map +1 -0
- package/dist/packlets/safer-fetch/defaults.js +131 -0
- package/dist/packlets/safer-fetch/defaults.js.map +1 -0
- package/dist/packlets/safer-fetch/failureReason.js +21 -0
- package/dist/packlets/safer-fetch/failureReason.js.map +1 -0
- package/dist/packlets/safer-fetch/guards.js +148 -0
- package/dist/packlets/safer-fetch/guards.js.map +1 -0
- package/dist/packlets/safer-fetch/index.browser.js +58 -0
- package/dist/packlets/safer-fetch/index.browser.js.map +1 -0
- package/dist/packlets/safer-fetch/index.js +52 -0
- package/dist/packlets/safer-fetch/index.js.map +1 -0
- package/dist/packlets/safer-fetch/model.js +21 -0
- package/dist/packlets/safer-fetch/model.js.map +1 -0
- package/dist/packlets/safer-fetch/nodeAddressGuard.js +191 -0
- package/dist/packlets/safer-fetch/nodeAddressGuard.js.map +1 -0
- package/dist/packlets/safer-fetch/redirect.js +93 -0
- package/dist/packlets/safer-fetch/redirect.js.map +1 -0
- package/dist/packlets/safer-fetch/retry.js +188 -0
- package/dist/packlets/safer-fetch/retry.js.map +1 -0
- package/dist/packlets/safer-fetch/saferFetch.js +755 -0
- package/dist/packlets/safer-fetch/saferFetch.js.map +1 -0
- package/dist/packlets/safer-fetch/transport.js +50 -0
- package/dist/packlets/safer-fetch/transport.js.map +1 -0
- package/dist/ts-extras.d.ts +1337 -0
- package/lib/index.browser.d.ts +2 -1
- package/lib/index.browser.d.ts.map +1 -1
- package/lib/index.browser.js +4 -1
- package/lib/index.browser.js.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/packlets/safer-fetch/addressClassification.d.ts +144 -0
- package/lib/packlets/safer-fetch/addressClassification.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/addressClassification.js +441 -0
- package/lib/packlets/safer-fetch/addressClassification.js.map +1 -0
- package/lib/packlets/safer-fetch/addressPolicy.d.ts +113 -0
- package/lib/packlets/safer-fetch/addressPolicy.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/addressPolicy.js +123 -0
- package/lib/packlets/safer-fetch/addressPolicy.js.map +1 -0
- package/lib/packlets/safer-fetch/contentType.d.ts +51 -0
- package/lib/packlets/safer-fetch/contentType.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/contentType.js +127 -0
- package/lib/packlets/safer-fetch/contentType.js.map +1 -0
- package/lib/packlets/safer-fetch/deadline.d.ts +138 -0
- package/lib/packlets/safer-fetch/deadline.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/deadline.js +258 -0
- package/lib/packlets/safer-fetch/deadline.js.map +1 -0
- package/lib/packlets/safer-fetch/defaults.d.ts +108 -0
- package/lib/packlets/safer-fetch/defaults.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/defaults.js +134 -0
- package/lib/packlets/safer-fetch/defaults.js.map +1 -0
- package/lib/packlets/safer-fetch/failureReason.d.ts +148 -0
- package/lib/packlets/safer-fetch/failureReason.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/failureReason.js +22 -0
- package/lib/packlets/safer-fetch/failureReason.js.map +1 -0
- package/lib/packlets/safer-fetch/guards.d.ts +62 -0
- package/lib/packlets/safer-fetch/guards.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/guards.js +153 -0
- package/lib/packlets/safer-fetch/guards.js.map +1 -0
- package/lib/packlets/safer-fetch/index.browser.d.ts +33 -0
- package/lib/packlets/safer-fetch/index.browser.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/index.browser.js +82 -0
- package/lib/packlets/safer-fetch/index.browser.js.map +1 -0
- package/lib/packlets/safer-fetch/index.d.ts +27 -0
- package/lib/packlets/safer-fetch/index.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/index.js +78 -0
- package/lib/packlets/safer-fetch/index.js.map +1 -0
- package/lib/packlets/safer-fetch/model.d.ts +373 -0
- package/lib/packlets/safer-fetch/model.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/model.js +22 -0
- package/lib/packlets/safer-fetch/model.js.map +1 -0
- package/lib/packlets/safer-fetch/nodeAddressGuard.d.ts +129 -0
- package/lib/packlets/safer-fetch/nodeAddressGuard.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/nodeAddressGuard.js +196 -0
- package/lib/packlets/safer-fetch/nodeAddressGuard.js.map +1 -0
- package/lib/packlets/safer-fetch/redirect.d.ts +62 -0
- package/lib/packlets/safer-fetch/redirect.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/redirect.js +98 -0
- package/lib/packlets/safer-fetch/redirect.js.map +1 -0
- package/lib/packlets/safer-fetch/retry.d.ts +162 -0
- package/lib/packlets/safer-fetch/retry.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/retry.js +197 -0
- package/lib/packlets/safer-fetch/retry.js.map +1 -0
- package/lib/packlets/safer-fetch/saferFetch.d.ts +108 -0
- package/lib/packlets/safer-fetch/saferFetch.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/saferFetch.js +760 -0
- package/lib/packlets/safer-fetch/saferFetch.js.map +1 -0
- package/lib/packlets/safer-fetch/transport.d.ts +18 -0
- package/lib/packlets/safer-fetch/transport.d.ts.map +1 -0
- package/lib/packlets/safer-fetch/transport.js +53 -0
- package/lib/packlets/safer-fetch/transport.js.map +1 -0
- package/package.json +17 -7
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model.js","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/model.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,EAAE;AACF,+EAA+E;AAC/E,gFAAgF;AAChF,+EAA+E;AAC/E,4EAA4E;AAC5E,wEAAwE;AACxE,2DAA2D;AAC3D,EAAE;AACF,iFAAiF;AACjF,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,8EAA8E;AAC9E,yEAAyE;AACzE,gFAAgF;AAChF,gFAAgF;AAChF,YAAY","sourcesContent":["// Copyright (c) 2026 Erik Fortune\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\nimport type { Logging, Result } from '@fgv/ts-utils';\n\nimport type { IRetryPolicy } from './retry';\n\n/**\n * HTTP methods a safer-fetch call may use.\n * @public\n */\nexport type SaferFetchMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n\n/**\n * How redirects are handled.\n *\n * @remarks\n * `'reject'` fails on any redirect status. It is the default, and the only mode with an\n * equivalent guarantee on every runtime — a browser cannot inspect a redirect hop at all, so a\n * per-hop revalidating mode is not implementable there.\n *\n * The *guarantee* is equivalent on both runtimes; the failure **reason** is not. Every call uses\n * `redirect: 'manual'`, so on Node a rejected redirect surfaces as `'redirect-rejected'` carrying\n * the status, while in a browser the response is opaque — `type` is `'opaqueredirect'` and\n * `status` is `0`, so there is no status to report — and the same redirect surfaces as\n * `'redirect-opaque'`. Callers that branch on the reason under `'reject'` must handle both.\n *\n * `'validate-each-hop'` follows redirects with `redirect: 'manual'` and runs the **full address\n * guard on every hop before any connection is made**, resolving `Location` against the hop that\n * sent it. Redirect handling and the address check are one mechanism, not two: a guard that\n * validated only the caller's URL is defeated by a single `302` to `http://169.254.169.254/`.\n * Following hops also makes this primitive responsible for credential stripping — see\n * `ISaferFetchOptions.sensitiveHeaders`.\n *\n * **`'validate-each-hop'` is accepted by the browser barrel but cannot succeed there, and fails\n * loudly rather than degrading.** The type is shared because one core serves both runtimes; the\n * runtime is not. A browser's `redirect: 'manual'` yields an opaque response — `type` is\n * `'opaqueredirect'`, `status` is `0`, and `Location` is not readable — so the first redirect\n * fails as `'redirect-opaque'`. That is the honest outcome: the hop information does not exist\n * on the browser side of the API, so there is nothing to guard and nothing to follow. Use\n * `'reject'` there, or handle `'redirect-opaque'`.\n *\n * A mode that defers to the platform's own redirect following is deliberately absent on Node:\n * it would put hops on the wire that the guard never saw.\n * @public\n */\nexport type SaferFetchRedirectPolicy = 'reject' | 'validate-each-hop';\n\n/**\n * One hop in a redirect chain. Entry 0 is the caller's original request.\n * @public\n */\nexport interface IRequestHop {\n /** The URL under consideration for this hop. */\n readonly url: URL;\n /** The redirect status that produced the NEXT hop; absent on the current one. */\n readonly status?: number;\n /**\n * The address actually connected to on this hop, when address pinning was in effect.\n *\n * @remarks\n * Populated from the address guard's {@link SaferFetch.IGuardVerdict.pinnedAddress} once the\n * transport has accepted it — which is sound because a transport that cannot honor a pin is\n * required to fail rather than connect by hostname, so a completed request with a pin set is\n * evidence the pin held. **Undefined throughout this release**, since no shipped guard pins\n * and {@link SaferFetch.platformFetchTransport} cannot honor one; the field is where the\n * rebinding defense's per-hop evidence will live.\n */\n readonly connectedAddress?: string;\n}\n\n/**\n * An address guard's decision about a hop.\n * @public\n */\nexport interface IGuardVerdict {\n /**\n * The URL cleared for request. Guards may **normalize** it — lowercase the host, strip a\n * trailing dot, punycode an IDN — and the normalized URL is what gets requested. Guards\n * must not **retarget** it to a different destination.\n *\n * @remarks\n * That contract is documented rather than enforced: origin-equality enforcement would\n * reject exactly the normalizations the guard is supposed to perform. Guards are trusted\n * first-party code; a malicious in-process caller is out of this primitive's threat model.\n * The one check that is enforced is the scheme — a verdict URL whose scheme is not\n * `http:` or `https:` is rejected.\n */\n readonly url: URL;\n\n /**\n * The address the guard validated and to which the connection SHOULD be pinned.\n *\n * @remarks\n * **Always `undefined` in this release** — reserved for the pinned-connect work that\n * closes the DNS-rebinding hole. A transport that receives a defined value it cannot\n * honor MUST fail rather than connect by hostname; {@link SaferFetch.platformFetchTransport}\n * does exactly that. Ignoring the hint would give a deployment that wired a pinning guard\n * but forgot the matching transport precisely the rebinding exposure it believed it had\n * closed, silently.\n */\n readonly pinnedAddress?: string;\n}\n\n/**\n * Decides whether a connection may be made. Invoked once per redirect hop, never only on\n * the initial URL.\n *\n * @remarks\n * This is the SSRF boundary and the one guard whose failure is catastrophic, which is why\n * it is a separate seam from the request and response policy guards: \"did the address check\n * run, and run correctly?\" should be answerable by reading one small implementation.\n *\n * The seam is **asynchronous and hop-chain-aware so that it can wrap a pure classifier**: a\n * real guard resolves `url.hostname` to an address list and then delegates the judgement to a\n * synchronous, address-list-in policy. That split keeps the part with the adversarial test\n * matrix — the address classification itself — free of transports, DNS, and hop bookkeeping,\n * and keeps exactly one implementation of it. A guard is the resolving, chain-aware half; it\n * should not re-derive what the classifier already decides.\n *\n * Classify `url.hostname`, never raw URL text: the WHATWG parser has already normalized\n * `127.0x.1` to `127.0.0.1` and `::ffff:169.254.169.254` to `::ffff:a9fe:a9fe`, and text\n * matching misses both.\n * @public\n */\nexport interface IAddressGuard {\n /** Stable identifier, surfaced in `'blocked-by-guard'` failures. */\n readonly name: string;\n\n /**\n * @param chain - Every hop so far, oldest first. The URL under consideration is the last\n * entry; `chain.length === 1` is the initial request. There is deliberately no separate\n * \"is this a redirect\" flag — hop 0 is not a special case, and a guard that compares only\n * against the immediately previous hop cannot see an `A → B → A` laundering hop.\n */\n check(chain: ReadonlyArray<IRequestHop>): Promise<Result<IGuardVerdict>>;\n}\n\n/**\n * The request a safer-fetch call is about to issue.\n * @public\n */\nexport interface ISaferFetchRequest {\n readonly url: URL;\n readonly method: SaferFetchMethod;\n /** Header names are lowercased. */\n readonly headers: Readonly<Record<string, string>>;\n readonly body?: string | Uint8Array;\n}\n\n/**\n * Response status and headers, available before any body bytes are read.\n * @public\n */\nexport interface ISaferFetchResponseHead {\n readonly status: number;\n readonly statusText: string;\n /** Header names are lowercased. */\n readonly headers: Readonly<Record<string, string>>;\n /** Raw `content-type` header value, including any parameters. */\n readonly contentType?: string;\n /** Parsed `content-length`, present only when the header was sent and is a valid count. */\n readonly contentLength?: number;\n}\n\n/**\n * Policy guard over the outbound request.\n *\n * @remarks\n * **Reject-only.** Silently rewriting a caller's headers or body inside a fetch primitive\n * means the caller no longer knows what was sent. To alter the request, return an explicit\n * replacement the caller could have observed.\n *\n * Runs **before** the address guard, so an address guard is always the last word before the\n * connect: a replacement request that changed the URL is still address-checked.\n * @public\n */\nexport interface IRequestGuard {\n readonly name: string;\n check(request: ISaferFetchRequest, chain: ReadonlyArray<IRequestHop>): Promise<Result<ISaferFetchRequest>>;\n}\n\n/**\n * Policy guard over the response head.\n *\n * @remarks\n * Runs before any body bytes are read, so a rejection costs a header comparison instead of a\n * partial body transfer. This is the right layer for content-type gating — see\n * {@link SaferFetch.allowContentTypes}.\n * @public\n */\nexport interface IResponseHeadersGuard {\n readonly name: string;\n\n /**\n * The media types this guard accepts, when it is a content-type allowlist.\n *\n * @remarks\n * Present so that a rejection can be reported as `'unsupported-content-type'` carrying the\n * accepted list, rather than as an opaque policy rejection the caller has to parse a message\n * to understand. A guard that is not a content-type allowlist omits it and its rejections are\n * reported as `'blocked-by-guard'`.\n */\n readonly acceptedContentTypes?: ReadonlyArray<string>;\n\n check(head: ISaferFetchResponseHead, chain: ReadonlyArray<IRequestHop>): Promise<Result<true>>;\n}\n\n/**\n * Policy guard over the buffered response body.\n *\n * @remarks\n * Runs on the buffered body, after the size cap has been enforced — so it can never be handed\n * an unbounded stream.\n * @public\n */\nexport interface IResponseBodyGuard {\n readonly name: string;\n check(body: Uint8Array, head: ISaferFetchResponseHead): Promise<Result<true>>;\n}\n\n/**\n * Out-of-band information a transport may need that `RequestInit` cannot express.\n * @public\n */\nexport interface IFetchTransportHints {\n /**\n * The address the address guard validated, to which this connection must be pinned.\n * A transport that cannot pin MUST fail rather than connect by hostname.\n */\n readonly pinnedAddress?: string;\n}\n\n/**\n * Performs the actual request.\n *\n * @remarks\n * Injectable for two reasons. It is the seam through which a pinned-connect implementation\n * drops in without any caller-visible signature change — which is what keeps closing the\n * DNS-rebinding hole additive rather than breaking. And it is the test seam: redirect chains,\n * lying `Content-Length` headers, compression-bomb shapes, and trickling bodies are all\n * unit-testable through a scripted transport, with no live server anywhere in the suite.\n * @public\n */\nexport interface IFetchTransport {\n readonly name: string;\n fetch(url: URL, init: RequestInit, hints: IFetchTransportHints): Promise<Result<Response>>;\n}\n\n/**\n * Options common to every safer-fetch entry point.\n * @public\n */\nexport interface ISaferFetchOptions {\n /**\n * **Required, with no default.** A passthrough default here would be exactly the failure\n * this primitive exists to avoid — advertising a guarantee it does not have. Requiring it\n * makes omission a *compile* error rather than a runtime surprise or a lint rule, and makes\n * every call site's posture greppable in one search with no ambient default to overlook.\n *\n * Use a named factory. `allowAnyAddress()` is the explicit, deliberately uncomfortable\n * opt-out, and is the only correct choice in a browser, where neither DNS resolution nor\n * redirect interposition exists.\n */\n readonly addressGuard: IAddressGuard;\n\n /**\n * Policy guard over the outbound request. Optional here and non-optional once resolved —\n * it defaults to silent passthrough, applied once at the boundary, so no downstream code\n * path branches on a guard's absence.\n */\n readonly requestGuard?: IRequestGuard;\n\n /**\n * Policy guard over the response head. Defaults to silent passthrough. Content-type gating\n * lives here — see {@link SaferFetch.allowContentTypes}; it is deliberately not a separate\n * option, which would be a second mechanism for one job.\n */\n readonly responseHeadersGuard?: IResponseHeadersGuard;\n\n /** Policy guard over the buffered body. Defaults to silent passthrough. */\n readonly responseBodyGuard?: IResponseBodyGuard;\n\n /** Defaults to `'GET'`. */\n readonly method?: SaferFetchMethod;\n\n /** Request headers. Names are lowercased before the request guard sees them. */\n readonly headers?: Readonly<Record<string, string>>;\n\n /** Request body. Not permitted with `'GET'` or `'HEAD'`. */\n readonly body?: string | Uint8Array;\n\n /**\n * Overall deadline in milliseconds, covering the connect, the response headers, and the\n * body read. Default {@link SaferFetch.DEFAULT_TIMEOUT_MS}. This is the deadline that stops\n * a response dribbling one byte every 25 seconds, which passes every per-read check.\n */\n readonly timeoutMs?: number;\n\n /**\n * Deadline to first response headers, in milliseconds. Default\n * {@link SaferFetch.DEFAULT_HEADERS_TIMEOUT_MS}. Distinguishes \"the host is not answering\"\n * from \"the host is answering slowly\", which the failure taxonomy then reports as\n * `timeout.phase`.\n *\n * @remarks\n * Measured from the start of the attempt, which includes guard evaluation — an address guard\n * that resolves DNS spends this budget too. That is deliberate: the deadline bounds the time\n * a caller waits for a usable response, not the time one layer of the implementation spends.\n */\n readonly headersTimeoutMs?: number;\n\n /**\n * Cap on decoded response bytes. Default {@link SaferFetch.DEFAULT_MAX_RESPONSE_BYTES}\n * (5 MiB).\n *\n * @remarks\n * **This knob is meant to be reached for.** 5 MiB is roughly an order of magnitude above a\n * realistic JSON API response and is generous for text, but real documents clear it — raise\n * it per call for the calls that need it rather than treating the default as a ceiling. The\n * cap is enforced by counting decoded bytes during the read, so raising it raises the\n * buffer the process may be asked to hold; size it against the heap you are willing to\n * spend, not against the largest document you can imagine.\n */\n readonly maxResponseBytes?: number;\n\n /**\n * How redirects are handled. Defaults to `'reject'`.\n *\n * @remarks\n * The conservative default is deliberate, and is the same polarity as `addressGuard` having no\n * default: one core serves both runtimes, `'reject'` is the only mode whose guarantee is\n * identical on each, and following a redirect chain into hosts the caller never named is a\n * posture worth spelling at the call site. Callers ingesting real-world URLs want\n * `'validate-each-hop'`.\n */\n readonly redirectPolicy?: SaferFetchRedirectPolicy;\n\n /**\n * Cap on redirect hops followed under `'validate-each-hop'`. Default\n * {@link SaferFetch.DEFAULT_MAX_REDIRECTS} (5). Must be a non-negative integer; `0` refuses\n * to follow any redirect.\n */\n readonly maxRedirects?: number;\n\n /**\n * Additional header names to drop on a cross-origin redirect hop, on top of the always-dropped\n * `authorization`, `cookie` and `proxy-authorization`\n * ({@link SaferFetch.ALWAYS_STRIPPED_HEADERS}).\n *\n * @remarks\n * Matching is case-insensitive. Use this for bearer-equivalent headers a deployment invented —\n * `x-api-key`, `x-auth-token`, a signed-request header. Stripping is **monotonic**: once a hop\n * has left an origin the headers are gone for the rest of the chain, so an `A` → `B` → `A`\n * chain does not hand the credential back to `A` after `B` has watched it leave.\n */\n readonly sensitiveHeaders?: ReadonlyArray<string>;\n\n /**\n * Opt-in retry. **Off by default** — a primitive that silently retries changes the semantics\n * of every call site and amplifies load against a service that is already struggling.\n *\n * @remarks\n * Retries consume `timeoutMs` and never reset it, so enabling retry does not extend the\n * deadline; and every attempt re-runs the address guard from hop 0 as a full re-walk, never a\n * resume and never a cached verdict. Both rules are load-bearing rather than incidental — see\n * {@link SaferFetch.IRetryPolicy}, which states why.\n */\n readonly retry?: IRetryPolicy;\n\n /** Defaults to {@link SaferFetch.platformFetchTransport}. */\n readonly transport?: IFetchTransport;\n\n /** Caller's cancellation signal. Reported as `'aborted'`, never as `'timeout'`. */\n readonly signal?: AbortSignal;\n\n /** Diagnostics sink. Defaults to a no-op logger. */\n readonly logger?: Logging.ILogger;\n}\n\n/**\n * The guards a call will actually use. Every field is concrete: defaults are applied once, at\n * the boundary, and nothing downstream branches on a guard's absence.\n * @public\n */\nexport interface IResolvedGuards {\n /** Caller-supplied; there is no default. */\n readonly address: IAddressGuard;\n readonly request: IRequestGuard;\n readonly responseHeaders: IResponseHeadersGuard;\n readonly responseBody: IResponseBodyGuard;\n}\n\n/**\n * A successful safer-fetch response.\n * @public\n */\nexport interface ISaferFetchResponse<T> {\n readonly value: T;\n readonly status: number;\n /** Header names are lowercased. */\n readonly headers: Readonly<Record<string, string>>;\n /**\n * Every URL actually requested, in order, as cleared by the address guard. `[0]` is the\n * caller's; later entries are redirect hops. A caller that allowlisted `api.example.com`\n * and was redirected to `cdn.example.com` usually wants to know.\n */\n readonly urlChain: ReadonlyArray<string>;\n /** Decoded bytes read from the response body. */\n readonly bytesRead: number;\n}\n"]}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Copyright (c) 2026 Erik Fortune
|
|
2
|
+
//
|
|
3
|
+
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
// of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
// in the Software without restriction, including without limitation the rights
|
|
6
|
+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
// copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
// furnished to do so, subject to the following conditions:
|
|
9
|
+
//
|
|
10
|
+
// The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
// copies or substantial portions of the Software.
|
|
12
|
+
//
|
|
13
|
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
// SOFTWARE.
|
|
20
|
+
// This module is **Node-only**: it imports `node:dns/promises` for its default resolver, so it
|
|
21
|
+
// is deliberately absent from the packlet's browser barrel. It is also the only module in the
|
|
22
|
+
// packlet that performs I/O.
|
|
23
|
+
import { captureAsyncResult, fail, succeed } from '@fgv/ts-utils';
|
|
24
|
+
import { lookup } from 'node:dns/promises';
|
|
25
|
+
import { classifyAddress } from './addressClassification';
|
|
26
|
+
import { blockPrivateNetworksPolicy } from './addressPolicy';
|
|
27
|
+
/**
|
|
28
|
+
* The default {@link SaferFetch.HostResolver}: `node:dns`'s `lookup`, returning every address.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* `lookup` rather than `resolve4`/`resolve6` deliberately. `lookup` goes through the operating
|
|
32
|
+
* system's resolver — the same path `fetch`'s connect takes — so it sees `/etc/hosts`, `nsswitch`
|
|
33
|
+
* ordering, and any local override. `resolve4` queries DNS directly and would miss a
|
|
34
|
+
* `hosts`-file entry pointing an allowlisted name at `127.0.0.1`, which is a bypass rather than
|
|
35
|
+
* a curiosity.
|
|
36
|
+
*
|
|
37
|
+
* `all: true` because the list contract is reject-if-any: a name resolving to one public and one
|
|
38
|
+
* private address must be refused, and asking for one address would hide the second.
|
|
39
|
+
*
|
|
40
|
+
* A rejected lookup (`ENOTFOUND`, `EAI_AGAIN`, …) becomes a `Failure`, never a throw.
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
export const nodeHostResolver = async (hostname) => {
|
|
44
|
+
return captureAsyncResult(async () => lookup(hostname, { all: true, verbatim: true }))
|
|
45
|
+
.withErrorFormat((message) => `failed to resolve "${hostname}": ${message}`)
|
|
46
|
+
.onSuccess((entries) => succeed(entries.map((entry) => entry.address)));
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Resolves a hop's hostname if it is not already an IP literal, and hands every resolved address
|
|
50
|
+
* to the policy.
|
|
51
|
+
*
|
|
52
|
+
* A `classifyAddress` failure means "this is not an IP literal, so resolve it" — it never means
|
|
53
|
+
* "block". Treating an unparseable literal as hostile would reject every ordinary hostname.
|
|
54
|
+
*/
|
|
55
|
+
async function _addressesFor(hostname, resolve) {
|
|
56
|
+
// Classify `url.hostname`, never raw URL text. By the time the WHATWG parser has produced a
|
|
57
|
+
// hostname, `127.0x.1` is already `127.0.0.1`, `⑫7.0.0.1` is already `127.0.0.1`, and
|
|
58
|
+
// `::ffff:169.254.169.254` is already `::ffff:a9fe:a9fe`. Text matching misses all three.
|
|
59
|
+
const literal = classifyAddress(hostname);
|
|
60
|
+
return literal.isSuccess() ? succeed([hostname]) : resolve(hostname);
|
|
61
|
+
}
|
|
62
|
+
const DEFAULT_PORTS = { 'https:': 443, 'http:': 80 };
|
|
63
|
+
/**
|
|
64
|
+
* Checks the constraints that are decidable from the URL alone, before any name resolution.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Ordered before the DNS lookup deliberately: a host that is not on the allowlist should cost a
|
|
68
|
+
* string comparison rather than a resolution, and a guard that resolves names it has already
|
|
69
|
+
* decided to refuse hands an off-allowlist hostname to the resolver for no benefit.
|
|
70
|
+
*/
|
|
71
|
+
function _checkUrl(name, url, options) {
|
|
72
|
+
var _a, _b;
|
|
73
|
+
if (url.protocol === 'http:') {
|
|
74
|
+
if (options.allowInsecureHttp !== true) {
|
|
75
|
+
return fail(`${name}: ${url.protocol} is not allowed without allowInsecureHttp`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else if (url.protocol !== 'https:') {
|
|
79
|
+
// `allowInsecureHttp` opts into **`http:`**, not into "any scheme that is not https". The
|
|
80
|
+
// core already refuses everything that is not `http:`/`https:` before a guard ever sees a
|
|
81
|
+
// URL, so this is unreachable through the shipped entry points — but a guard is a public
|
|
82
|
+
// export a caller may hold and invoke directly, and a security check that is only correct
|
|
83
|
+
// because something upstream happens to be correct is one refactor away from being wrong.
|
|
84
|
+
return fail(`${name}: ${url.protocol} is not allowed (only https:, or http: with allowInsecureHttp)`);
|
|
85
|
+
}
|
|
86
|
+
const allowHosts = (_a = options.allowHosts) !== null && _a !== void 0 ? _a : undefined;
|
|
87
|
+
if (allowHosts !== undefined && !allowHosts.some((h) => h.toLowerCase() === url.hostname)) {
|
|
88
|
+
return fail(`${name}: host "${url.hostname}" is not in the allowed host list`);
|
|
89
|
+
}
|
|
90
|
+
const allowPorts = (_b = options.allowPorts) !== null && _b !== void 0 ? _b : undefined;
|
|
91
|
+
if (allowPorts !== undefined) {
|
|
92
|
+
// An empty `port` means the scheme's default, which is the spelling almost every real URL
|
|
93
|
+
// uses — checking the raw string would reject `https://example.com/` under `[443]`.
|
|
94
|
+
const port = url.port === '' ? DEFAULT_PORTS[url.protocol] : Number(url.port);
|
|
95
|
+
if (!allowPorts.includes(port)) {
|
|
96
|
+
return fail(`${name}: port ${port} is not in the allowed port list`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return succeed(true);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Creates the recommended address guard: resolves each hop's host and requires every resolved
|
|
103
|
+
* address to be globally routable public unicast.
|
|
104
|
+
*
|
|
105
|
+
* @remarks
|
|
106
|
+
* **Node only** — it resolves names, and no browser API returns a hostname's A/AAAA records.
|
|
107
|
+
* `allowAnyAddress()` is the honest choice there, and its name says so.
|
|
108
|
+
*
|
|
109
|
+
* This is the guard an entry point's `addressGuard` option takes. It is the resolving,
|
|
110
|
+
* hop-chain-aware half; the judgement itself belongs to
|
|
111
|
+
* {@link SaferFetch.blockPrivateNetworksPolicy}, which this guard delegates to unchanged. Keeping
|
|
112
|
+
* the adversarial classification matrix in one pure, synchronous implementation is what makes
|
|
113
|
+
* "did the address check run, and run correctly?" answerable by reading one file.
|
|
114
|
+
*
|
|
115
|
+
* The guard is invoked **once per redirect hop**, on the last entry of the chain. Hop 0 is not a
|
|
116
|
+
* special case: a guard that only validated the initial URL is defeated by a single `302` to
|
|
117
|
+
* `http://169.254.169.254/`.
|
|
118
|
+
*
|
|
119
|
+
* The URL-level constraints — `https:` unless `allowInsecureHttp`, plus the optional `allowHosts`
|
|
120
|
+
* and `allowPorts` allowlists — are checked **before** the name resolution, so a host the caller
|
|
121
|
+
* never allowlisted is refused by string comparison rather than handed to a resolver.
|
|
122
|
+
*
|
|
123
|
+
* ```typescript
|
|
124
|
+
* // A local Ollama sidecar: every deviation from the default posture is named and greppable.
|
|
125
|
+
* const guard = blockPrivateNetworks({
|
|
126
|
+
* allowLoopback: true,
|
|
127
|
+
* allowInsecureHttp: true,
|
|
128
|
+
* allowHosts: ['localhost'],
|
|
129
|
+
* allowPorts: [11434]
|
|
130
|
+
* });
|
|
131
|
+
* ```
|
|
132
|
+
*
|
|
133
|
+
* **What this does not protect against.** It validates a resolved address and the transport then
|
|
134
|
+
* re-resolves, so hostile DNS can answer the two lookups differently — the documented
|
|
135
|
+
* DNS-rebinding limit, which is open in this release. A strict `allowHosts` list is the
|
|
136
|
+
* recommended posture precisely because it shrinks that exposure to "an allowlisted host's own
|
|
137
|
+
* resolver is hostile".
|
|
138
|
+
*
|
|
139
|
+
* @param options - optional relaxations of the default posture, plus the resolver seam.
|
|
140
|
+
* @returns the guard. Construction cannot fail.
|
|
141
|
+
* @public
|
|
142
|
+
*/
|
|
143
|
+
export function blockPrivateNetworks(options) {
|
|
144
|
+
var _a;
|
|
145
|
+
const settings = options !== null && options !== void 0 ? options : {};
|
|
146
|
+
const allowLoopback = settings.allowLoopback === true;
|
|
147
|
+
const resolve = (_a = settings.resolve) !== null && _a !== void 0 ? _a : nodeHostResolver;
|
|
148
|
+
const policy = blockPrivateNetworksPolicy({ allowLoopback });
|
|
149
|
+
// Every relaxation is named in the guard's own name, so a `'blocked-by-guard'` failure — and
|
|
150
|
+
// any log line carrying it — says which posture was actually in force. Two call sites with
|
|
151
|
+
// different relaxations are two distinguishable names, not one ambiguous one.
|
|
152
|
+
const relaxations = [
|
|
153
|
+
...(allowLoopback ? ['allowLoopback'] : []),
|
|
154
|
+
...(settings.allowInsecureHttp === true ? ['allowInsecureHttp'] : []),
|
|
155
|
+
...(settings.allowHosts !== undefined ? [`allowHosts=${settings.allowHosts.join('|')}`] : []),
|
|
156
|
+
...(settings.allowPorts !== undefined ? [`allowPorts=${settings.allowPorts.join('|')}`] : [])
|
|
157
|
+
];
|
|
158
|
+
const name = relaxations.length > 0 ? `blockPrivateNetworks(${relaxations.join(', ')})` : 'blockPrivateNetworks';
|
|
159
|
+
return {
|
|
160
|
+
name,
|
|
161
|
+
check: async (chain) => {
|
|
162
|
+
var _a;
|
|
163
|
+
const hop = (_a = chain[chain.length - 1]) !== null && _a !== void 0 ? _a : undefined;
|
|
164
|
+
if (hop === undefined) {
|
|
165
|
+
return fail(`${name}: hop chain is empty.`);
|
|
166
|
+
}
|
|
167
|
+
// The format applies to the resolution failure only, and deliberately does not wrap the
|
|
168
|
+
// policy's. The two layers are named separately on purpose: this guard is the resolving,
|
|
169
|
+
// chain-aware half and reports as `blockPrivateNetworks(...)`, while the pure classifier
|
|
170
|
+
// beneath it reports as `blockPrivateNetworksPolicy(...)`. A reader of either message can
|
|
171
|
+
// therefore tell which layer said no — whether DNS failed or an address was classified and
|
|
172
|
+
// refused — which is the distinction the guard/policy split exists to make legible.
|
|
173
|
+
// Flattening both to the guard name would read as more consistent and carry strictly less
|
|
174
|
+
// information. `FetchFailureReason.blocked-by-guard.guard` names the guard either way.
|
|
175
|
+
const url = _checkUrl(name, hop.url, settings);
|
|
176
|
+
if (url.isFailure()) {
|
|
177
|
+
return fail(url.message);
|
|
178
|
+
}
|
|
179
|
+
return (await _addressesFor(hop.url.hostname, resolve))
|
|
180
|
+
.withErrorFormat((message) => `${name}: ${message}`)
|
|
181
|
+
.onSuccess((addresses) => policy.checkAddresses(addresses))
|
|
182
|
+
.onSuccess(() =>
|
|
183
|
+
// `pinnedAddress` is deliberately left undefined. The guard validated a resolved
|
|
184
|
+
// address, but `platformFetchTransport` connects by hostname and would have to fail
|
|
185
|
+
// rather than honor a pin — so claiming one here would either break every call or,
|
|
186
|
+
// worse, advertise a rebinding defense that is not in place.
|
|
187
|
+
succeed({ url: hop.url }));
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=nodeAddressGuard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nodeAddressGuard.js","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/nodeAddressGuard.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright (c) 2026 Erik Fortune\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\n// This module is **Node-only**: it imports `node:dns/promises` for its default resolver, so it\n// is deliberately absent from the packlet's browser barrel. It is also the only module in the\n// packlet that performs I/O.\n\nimport { captureAsyncResult, fail, succeed, type Result } from '@fgv/ts-utils';\nimport { lookup } from 'node:dns/promises';\n\nimport { classifyAddress } from './addressClassification';\nimport {\n blockPrivateNetworksPolicy,\n type IAddressPolicy,\n type IBlockPrivateNetworksOptions\n} from './addressPolicy';\nimport type { IAddressGuard, IGuardVerdict, IRequestHop } from './model';\n\n/**\n * Resolves a hostname to every address it names.\n *\n * @remarks\n * Injectable so that the resolving half of an address guard is testable without a network — a\n * guard test that performs a real lookup fails in CI for reasons unrelated to the guard, and a\n * guard test that could reach `169.254.169.254` is worse than flaky.\n *\n * An implementation must return a `Failure` rather than reject: `node:dns` rejects on\n * `ENOTFOUND`, `EAI_AGAIN` and friends, and an entry point documented to always return a\n * `Result` must not let that escape as a throw. {@link SaferFetch.nodeHostResolver} converts.\n *\n * @param hostname - The host to resolve, with no surrounding brackets.\n * @public\n */\nexport type HostResolver = (hostname: string) => Promise<Result<ReadonlyArray<string>>>;\n\n/**\n * The default {@link SaferFetch.HostResolver}: `node:dns`'s `lookup`, returning every address.\n *\n * @remarks\n * `lookup` rather than `resolve4`/`resolve6` deliberately. `lookup` goes through the operating\n * system's resolver — the same path `fetch`'s connect takes — so it sees `/etc/hosts`, `nsswitch`\n * ordering, and any local override. `resolve4` queries DNS directly and would miss a\n * `hosts`-file entry pointing an allowlisted name at `127.0.0.1`, which is a bypass rather than\n * a curiosity.\n *\n * `all: true` because the list contract is reject-if-any: a name resolving to one public and one\n * private address must be refused, and asking for one address would hide the second.\n *\n * A rejected lookup (`ENOTFOUND`, `EAI_AGAIN`, …) becomes a `Failure`, never a throw.\n * @public\n */\nexport const nodeHostResolver: HostResolver = async (\n hostname: string\n): Promise<Result<ReadonlyArray<string>>> => {\n return captureAsyncResult(async () => lookup(hostname, { all: true, verbatim: true }))\n .withErrorFormat((message) => `failed to resolve \"${hostname}\": ${message}`)\n .onSuccess((entries) => succeed(entries.map((entry) => entry.address)));\n};\n\n/**\n * Options for {@link SaferFetch.blockPrivateNetworks}.\n * @public\n */\nexport interface IBlockPrivateNetworksGuardOptions extends IBlockPrivateNetworksOptions {\n /**\n * Restricts every hop to these hostnames. Absent means any hostname.\n *\n * @remarks\n * Matching is case-insensitive and exact — no wildcards and no suffix matching, because\n * `endsWith('.example.com')` is the classic host-allowlist bypass (`evil-example.com`,\n * `example.com.attacker.net`) and a primitive that offers the convenient form invites it.\n * List the hosts.\n *\n * **A host allowlist is the recommended posture**, and it is stronger than address\n * classification alone: with one, DNS rebinding can only be mounted by an allowlisted host's\n * own resolver, which is a far smaller surface than \"any hostname the caller was handed\".\n */\n readonly allowHosts?: ReadonlyArray<string>;\n\n /**\n * Restricts every hop to these ports. Absent means any port.\n *\n * @remarks\n * A URL with no explicit port is checked against the scheme's default — `443` for `https:`,\n * `80` for `http:` — so `allowPorts: [443]` accepts `https://example.com/`.\n */\n readonly allowPorts?: ReadonlyArray<number>;\n\n /**\n * Permits `http:` hops. **Off by default**, so this guard requires `https:`.\n *\n * @remarks\n * The core refuses everything that is not `http:` or `https:` and deliberately chooses\n * between those two here rather than there, because the choice is a posture rather than a\n * structural rule. Plaintext HTTP is exposed to a network-position attacker and is the scheme\n * every SSRF payload reaches for, so the polarity matches `allowLoopback`: deny by default,\n * opt in visibly, and let a reviewer grep for the opt-in.\n */\n readonly allowInsecureHttp?: boolean;\n\n /**\n * Name resolution. Defaults to {@link SaferFetch.nodeHostResolver}.\n *\n * @remarks\n * Present so the guard is unit-testable with no network. It is **not** a hook through which\n * DNS rebinding can be closed: swapping the resolver changes only the address the guard\n * validates, while the transport still connects by hostname and resolves again. Closing that\n * requires a pinning transport — see `IGuardVerdict.pinnedAddress`.\n */\n readonly resolve?: HostResolver;\n}\n\n/**\n * Resolves a hop's hostname if it is not already an IP literal, and hands every resolved address\n * to the policy.\n *\n * A `classifyAddress` failure means \"this is not an IP literal, so resolve it\" — it never means\n * \"block\". Treating an unparseable literal as hostile would reject every ordinary hostname.\n */\nasync function _addressesFor(\n hostname: string,\n resolve: HostResolver\n): Promise<Result<ReadonlyArray<string>>> {\n // Classify `url.hostname`, never raw URL text. By the time the WHATWG parser has produced a\n // hostname, `127.0x.1` is already `127.0.0.1`, `⑫7.0.0.1` is already `127.0.0.1`, and\n // `::ffff:169.254.169.254` is already `::ffff:a9fe:a9fe`. Text matching misses all three.\n const literal = classifyAddress(hostname);\n return literal.isSuccess() ? succeed([hostname]) : resolve(hostname);\n}\n\nconst DEFAULT_PORTS: Readonly<Record<string, number>> = { 'https:': 443, 'http:': 80 };\n\n/**\n * Checks the constraints that are decidable from the URL alone, before any name resolution.\n *\n * @remarks\n * Ordered before the DNS lookup deliberately: a host that is not on the allowlist should cost a\n * string comparison rather than a resolution, and a guard that resolves names it has already\n * decided to refuse hands an off-allowlist hostname to the resolver for no benefit.\n */\nfunction _checkUrl(name: string, url: URL, options: IBlockPrivateNetworksGuardOptions): Result<true> {\n if (url.protocol === 'http:') {\n if (options.allowInsecureHttp !== true) {\n return fail(`${name}: ${url.protocol} is not allowed without allowInsecureHttp`);\n }\n } else if (url.protocol !== 'https:') {\n // `allowInsecureHttp` opts into **`http:`**, not into \"any scheme that is not https\". The\n // core already refuses everything that is not `http:`/`https:` before a guard ever sees a\n // URL, so this is unreachable through the shipped entry points — but a guard is a public\n // export a caller may hold and invoke directly, and a security check that is only correct\n // because something upstream happens to be correct is one refactor away from being wrong.\n return fail(`${name}: ${url.protocol} is not allowed (only https:, or http: with allowInsecureHttp)`);\n }\n\n const allowHosts = options.allowHosts ?? undefined;\n if (allowHosts !== undefined && !allowHosts.some((h) => h.toLowerCase() === url.hostname)) {\n return fail(`${name}: host \"${url.hostname}\" is not in the allowed host list`);\n }\n\n const allowPorts = options.allowPorts ?? undefined;\n if (allowPorts !== undefined) {\n // An empty `port` means the scheme's default, which is the spelling almost every real URL\n // uses — checking the raw string would reject `https://example.com/` under `[443]`.\n const port = url.port === '' ? DEFAULT_PORTS[url.protocol] : Number(url.port);\n if (!allowPorts.includes(port)) {\n return fail(`${name}: port ${port} is not in the allowed port list`);\n }\n }\n\n return succeed(true as const);\n}\n\n/**\n * Creates the recommended address guard: resolves each hop's host and requires every resolved\n * address to be globally routable public unicast.\n *\n * @remarks\n * **Node only** — it resolves names, and no browser API returns a hostname's A/AAAA records.\n * `allowAnyAddress()` is the honest choice there, and its name says so.\n *\n * This is the guard an entry point's `addressGuard` option takes. It is the resolving,\n * hop-chain-aware half; the judgement itself belongs to\n * {@link SaferFetch.blockPrivateNetworksPolicy}, which this guard delegates to unchanged. Keeping\n * the adversarial classification matrix in one pure, synchronous implementation is what makes\n * \"did the address check run, and run correctly?\" answerable by reading one file.\n *\n * The guard is invoked **once per redirect hop**, on the last entry of the chain. Hop 0 is not a\n * special case: a guard that only validated the initial URL is defeated by a single `302` to\n * `http://169.254.169.254/`.\n *\n * The URL-level constraints — `https:` unless `allowInsecureHttp`, plus the optional `allowHosts`\n * and `allowPorts` allowlists — are checked **before** the name resolution, so a host the caller\n * never allowlisted is refused by string comparison rather than handed to a resolver.\n *\n * ```typescript\n * // A local Ollama sidecar: every deviation from the default posture is named and greppable.\n * const guard = blockPrivateNetworks({\n * allowLoopback: true,\n * allowInsecureHttp: true,\n * allowHosts: ['localhost'],\n * allowPorts: [11434]\n * });\n * ```\n *\n * **What this does not protect against.** It validates a resolved address and the transport then\n * re-resolves, so hostile DNS can answer the two lookups differently — the documented\n * DNS-rebinding limit, which is open in this release. A strict `allowHosts` list is the\n * recommended posture precisely because it shrinks that exposure to \"an allowlisted host's own\n * resolver is hostile\".\n *\n * @param options - optional relaxations of the default posture, plus the resolver seam.\n * @returns the guard. Construction cannot fail.\n * @public\n */\nexport function blockPrivateNetworks(options?: IBlockPrivateNetworksGuardOptions): IAddressGuard {\n const settings: IBlockPrivateNetworksGuardOptions = options ?? {};\n const allowLoopback: boolean = settings.allowLoopback === true;\n const resolve: HostResolver = settings.resolve ?? nodeHostResolver;\n const policy: IAddressPolicy = blockPrivateNetworksPolicy({ allowLoopback });\n // Every relaxation is named in the guard's own name, so a `'blocked-by-guard'` failure — and\n // any log line carrying it — says which posture was actually in force. Two call sites with\n // different relaxations are two distinguishable names, not one ambiguous one.\n const relaxations: ReadonlyArray<string> = [\n ...(allowLoopback ? ['allowLoopback'] : []),\n ...(settings.allowInsecureHttp === true ? ['allowInsecureHttp'] : []),\n ...(settings.allowHosts !== undefined ? [`allowHosts=${settings.allowHosts.join('|')}`] : []),\n ...(settings.allowPorts !== undefined ? [`allowPorts=${settings.allowPorts.join('|')}`] : [])\n ];\n const name: string =\n relaxations.length > 0 ? `blockPrivateNetworks(${relaxations.join(', ')})` : 'blockPrivateNetworks';\n\n return {\n name,\n check: async (chain: ReadonlyArray<IRequestHop>): Promise<Result<IGuardVerdict>> => {\n const hop = chain[chain.length - 1] ?? undefined;\n if (hop === undefined) {\n return fail(`${name}: hop chain is empty.`);\n }\n // The format applies to the resolution failure only, and deliberately does not wrap the\n // policy's. The two layers are named separately on purpose: this guard is the resolving,\n // chain-aware half and reports as `blockPrivateNetworks(...)`, while the pure classifier\n // beneath it reports as `blockPrivateNetworksPolicy(...)`. A reader of either message can\n // therefore tell which layer said no — whether DNS failed or an address was classified and\n // refused — which is the distinction the guard/policy split exists to make legible.\n // Flattening both to the guard name would read as more consistent and carry strictly less\n // information. `FetchFailureReason.blocked-by-guard.guard` names the guard either way.\n const url = _checkUrl(name, hop.url, settings);\n if (url.isFailure()) {\n return fail(url.message);\n }\n return (await _addressesFor(hop.url.hostname, resolve))\n .withErrorFormat((message) => `${name}: ${message}`)\n .onSuccess((addresses) => policy.checkAddresses(addresses))\n .onSuccess(() =>\n // `pinnedAddress` is deliberately left undefined. The guard validated a resolved\n // address, but `platformFetchTransport` connects by hostname and would have to fail\n // rather than honor a pin — so claiming one here would either break every call or,\n // worse, advertise a rebinding defense that is not in place.\n succeed({ url: hop.url })\n );\n }\n };\n}\n"]}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Copyright (c) 2026 Erik Fortune
|
|
2
|
+
//
|
|
3
|
+
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
// of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
// in the Software without restriction, including without limitation the rights
|
|
6
|
+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
// copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
// furnished to do so, subject to the following conditions:
|
|
9
|
+
//
|
|
10
|
+
// The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
// copies or substantial portions of the Software.
|
|
12
|
+
//
|
|
13
|
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
// SOFTWARE.
|
|
20
|
+
import { captureResult } from '@fgv/ts-utils';
|
|
21
|
+
import { ALWAYS_STRIPPED_HEADERS } from './defaults';
|
|
22
|
+
/** Headers describing a body that is about to be dropped, so they must go with it. */
|
|
23
|
+
const BODY_DESCRIBING_HEADERS = ['content-type', 'content-length'];
|
|
24
|
+
/**
|
|
25
|
+
* Builds the set of header names to drop on a cross-origin hop.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Lowercased on the way in so the comparison is case-insensitive: HTTP header names are, and a
|
|
29
|
+
* request guard returning a replacement request spelled `Authorization` must not slip a
|
|
30
|
+
* credential past a set keyed on `authorization`.
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export function sensitiveHeaderSet(extra) {
|
|
34
|
+
return new Set([...ALWAYS_STRIPPED_HEADERS, ...(extra !== null && extra !== void 0 ? extra : [])].map((n) => n.toLowerCase()));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolves a `Location` header against the URL of the hop that sent it.
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* Resolved against the **current** hop, not the caller's original URL: a relative `Location` on
|
|
41
|
+
* the third hop of a chain is relative to the third hop.
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
export function resolveLocation(location, base) {
|
|
45
|
+
return captureResult(() => new URL(location, base)).withErrorFormat((message) => `cannot resolve redirect target "${location}": ${message}`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Derives the next hop's method, headers and body from the hop that redirected.
|
|
49
|
+
*
|
|
50
|
+
* @remarks
|
|
51
|
+
* **Method and body** follow the platform, because a primitive that redirects differently from
|
|
52
|
+
* `fetch` surprises callers in a way no documentation repairs. Per the Fetch standard: `301` and
|
|
53
|
+
* `302` convert a `POST` to a `GET` and drop the body, and leave every other method alone; `303`
|
|
54
|
+
* converts anything that is not already `GET`/`HEAD`; `307` and `308` preserve both. `status` is
|
|
55
|
+
* on `IRequestHop` for exactly this reason. A dropped body takes `Content-Type` and
|
|
56
|
+
* `Content-Length` with it — describing a body that is no longer there is not a rounding error
|
|
57
|
+
* on a request a guard is about to inspect.
|
|
58
|
+
*
|
|
59
|
+
* **Credential stripping is monotonic.** The comparison is against the hop that redirected, and
|
|
60
|
+
* the headers carried in are the *previous hop's* headers, not the caller's original set. That
|
|
61
|
+
* distinction is the whole game: `A`(authenticated) → `B` → `A` must not re-attach on the final
|
|
62
|
+
* hop. Re-deriving each hop's headers from the caller's original request and comparing to the
|
|
63
|
+
* caller's original origin finds the last hop same-origin with `A` and hands the token back —
|
|
64
|
+
* after `B` has already observed the chain. It reviews as correct and it is a real leak.
|
|
65
|
+
* @internal
|
|
66
|
+
*/
|
|
67
|
+
export function rewriteForRedirect(params) {
|
|
68
|
+
const crossOrigin = params.to.origin !== params.from.origin;
|
|
69
|
+
const method = _rewriteMethod(params.status, params.method);
|
|
70
|
+
const dropsBody = method !== params.method;
|
|
71
|
+
const drop = new Set([
|
|
72
|
+
...(crossOrigin ? params.sensitiveHeaders : []),
|
|
73
|
+
...(dropsBody ? BODY_DESCRIBING_HEADERS : [])
|
|
74
|
+
]);
|
|
75
|
+
const headers = {};
|
|
76
|
+
for (const [name, value] of Object.entries(params.headers)) {
|
|
77
|
+
const lowered = name.toLowerCase();
|
|
78
|
+
if (!drop.has(lowered)) {
|
|
79
|
+
headers[lowered] = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { method, headers, body: dropsBody ? undefined : params.body };
|
|
83
|
+
}
|
|
84
|
+
function _rewriteMethod(status, method) {
|
|
85
|
+
if ((status === 301 || status === 302) && method === 'POST') {
|
|
86
|
+
return 'GET';
|
|
87
|
+
}
|
|
88
|
+
if (status === 303 && method !== 'GET' && method !== 'HEAD') {
|
|
89
|
+
return 'GET';
|
|
90
|
+
}
|
|
91
|
+
return method;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=redirect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redirect.js","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/redirect.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright (c) 2026 Erik Fortune\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\nimport { captureResult, type Result } from '@fgv/ts-utils';\n\nimport { ALWAYS_STRIPPED_HEADERS } from './defaults';\nimport type { SaferFetchMethod } from './model';\n\n/** Headers describing a body that is about to be dropped, so they must go with it. */\nconst BODY_DESCRIBING_HEADERS: ReadonlyArray<string> = ['content-type', 'content-length'];\n\n/**\n * Builds the set of header names to drop on a cross-origin hop.\n *\n * @remarks\n * Lowercased on the way in so the comparison is case-insensitive: HTTP header names are, and a\n * request guard returning a replacement request spelled `Authorization` must not slip a\n * credential past a set keyed on `authorization`.\n * @internal\n */\nexport function sensitiveHeaderSet(extra?: ReadonlyArray<string>): ReadonlySet<string> {\n return new Set<string>([...ALWAYS_STRIPPED_HEADERS, ...(extra ?? [])].map((n) => n.toLowerCase()));\n}\n\n/**\n * Resolves a `Location` header against the URL of the hop that sent it.\n *\n * @remarks\n * Resolved against the **current** hop, not the caller's original URL: a relative `Location` on\n * the third hop of a chain is relative to the third hop.\n * @internal\n */\nexport function resolveLocation(location: string, base: URL): Result<URL> {\n return captureResult(() => new URL(location, base)).withErrorFormat(\n (message) => `cannot resolve redirect target \"${location}\": ${message}`\n );\n}\n\n/** The request to issue on the next hop. @internal */\nexport interface IRedirectRewrite {\n readonly method: SaferFetchMethod;\n /** Header names are lowercased; credential headers are absent on a cross-origin hop. */\n readonly headers: Record<string, string>;\n readonly body: string | Uint8Array | undefined;\n}\n\n/** Inputs to {@link rewriteForRedirect}. @internal */\nexport interface IRedirectRewriteParams {\n /** The URL that was requested and answered with the redirect. */\n readonly from: URL;\n /** The already-resolved redirect target. */\n readonly to: URL;\n readonly status: number;\n readonly method: SaferFetchMethod;\n readonly headers: Readonly<Record<string, string>>;\n readonly body: string | Uint8Array | undefined;\n readonly sensitiveHeaders: ReadonlySet<string>;\n}\n\n/**\n * Derives the next hop's method, headers and body from the hop that redirected.\n *\n * @remarks\n * **Method and body** follow the platform, because a primitive that redirects differently from\n * `fetch` surprises callers in a way no documentation repairs. Per the Fetch standard: `301` and\n * `302` convert a `POST` to a `GET` and drop the body, and leave every other method alone; `303`\n * converts anything that is not already `GET`/`HEAD`; `307` and `308` preserve both. `status` is\n * on `IRequestHop` for exactly this reason. A dropped body takes `Content-Type` and\n * `Content-Length` with it — describing a body that is no longer there is not a rounding error\n * on a request a guard is about to inspect.\n *\n * **Credential stripping is monotonic.** The comparison is against the hop that redirected, and\n * the headers carried in are the *previous hop's* headers, not the caller's original set. That\n * distinction is the whole game: `A`(authenticated) → `B` → `A` must not re-attach on the final\n * hop. Re-deriving each hop's headers from the caller's original request and comparing to the\n * caller's original origin finds the last hop same-origin with `A` and hands the token back —\n * after `B` has already observed the chain. It reviews as correct and it is a real leak.\n * @internal\n */\nexport function rewriteForRedirect(params: IRedirectRewriteParams): IRedirectRewrite {\n const crossOrigin: boolean = params.to.origin !== params.from.origin;\n const method: SaferFetchMethod = _rewriteMethod(params.status, params.method);\n const dropsBody: boolean = method !== params.method;\n const drop: ReadonlySet<string> = new Set<string>([\n ...(crossOrigin ? params.sensitiveHeaders : []),\n ...(dropsBody ? BODY_DESCRIBING_HEADERS : [])\n ]);\n\n const headers: Record<string, string> = {};\n for (const [name, value] of Object.entries(params.headers)) {\n const lowered = name.toLowerCase();\n if (!drop.has(lowered)) {\n headers[lowered] = value;\n }\n }\n\n return { method, headers, body: dropsBody ? undefined : params.body };\n}\n\nfunction _rewriteMethod(status: number, method: SaferFetchMethod): SaferFetchMethod {\n if ((status === 301 || status === 302) && method === 'POST') {\n return 'GET';\n }\n if (status === 303 && method !== 'GET' && method !== 'HEAD') {\n return 'GET';\n }\n return method;\n}\n"]}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Copyright (c) 2026 Erik Fortune
|
|
2
|
+
//
|
|
3
|
+
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
// of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
// in the Software without restriction, including without limitation the rights
|
|
6
|
+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
// copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
// furnished to do so, subject to the following conditions:
|
|
9
|
+
//
|
|
10
|
+
// The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
// copies or substantial portions of the Software.
|
|
12
|
+
//
|
|
13
|
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
// SOFTWARE.
|
|
20
|
+
import { fail, succeed } from '@fgv/ts-utils';
|
|
21
|
+
import { DEFAULT_RETRY_BASE_DELAY_MS, DEFAULT_RETRY_MAX_DELAY_MS, IDEMPOTENT_METHODS, RETRY_AFTER_STATUSES, RETRYABLE_HTTP_STATUSES } from './defaults';
|
|
22
|
+
/** No retry. The resolved shape of an absent `retry` option. @internal */
|
|
23
|
+
export const NO_RETRY = {
|
|
24
|
+
attempts: 0,
|
|
25
|
+
baseDelayMs: DEFAULT_RETRY_BASE_DELAY_MS,
|
|
26
|
+
maxDelayMs: DEFAULT_RETRY_MAX_DELAY_MS,
|
|
27
|
+
retryNonIdempotent: false,
|
|
28
|
+
respectRetryAfter: true
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Applies the retry defaults, rejecting a policy that cannot mean anything.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Validated for the same reason `redirectPolicy` and `sensitiveHeaders` are: a JavaScript
|
|
35
|
+
* caller can pass anything, and a `NaN` delay silently becomes a `setTimeout(NaN)` that fires
|
|
36
|
+
* immediately — turning a backoff into a tight retry loop against a struggling service.
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
export function resolveRetryPolicy(retry) {
|
|
40
|
+
var _a, _b, _c;
|
|
41
|
+
// `?? undefined` rather than `!== undefined`, matching every other option: a `null` from a
|
|
42
|
+
// JavaScript caller means "absent", never "installed".
|
|
43
|
+
const policy = retry !== null && retry !== void 0 ? retry : undefined;
|
|
44
|
+
if (policy === undefined) {
|
|
45
|
+
return succeed(NO_RETRY);
|
|
46
|
+
}
|
|
47
|
+
const attempts = policy.attempts;
|
|
48
|
+
if (!Number.isInteger(attempts) || attempts < 0) {
|
|
49
|
+
return fail(`retry.attempts must be a non-negative integer; got ${attempts}.`);
|
|
50
|
+
}
|
|
51
|
+
const baseDelayMs = (_a = policy.baseDelayMs) !== null && _a !== void 0 ? _a : DEFAULT_RETRY_BASE_DELAY_MS;
|
|
52
|
+
const maxDelayMs = (_b = policy.maxDelayMs) !== null && _b !== void 0 ? _b : DEFAULT_RETRY_MAX_DELAY_MS;
|
|
53
|
+
const delays = [
|
|
54
|
+
['retry.baseDelayMs', baseDelayMs],
|
|
55
|
+
['retry.maxDelayMs', maxDelayMs]
|
|
56
|
+
];
|
|
57
|
+
for (const [name, value] of delays) {
|
|
58
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
59
|
+
return fail(`${name} must be a non-negative finite number; got ${value}.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return succeed({
|
|
63
|
+
attempts,
|
|
64
|
+
baseDelayMs,
|
|
65
|
+
maxDelayMs,
|
|
66
|
+
retryNonIdempotent: policy.retryNonIdempotent === true,
|
|
67
|
+
// Defaults to honoring the header, so `?? true` rather than the `=== true` the other two
|
|
68
|
+
// flags use.
|
|
69
|
+
respectRetryAfter: (_c = policy.respectRetryAfter) !== null && _c !== void 0 ? _c : true
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Decides whether a failure is one a later attempt could plausibly answer differently.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* The `switch` is exhaustive over the taxonomy on purpose: a new failure kind should have to be
|
|
77
|
+
* classified deliberately rather than inherit "retryable" or "not" from a default branch.
|
|
78
|
+
*
|
|
79
|
+
* `'blocked-by-guard'` can never succeed on a retry — the guard's answer is about the
|
|
80
|
+
* destination, not about the moment. `'too-large'` is the sharper case: retrying it
|
|
81
|
+
* re-downloads up to the cap every time, which turns a defense into an amplifier aimed at the
|
|
82
|
+
* caller's own bandwidth.
|
|
83
|
+
* @internal
|
|
84
|
+
*/
|
|
85
|
+
export function isRetryableFailure(reason) {
|
|
86
|
+
// A failure with no detail is one this primitive could not classify, and an unclassifiable
|
|
87
|
+
// failure is not one to retry — the whole basis for retrying is knowing what went wrong.
|
|
88
|
+
if (reason === undefined) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
switch (reason.kind) {
|
|
92
|
+
case 'network':
|
|
93
|
+
case 'timeout':
|
|
94
|
+
return true;
|
|
95
|
+
case 'http-status':
|
|
96
|
+
return RETRYABLE_HTTP_STATUSES.includes(reason.status);
|
|
97
|
+
case 'invalid-url':
|
|
98
|
+
case 'blocked-by-guard':
|
|
99
|
+
case 'redirect-rejected':
|
|
100
|
+
case 'redirect-opaque':
|
|
101
|
+
case 'too-many-redirects':
|
|
102
|
+
case 'aborted':
|
|
103
|
+
case 'too-large':
|
|
104
|
+
case 'unsupported-content-type':
|
|
105
|
+
case 'decode':
|
|
106
|
+
case 'parse':
|
|
107
|
+
case 'unknown':
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Whether this method may be retried under the supplied policy.
|
|
113
|
+
* @internal
|
|
114
|
+
*/
|
|
115
|
+
export function isRetryableMethod(method, policy) {
|
|
116
|
+
return policy.retryNonIdempotent || IDEMPOTENT_METHODS.includes(method);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether a `Retry-After` header on this failure is one to honor.
|
|
120
|
+
*
|
|
121
|
+
* @remarks
|
|
122
|
+
* Only on `429` and `503`, and only when the policy asks for it. A `500` carrying a
|
|
123
|
+
* `Retry-After` does not get to dictate the caller's schedule.
|
|
124
|
+
* @internal
|
|
125
|
+
*/
|
|
126
|
+
export function honorsRetryAfter(reason, policy) {
|
|
127
|
+
return (policy.respectRetryAfter && (reason === null || reason === void 0 ? void 0 : reason.kind) === 'http-status' && RETRY_AFTER_STATUSES.includes(reason.status));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Parses a `Retry-After` header value into milliseconds.
|
|
131
|
+
*
|
|
132
|
+
* @remarks
|
|
133
|
+
* Both RFC 9110 forms: delta-seconds, and an HTTP-date which is converted to a delay relative
|
|
134
|
+
* to `nowMs`. An unparseable value, a negative delta, or a date already in the past yields
|
|
135
|
+
* `undefined` — "the server said nothing usable", which falls back to ordinary backoff rather
|
|
136
|
+
* than to zero delay. Returning `0` for a past date would let a stale clock produce a hot loop.
|
|
137
|
+
*
|
|
138
|
+
* The value is **not** clamped here. Clamping belongs with the rest of the delay arithmetic in
|
|
139
|
+
* {@link computeRetryDelayMs}, so there is exactly one place the ceiling is applied.
|
|
140
|
+
* @internal
|
|
141
|
+
*/
|
|
142
|
+
export function parseRetryAfterMs(value, nowMs) {
|
|
143
|
+
const text = (value !== null && value !== void 0 ? value : '').trim();
|
|
144
|
+
if (text.length === 0) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
// Digits only: a delta-seconds value. Tested before `Date.parse`, which accepts bare numbers
|
|
148
|
+
// as years in some engines and would read `Retry-After: 120` as the year 120.
|
|
149
|
+
if (/^\d+$/.test(text)) {
|
|
150
|
+
return Number(text) * 1000;
|
|
151
|
+
}
|
|
152
|
+
const at = Date.parse(text);
|
|
153
|
+
if (Number.isNaN(at)) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
const delay = at - nowMs;
|
|
157
|
+
return delay > 0 ? delay : undefined;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Computes how long to wait before the next attempt.
|
|
161
|
+
*
|
|
162
|
+
* @remarks
|
|
163
|
+
* **Exponential backoff with full jitter**: the delay is uniform on `[0, cap)` where `cap` is
|
|
164
|
+
* `baseDelayMs * 2^attempt` bounded by `maxDelayMs`. Full jitter rather than a fixed backoff
|
|
165
|
+
* because every caller that failed against the same struggling host would otherwise retry in
|
|
166
|
+
* the same instant, which is the thundering herd the backoff was supposed to prevent.
|
|
167
|
+
*
|
|
168
|
+
* **A honored `Retry-After` replaces the computed backoff and is clamped to `maxDelayMs`.** The
|
|
169
|
+
* clamp is not a rounding detail: `Retry-After` is chosen by the server, which in this threat
|
|
170
|
+
* model may be the adversary, so an unclamped `Retry-After: 86400` is a day-long sleep the
|
|
171
|
+
* caller never agreed to. Jitter is not applied to it — the server named an instant, and
|
|
172
|
+
* scattering around it is the caller's herd problem, not the server's instruction.
|
|
173
|
+
*
|
|
174
|
+
* `2 ** attempt` overflows to `Infinity` for a large enough `attempt`; `Math.min` with a finite
|
|
175
|
+
* ceiling absorbs that, which is why the cap is computed in this order.
|
|
176
|
+
* @internal
|
|
177
|
+
*/
|
|
178
|
+
export function computeRetryDelayMs(params) {
|
|
179
|
+
var _a;
|
|
180
|
+
const { attempt, policy, random } = params;
|
|
181
|
+
const retryAfterMs = (_a = params.retryAfterMs) !== null && _a !== void 0 ? _a : undefined;
|
|
182
|
+
if (retryAfterMs !== undefined) {
|
|
183
|
+
return Math.min(retryAfterMs, policy.maxDelayMs);
|
|
184
|
+
}
|
|
185
|
+
const cap = Math.min(policy.baseDelayMs * 2 ** attempt, policy.maxDelayMs);
|
|
186
|
+
return random() * cap;
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=retry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.js","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/retry.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright (c) 2026 Erik Fortune\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\nimport { fail, succeed, type Result } from '@fgv/ts-utils';\n\nimport {\n DEFAULT_RETRY_BASE_DELAY_MS,\n DEFAULT_RETRY_MAX_DELAY_MS,\n IDEMPOTENT_METHODS,\n RETRY_AFTER_STATUSES,\n RETRYABLE_HTTP_STATUSES\n} from './defaults';\nimport type { FetchFailureReason } from './failureReason';\nimport type { SaferFetchMethod } from './model';\n\n/**\n * Opt-in retry policy.\n *\n * @remarks\n * **Retry is off by default and has to be asked for.** A primitive that silently retries\n * changes the semantics of every call site and amplifies load against a service that is already\n * struggling.\n *\n * Two rules are load-bearing rather than incidental, and are worth reading before enabling this:\n *\n * **Every attempt re-runs the address guard from hop 0 — a full re-walk, never a resume and\n * never a cached verdict.** Reusing an earlier attempt's verdict would make retry its own\n * DNS-rebinding vector: an attacker needs the guard to pass once and a connect to land on a\n * private address, so `N` connects against **one** check multiplies their odds by the retry\n * count — and a retry delay is precisely when a short-TTL rebind lands. A resume is\n * independently wrong because the redirect chain is not stable across attempts: attempt 2's\n * server may answer with a different `Location`, so revalidating only the current hop would\n * validate a chain the request no longer follows. The consequence is that retries cost more\n * than a naive implementation would suggest — each carries full re-resolution — and that cost\n * is deliberate.\n *\n * **The overall deadline is the ceiling.** Retries consume `timeoutMs` and never reset it, so\n * `{ timeoutMs: 30_000, retry: { attempts: 3 } }` is a thirty-second call, not a two-minute\n * one. When the remaining budget is shorter than the computed delay, the call fails with the\n * attempt's own failure rather than sleeping past its deadline.\n * @public\n */\nexport interface IRetryPolicy {\n /**\n * Additional attempts after the first, **not** a total. `attempts: 2` means up to three\n * requests. Must be a non-negative integer; `0` disables retry.\n */\n readonly attempts: number;\n\n /**\n * First backoff delay, in milliseconds, doubled per attempt before jitter. Default\n * {@link SaferFetch.DEFAULT_RETRY_BASE_DELAY_MS}.\n */\n readonly baseDelayMs?: number;\n\n /**\n * Ceiling on any single delay, in milliseconds. Default\n * {@link SaferFetch.DEFAULT_RETRY_MAX_DELAY_MS}. Also clamps a server-supplied\n * `Retry-After`, which is the security-relevant half of its job.\n */\n readonly maxDelayMs?: number;\n\n /**\n * Retries `POST` / `PUT` / `PATCH` / `DELETE` as well as `GET` / `HEAD`. Default `false`.\n *\n * @remarks\n * Off by default because a timeout does not tell you whether the server processed the\n * request: a retried `POST` after a timeout can double-charge. Turn it on for endpoints you\n * know are idempotent — an idempotency key, a `PUT` of a whole resource — and not otherwise.\n */\n readonly retryNonIdempotent?: boolean;\n\n /**\n * Honors a `Retry-After` header on `429` and `503`. Default `true`.\n *\n * @remarks\n * Always clamped to `maxDelayMs`, whatever this is set to. The header is chosen by the\n * server, which in this threat model may be the adversary.\n */\n readonly respectRetryAfter?: boolean;\n}\n\n/**\n * A {@link SaferFetch.IRetryPolicy} with every default applied, so no downstream code path\n * branches on a field's absence.\n * @internal\n */\nexport interface IResolvedRetryPolicy {\n readonly attempts: number;\n readonly baseDelayMs: number;\n readonly maxDelayMs: number;\n readonly retryNonIdempotent: boolean;\n readonly respectRetryAfter: boolean;\n}\n\n/** No retry. The resolved shape of an absent `retry` option. @internal */\nexport const NO_RETRY: IResolvedRetryPolicy = {\n attempts: 0,\n baseDelayMs: DEFAULT_RETRY_BASE_DELAY_MS,\n maxDelayMs: DEFAULT_RETRY_MAX_DELAY_MS,\n retryNonIdempotent: false,\n respectRetryAfter: true\n};\n\n/**\n * Applies the retry defaults, rejecting a policy that cannot mean anything.\n *\n * @remarks\n * Validated for the same reason `redirectPolicy` and `sensitiveHeaders` are: a JavaScript\n * caller can pass anything, and a `NaN` delay silently becomes a `setTimeout(NaN)` that fires\n * immediately — turning a backoff into a tight retry loop against a struggling service.\n * @internal\n */\nexport function resolveRetryPolicy(retry?: IRetryPolicy): Result<IResolvedRetryPolicy> {\n // `?? undefined` rather than `!== undefined`, matching every other option: a `null` from a\n // JavaScript caller means \"absent\", never \"installed\".\n const policy = retry ?? undefined;\n if (policy === undefined) {\n return succeed(NO_RETRY);\n }\n\n const attempts = policy.attempts;\n if (!Number.isInteger(attempts) || attempts < 0) {\n return fail(`retry.attempts must be a non-negative integer; got ${attempts}.`);\n }\n\n const baseDelayMs = policy.baseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;\n const maxDelayMs = policy.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS;\n const delays: ReadonlyArray<readonly [string, number]> = [\n ['retry.baseDelayMs', baseDelayMs],\n ['retry.maxDelayMs', maxDelayMs]\n ];\n for (const [name, value] of delays) {\n if (!Number.isFinite(value) || value < 0) {\n return fail(`${name} must be a non-negative finite number; got ${value}.`);\n }\n }\n\n return succeed({\n attempts,\n baseDelayMs,\n maxDelayMs,\n retryNonIdempotent: policy.retryNonIdempotent === true,\n // Defaults to honoring the header, so `?? true` rather than the `=== true` the other two\n // flags use.\n respectRetryAfter: policy.respectRetryAfter ?? true\n });\n}\n\n/**\n * Decides whether a failure is one a later attempt could plausibly answer differently.\n *\n * @remarks\n * The `switch` is exhaustive over the taxonomy on purpose: a new failure kind should have to be\n * classified deliberately rather than inherit \"retryable\" or \"not\" from a default branch.\n *\n * `'blocked-by-guard'` can never succeed on a retry — the guard's answer is about the\n * destination, not about the moment. `'too-large'` is the sharper case: retrying it\n * re-downloads up to the cap every time, which turns a defense into an amplifier aimed at the\n * caller's own bandwidth.\n * @internal\n */\nexport function isRetryableFailure(reason: FetchFailureReason | undefined): boolean {\n // A failure with no detail is one this primitive could not classify, and an unclassifiable\n // failure is not one to retry — the whole basis for retrying is knowing what went wrong.\n if (reason === undefined) {\n return false;\n }\n switch (reason.kind) {\n case 'network':\n case 'timeout':\n return true;\n case 'http-status':\n return RETRYABLE_HTTP_STATUSES.includes(reason.status);\n case 'invalid-url':\n case 'blocked-by-guard':\n case 'redirect-rejected':\n case 'redirect-opaque':\n case 'too-many-redirects':\n case 'aborted':\n case 'too-large':\n case 'unsupported-content-type':\n case 'decode':\n case 'parse':\n case 'unknown':\n return false;\n }\n}\n\n/**\n * Whether this method may be retried under the supplied policy.\n * @internal\n */\nexport function isRetryableMethod(method: SaferFetchMethod, policy: IResolvedRetryPolicy): boolean {\n return policy.retryNonIdempotent || IDEMPOTENT_METHODS.includes(method);\n}\n\n/**\n * Whether a `Retry-After` header on this failure is one to honor.\n *\n * @remarks\n * Only on `429` and `503`, and only when the policy asks for it. A `500` carrying a\n * `Retry-After` does not get to dictate the caller's schedule.\n * @internal\n */\nexport function honorsRetryAfter(\n reason: FetchFailureReason | undefined,\n policy: IResolvedRetryPolicy\n): boolean {\n return (\n policy.respectRetryAfter && reason?.kind === 'http-status' && RETRY_AFTER_STATUSES.includes(reason.status)\n );\n}\n\n/**\n * Parses a `Retry-After` header value into milliseconds.\n *\n * @remarks\n * Both RFC 9110 forms: delta-seconds, and an HTTP-date which is converted to a delay relative\n * to `nowMs`. An unparseable value, a negative delta, or a date already in the past yields\n * `undefined` — \"the server said nothing usable\", which falls back to ordinary backoff rather\n * than to zero delay. Returning `0` for a past date would let a stale clock produce a hot loop.\n *\n * The value is **not** clamped here. Clamping belongs with the rest of the delay arithmetic in\n * {@link computeRetryDelayMs}, so there is exactly one place the ceiling is applied.\n * @internal\n */\nexport function parseRetryAfterMs(value: string | undefined, nowMs: number): number | undefined {\n const text = (value ?? '').trim();\n if (text.length === 0) {\n return undefined;\n }\n // Digits only: a delta-seconds value. Tested before `Date.parse`, which accepts bare numbers\n // as years in some engines and would read `Retry-After: 120` as the year 120.\n if (/^\\d+$/.test(text)) {\n return Number(text) * 1000;\n }\n const at = Date.parse(text);\n if (Number.isNaN(at)) {\n return undefined;\n }\n const delay = at - nowMs;\n return delay > 0 ? delay : undefined;\n}\n\n/** Inputs to {@link computeRetryDelayMs}. @internal */\nexport interface IRetryDelayParams {\n /** 0-based index of the retry about to be scheduled: `0` is the first retry. */\n readonly attempt: number;\n readonly policy: IResolvedRetryPolicy;\n /** A honored, parsed `Retry-After`, in milliseconds, when the server sent a usable one. */\n readonly retryAfterMs?: number;\n /** Injected for determinism in tests; the call path passes `Math.random`. */\n readonly random: () => number;\n}\n\n/**\n * Computes how long to wait before the next attempt.\n *\n * @remarks\n * **Exponential backoff with full jitter**: the delay is uniform on `[0, cap)` where `cap` is\n * `baseDelayMs * 2^attempt` bounded by `maxDelayMs`. Full jitter rather than a fixed backoff\n * because every caller that failed against the same struggling host would otherwise retry in\n * the same instant, which is the thundering herd the backoff was supposed to prevent.\n *\n * **A honored `Retry-After` replaces the computed backoff and is clamped to `maxDelayMs`.** The\n * clamp is not a rounding detail: `Retry-After` is chosen by the server, which in this threat\n * model may be the adversary, so an unclamped `Retry-After: 86400` is a day-long sleep the\n * caller never agreed to. Jitter is not applied to it — the server named an instant, and\n * scattering around it is the caller's herd problem, not the server's instruction.\n *\n * `2 ** attempt` overflows to `Infinity` for a large enough `attempt`; `Math.min` with a finite\n * ceiling absorbs that, which is why the cap is computed in this order.\n * @internal\n */\nexport function computeRetryDelayMs(params: IRetryDelayParams): number {\n const { attempt, policy, random } = params;\n const retryAfterMs = params.retryAfterMs ?? undefined;\n if (retryAfterMs !== undefined) {\n return Math.min(retryAfterMs, policy.maxDelayMs);\n }\n const cap = Math.min(policy.baseDelayMs * 2 ** attempt, policy.maxDelayMs);\n return random() * cap;\n}\n"]}
|