@fgv/ts-extras 5.1.0-45 → 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/ai-assist/index.js +1 -1
- package/dist/packlets/ai-assist/index.js.map +1 -1
- package/dist/packlets/ai-assist/jsonResponse.js +270 -7
- package/dist/packlets/ai-assist/jsonResponse.js.map +1 -1
- package/dist/packlets/ai-assist/model.js +14 -0
- package/dist/packlets/ai-assist/model.js.map +1 -1
- package/dist/packlets/ai-assist/registry.js +57 -14
- package/dist/packlets/ai-assist/registry.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/packlets/zip-file-tree/zipFileTreeAccessors.js +65 -7
- package/dist/packlets/zip-file-tree/zipFileTreeAccessors.js.map +1 -1
- package/dist/packlets/zip-file-tree/zipFileTreeWriter.js +16 -1
- package/dist/packlets/zip-file-tree/zipFileTreeWriter.js.map +1 -1
- package/dist/ts-extras.d.ts +1590 -13
- 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/ai-assist/index.d.ts +1 -1
- package/lib/packlets/ai-assist/index.d.ts.map +1 -1
- package/lib/packlets/ai-assist/index.js +3 -2
- package/lib/packlets/ai-assist/index.js.map +1 -1
- package/lib/packlets/ai-assist/jsonResponse.d.ts +96 -0
- package/lib/packlets/ai-assist/jsonResponse.d.ts.map +1 -1
- package/lib/packlets/ai-assist/jsonResponse.js +271 -7
- package/lib/packlets/ai-assist/jsonResponse.js.map +1 -1
- package/lib/packlets/ai-assist/model.d.ts +43 -0
- package/lib/packlets/ai-assist/model.d.ts.map +1 -1
- package/lib/packlets/ai-assist/model.js +14 -0
- package/lib/packlets/ai-assist/model.js.map +1 -1
- package/lib/packlets/ai-assist/registry.d.ts +26 -6
- package/lib/packlets/ai-assist/registry.d.ts.map +1 -1
- package/lib/packlets/ai-assist/registry.js +57 -14
- package/lib/packlets/ai-assist/registry.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/lib/packlets/zip-file-tree/zipFileTreeAccessors.d.ts +54 -6
- package/lib/packlets/zip-file-tree/zipFileTreeAccessors.d.ts.map +1 -1
- package/lib/packlets/zip-file-tree/zipFileTreeAccessors.js +65 -7
- package/lib/packlets/zip-file-tree/zipFileTreeAccessors.js.map +1 -1
- package/lib/packlets/zip-file-tree/zipFileTreeWriter.d.ts +26 -1
- package/lib/packlets/zip-file-tree/zipFileTreeWriter.d.ts.map +1 -1
- package/lib/packlets/zip-file-tree/zipFileTreeWriter.js +17 -1
- package/lib/packlets/zip-file-tree/zipFileTreeWriter.js.map +1 -1
- package/package.json +17 -7
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type Result } from '@fgv/ts-utils';
|
|
2
|
+
import { type IBlockPrivateNetworksOptions } from './addressPolicy';
|
|
3
|
+
import type { IAddressGuard } from './model';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves a hostname to every address it names.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Injectable so that the resolving half of an address guard is testable without a network — a
|
|
9
|
+
* guard test that performs a real lookup fails in CI for reasons unrelated to the guard, and a
|
|
10
|
+
* guard test that could reach `169.254.169.254` is worse than flaky.
|
|
11
|
+
*
|
|
12
|
+
* An implementation must return a `Failure` rather than reject: `node:dns` rejects on
|
|
13
|
+
* `ENOTFOUND`, `EAI_AGAIN` and friends, and an entry point documented to always return a
|
|
14
|
+
* `Result` must not let that escape as a throw. {@link SaferFetch.nodeHostResolver} converts.
|
|
15
|
+
*
|
|
16
|
+
* @param hostname - The host to resolve, with no surrounding brackets.
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
export type HostResolver = (hostname: string) => Promise<Result<ReadonlyArray<string>>>;
|
|
20
|
+
/**
|
|
21
|
+
* The default {@link SaferFetch.HostResolver}: `node:dns`'s `lookup`, returning every address.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* `lookup` rather than `resolve4`/`resolve6` deliberately. `lookup` goes through the operating
|
|
25
|
+
* system's resolver — the same path `fetch`'s connect takes — so it sees `/etc/hosts`, `nsswitch`
|
|
26
|
+
* ordering, and any local override. `resolve4` queries DNS directly and would miss a
|
|
27
|
+
* `hosts`-file entry pointing an allowlisted name at `127.0.0.1`, which is a bypass rather than
|
|
28
|
+
* a curiosity.
|
|
29
|
+
*
|
|
30
|
+
* `all: true` because the list contract is reject-if-any: a name resolving to one public and one
|
|
31
|
+
* private address must be refused, and asking for one address would hide the second.
|
|
32
|
+
*
|
|
33
|
+
* A rejected lookup (`ENOTFOUND`, `EAI_AGAIN`, …) becomes a `Failure`, never a throw.
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
export declare const nodeHostResolver: HostResolver;
|
|
37
|
+
/**
|
|
38
|
+
* Options for {@link SaferFetch.blockPrivateNetworks}.
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
export interface IBlockPrivateNetworksGuardOptions extends IBlockPrivateNetworksOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Restricts every hop to these hostnames. Absent means any hostname.
|
|
44
|
+
*
|
|
45
|
+
* @remarks
|
|
46
|
+
* Matching is case-insensitive and exact — no wildcards and no suffix matching, because
|
|
47
|
+
* `endsWith('.example.com')` is the classic host-allowlist bypass (`evil-example.com`,
|
|
48
|
+
* `example.com.attacker.net`) and a primitive that offers the convenient form invites it.
|
|
49
|
+
* List the hosts.
|
|
50
|
+
*
|
|
51
|
+
* **A host allowlist is the recommended posture**, and it is stronger than address
|
|
52
|
+
* classification alone: with one, DNS rebinding can only be mounted by an allowlisted host's
|
|
53
|
+
* own resolver, which is a far smaller surface than "any hostname the caller was handed".
|
|
54
|
+
*/
|
|
55
|
+
readonly allowHosts?: ReadonlyArray<string>;
|
|
56
|
+
/**
|
|
57
|
+
* Restricts every hop to these ports. Absent means any port.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* A URL with no explicit port is checked against the scheme's default — `443` for `https:`,
|
|
61
|
+
* `80` for `http:` — so `allowPorts: [443]` accepts `https://example.com/`.
|
|
62
|
+
*/
|
|
63
|
+
readonly allowPorts?: ReadonlyArray<number>;
|
|
64
|
+
/**
|
|
65
|
+
* Permits `http:` hops. **Off by default**, so this guard requires `https:`.
|
|
66
|
+
*
|
|
67
|
+
* @remarks
|
|
68
|
+
* The core refuses everything that is not `http:` or `https:` and deliberately chooses
|
|
69
|
+
* between those two here rather than there, because the choice is a posture rather than a
|
|
70
|
+
* structural rule. Plaintext HTTP is exposed to a network-position attacker and is the scheme
|
|
71
|
+
* every SSRF payload reaches for, so the polarity matches `allowLoopback`: deny by default,
|
|
72
|
+
* opt in visibly, and let a reviewer grep for the opt-in.
|
|
73
|
+
*/
|
|
74
|
+
readonly allowInsecureHttp?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Name resolution. Defaults to {@link SaferFetch.nodeHostResolver}.
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* Present so the guard is unit-testable with no network. It is **not** a hook through which
|
|
80
|
+
* DNS rebinding can be closed: swapping the resolver changes only the address the guard
|
|
81
|
+
* validates, while the transport still connects by hostname and resolves again. Closing that
|
|
82
|
+
* requires a pinning transport — see `IGuardVerdict.pinnedAddress`.
|
|
83
|
+
*/
|
|
84
|
+
readonly resolve?: HostResolver;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Creates the recommended address guard: resolves each hop's host and requires every resolved
|
|
88
|
+
* address to be globally routable public unicast.
|
|
89
|
+
*
|
|
90
|
+
* @remarks
|
|
91
|
+
* **Node only** — it resolves names, and no browser API returns a hostname's A/AAAA records.
|
|
92
|
+
* `allowAnyAddress()` is the honest choice there, and its name says so.
|
|
93
|
+
*
|
|
94
|
+
* This is the guard an entry point's `addressGuard` option takes. It is the resolving,
|
|
95
|
+
* hop-chain-aware half; the judgement itself belongs to
|
|
96
|
+
* {@link SaferFetch.blockPrivateNetworksPolicy}, which this guard delegates to unchanged. Keeping
|
|
97
|
+
* the adversarial classification matrix in one pure, synchronous implementation is what makes
|
|
98
|
+
* "did the address check run, and run correctly?" answerable by reading one file.
|
|
99
|
+
*
|
|
100
|
+
* The guard is invoked **once per redirect hop**, on the last entry of the chain. Hop 0 is not a
|
|
101
|
+
* special case: a guard that only validated the initial URL is defeated by a single `302` to
|
|
102
|
+
* `http://169.254.169.254/`.
|
|
103
|
+
*
|
|
104
|
+
* The URL-level constraints — `https:` unless `allowInsecureHttp`, plus the optional `allowHosts`
|
|
105
|
+
* and `allowPorts` allowlists — are checked **before** the name resolution, so a host the caller
|
|
106
|
+
* never allowlisted is refused by string comparison rather than handed to a resolver.
|
|
107
|
+
*
|
|
108
|
+
* ```typescript
|
|
109
|
+
* // A local Ollama sidecar: every deviation from the default posture is named and greppable.
|
|
110
|
+
* const guard = blockPrivateNetworks({
|
|
111
|
+
* allowLoopback: true,
|
|
112
|
+
* allowInsecureHttp: true,
|
|
113
|
+
* allowHosts: ['localhost'],
|
|
114
|
+
* allowPorts: [11434]
|
|
115
|
+
* });
|
|
116
|
+
* ```
|
|
117
|
+
*
|
|
118
|
+
* **What this does not protect against.** It validates a resolved address and the transport then
|
|
119
|
+
* re-resolves, so hostile DNS can answer the two lookups differently — the documented
|
|
120
|
+
* DNS-rebinding limit, which is open in this release. A strict `allowHosts` list is the
|
|
121
|
+
* recommended posture precisely because it shrinks that exposure to "an allowlisted host's own
|
|
122
|
+
* resolver is hostile".
|
|
123
|
+
*
|
|
124
|
+
* @param options - optional relaxations of the default posture, plus the resolver seam.
|
|
125
|
+
* @returns the guard. Construction cannot fail.
|
|
126
|
+
* @public
|
|
127
|
+
*/
|
|
128
|
+
export declare function blockPrivateNetworks(options?: IBlockPrivateNetworksGuardOptions): IAddressGuard;
|
|
129
|
+
//# sourceMappingURL=nodeAddressGuard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nodeAddressGuard.d.ts","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/nodeAddressGuard.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAqC,KAAK,MAAM,EAAE,MAAM,eAAe,CAAC;AAI/E,OAAO,EAGL,KAAK,4BAA4B,EAClC,MAAM,iBAAiB,CAAC;AACzB,OAAO,KAAK,EAAE,aAAa,EAA8B,MAAM,SAAS,CAAC;AAEzE;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AAExF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,gBAAgB,EAAE,YAM9B,CAAC;AAEF;;;GAGG;AACH,MAAM,WAAW,iCAAkC,SAAQ,4BAA4B;IACrF;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAE5C;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAE5C;;;;;;;;;OASG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAErC;;;;;;;;OAQG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,YAAY,CAAC;CACjC;AA8DD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,CAAC,EAAE,iCAAiC,GAAG,aAAa,CAgD/F"}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) 2026 Erik Fortune
|
|
3
|
+
//
|
|
4
|
+
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
// of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
// in the Software without restriction, including without limitation the rights
|
|
7
|
+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
// copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
// furnished to do so, subject to the following conditions:
|
|
10
|
+
//
|
|
11
|
+
// The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
// copies or substantial portions of the Software.
|
|
13
|
+
//
|
|
14
|
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
// SOFTWARE.
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.nodeHostResolver = void 0;
|
|
23
|
+
exports.blockPrivateNetworks = blockPrivateNetworks;
|
|
24
|
+
// This module is **Node-only**: it imports `node:dns/promises` for its default resolver, so it
|
|
25
|
+
// is deliberately absent from the packlet's browser barrel. It is also the only module in the
|
|
26
|
+
// packlet that performs I/O.
|
|
27
|
+
const ts_utils_1 = require("@fgv/ts-utils");
|
|
28
|
+
const promises_1 = require("node:dns/promises");
|
|
29
|
+
const addressClassification_1 = require("./addressClassification");
|
|
30
|
+
const addressPolicy_1 = require("./addressPolicy");
|
|
31
|
+
/**
|
|
32
|
+
* The default {@link SaferFetch.HostResolver}: `node:dns`'s `lookup`, returning every address.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* `lookup` rather than `resolve4`/`resolve6` deliberately. `lookup` goes through the operating
|
|
36
|
+
* system's resolver — the same path `fetch`'s connect takes — so it sees `/etc/hosts`, `nsswitch`
|
|
37
|
+
* ordering, and any local override. `resolve4` queries DNS directly and would miss a
|
|
38
|
+
* `hosts`-file entry pointing an allowlisted name at `127.0.0.1`, which is a bypass rather than
|
|
39
|
+
* a curiosity.
|
|
40
|
+
*
|
|
41
|
+
* `all: true` because the list contract is reject-if-any: a name resolving to one public and one
|
|
42
|
+
* private address must be refused, and asking for one address would hide the second.
|
|
43
|
+
*
|
|
44
|
+
* A rejected lookup (`ENOTFOUND`, `EAI_AGAIN`, …) becomes a `Failure`, never a throw.
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
const nodeHostResolver = async (hostname) => {
|
|
48
|
+
return (0, ts_utils_1.captureAsyncResult)(async () => (0, promises_1.lookup)(hostname, { all: true, verbatim: true }))
|
|
49
|
+
.withErrorFormat((message) => `failed to resolve "${hostname}": ${message}`)
|
|
50
|
+
.onSuccess((entries) => (0, ts_utils_1.succeed)(entries.map((entry) => entry.address)));
|
|
51
|
+
};
|
|
52
|
+
exports.nodeHostResolver = nodeHostResolver;
|
|
53
|
+
/**
|
|
54
|
+
* Resolves a hop's hostname if it is not already an IP literal, and hands every resolved address
|
|
55
|
+
* to the policy.
|
|
56
|
+
*
|
|
57
|
+
* A `classifyAddress` failure means "this is not an IP literal, so resolve it" — it never means
|
|
58
|
+
* "block". Treating an unparseable literal as hostile would reject every ordinary hostname.
|
|
59
|
+
*/
|
|
60
|
+
async function _addressesFor(hostname, resolve) {
|
|
61
|
+
// Classify `url.hostname`, never raw URL text. By the time the WHATWG parser has produced a
|
|
62
|
+
// hostname, `127.0x.1` is already `127.0.0.1`, `⑫7.0.0.1` is already `127.0.0.1`, and
|
|
63
|
+
// `::ffff:169.254.169.254` is already `::ffff:a9fe:a9fe`. Text matching misses all three.
|
|
64
|
+
const literal = (0, addressClassification_1.classifyAddress)(hostname);
|
|
65
|
+
return literal.isSuccess() ? (0, ts_utils_1.succeed)([hostname]) : resolve(hostname);
|
|
66
|
+
}
|
|
67
|
+
const DEFAULT_PORTS = { 'https:': 443, 'http:': 80 };
|
|
68
|
+
/**
|
|
69
|
+
* Checks the constraints that are decidable from the URL alone, before any name resolution.
|
|
70
|
+
*
|
|
71
|
+
* @remarks
|
|
72
|
+
* Ordered before the DNS lookup deliberately: a host that is not on the allowlist should cost a
|
|
73
|
+
* string comparison rather than a resolution, and a guard that resolves names it has already
|
|
74
|
+
* decided to refuse hands an off-allowlist hostname to the resolver for no benefit.
|
|
75
|
+
*/
|
|
76
|
+
function _checkUrl(name, url, options) {
|
|
77
|
+
var _a, _b;
|
|
78
|
+
if (url.protocol === 'http:') {
|
|
79
|
+
if (options.allowInsecureHttp !== true) {
|
|
80
|
+
return (0, ts_utils_1.fail)(`${name}: ${url.protocol} is not allowed without allowInsecureHttp`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (url.protocol !== 'https:') {
|
|
84
|
+
// `allowInsecureHttp` opts into **`http:`**, not into "any scheme that is not https". The
|
|
85
|
+
// core already refuses everything that is not `http:`/`https:` before a guard ever sees a
|
|
86
|
+
// URL, so this is unreachable through the shipped entry points — but a guard is a public
|
|
87
|
+
// export a caller may hold and invoke directly, and a security check that is only correct
|
|
88
|
+
// because something upstream happens to be correct is one refactor away from being wrong.
|
|
89
|
+
return (0, ts_utils_1.fail)(`${name}: ${url.protocol} is not allowed (only https:, or http: with allowInsecureHttp)`);
|
|
90
|
+
}
|
|
91
|
+
const allowHosts = (_a = options.allowHosts) !== null && _a !== void 0 ? _a : undefined;
|
|
92
|
+
if (allowHosts !== undefined && !allowHosts.some((h) => h.toLowerCase() === url.hostname)) {
|
|
93
|
+
return (0, ts_utils_1.fail)(`${name}: host "${url.hostname}" is not in the allowed host list`);
|
|
94
|
+
}
|
|
95
|
+
const allowPorts = (_b = options.allowPorts) !== null && _b !== void 0 ? _b : undefined;
|
|
96
|
+
if (allowPorts !== undefined) {
|
|
97
|
+
// An empty `port` means the scheme's default, which is the spelling almost every real URL
|
|
98
|
+
// uses — checking the raw string would reject `https://example.com/` under `[443]`.
|
|
99
|
+
const port = url.port === '' ? DEFAULT_PORTS[url.protocol] : Number(url.port);
|
|
100
|
+
if (!allowPorts.includes(port)) {
|
|
101
|
+
return (0, ts_utils_1.fail)(`${name}: port ${port} is not in the allowed port list`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return (0, ts_utils_1.succeed)(true);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Creates the recommended address guard: resolves each hop's host and requires every resolved
|
|
108
|
+
* address to be globally routable public unicast.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* **Node only** — it resolves names, and no browser API returns a hostname's A/AAAA records.
|
|
112
|
+
* `allowAnyAddress()` is the honest choice there, and its name says so.
|
|
113
|
+
*
|
|
114
|
+
* This is the guard an entry point's `addressGuard` option takes. It is the resolving,
|
|
115
|
+
* hop-chain-aware half; the judgement itself belongs to
|
|
116
|
+
* {@link SaferFetch.blockPrivateNetworksPolicy}, which this guard delegates to unchanged. Keeping
|
|
117
|
+
* the adversarial classification matrix in one pure, synchronous implementation is what makes
|
|
118
|
+
* "did the address check run, and run correctly?" answerable by reading one file.
|
|
119
|
+
*
|
|
120
|
+
* The guard is invoked **once per redirect hop**, on the last entry of the chain. Hop 0 is not a
|
|
121
|
+
* special case: a guard that only validated the initial URL is defeated by a single `302` to
|
|
122
|
+
* `http://169.254.169.254/`.
|
|
123
|
+
*
|
|
124
|
+
* The URL-level constraints — `https:` unless `allowInsecureHttp`, plus the optional `allowHosts`
|
|
125
|
+
* and `allowPorts` allowlists — are checked **before** the name resolution, so a host the caller
|
|
126
|
+
* never allowlisted is refused by string comparison rather than handed to a resolver.
|
|
127
|
+
*
|
|
128
|
+
* ```typescript
|
|
129
|
+
* // A local Ollama sidecar: every deviation from the default posture is named and greppable.
|
|
130
|
+
* const guard = blockPrivateNetworks({
|
|
131
|
+
* allowLoopback: true,
|
|
132
|
+
* allowInsecureHttp: true,
|
|
133
|
+
* allowHosts: ['localhost'],
|
|
134
|
+
* allowPorts: [11434]
|
|
135
|
+
* });
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* **What this does not protect against.** It validates a resolved address and the transport then
|
|
139
|
+
* re-resolves, so hostile DNS can answer the two lookups differently — the documented
|
|
140
|
+
* DNS-rebinding limit, which is open in this release. A strict `allowHosts` list is the
|
|
141
|
+
* recommended posture precisely because it shrinks that exposure to "an allowlisted host's own
|
|
142
|
+
* resolver is hostile".
|
|
143
|
+
*
|
|
144
|
+
* @param options - optional relaxations of the default posture, plus the resolver seam.
|
|
145
|
+
* @returns the guard. Construction cannot fail.
|
|
146
|
+
* @public
|
|
147
|
+
*/
|
|
148
|
+
function blockPrivateNetworks(options) {
|
|
149
|
+
var _a;
|
|
150
|
+
const settings = options !== null && options !== void 0 ? options : {};
|
|
151
|
+
const allowLoopback = settings.allowLoopback === true;
|
|
152
|
+
const resolve = (_a = settings.resolve) !== null && _a !== void 0 ? _a : exports.nodeHostResolver;
|
|
153
|
+
const policy = (0, addressPolicy_1.blockPrivateNetworksPolicy)({ allowLoopback });
|
|
154
|
+
// Every relaxation is named in the guard's own name, so a `'blocked-by-guard'` failure — and
|
|
155
|
+
// any log line carrying it — says which posture was actually in force. Two call sites with
|
|
156
|
+
// different relaxations are two distinguishable names, not one ambiguous one.
|
|
157
|
+
const relaxations = [
|
|
158
|
+
...(allowLoopback ? ['allowLoopback'] : []),
|
|
159
|
+
...(settings.allowInsecureHttp === true ? ['allowInsecureHttp'] : []),
|
|
160
|
+
...(settings.allowHosts !== undefined ? [`allowHosts=${settings.allowHosts.join('|')}`] : []),
|
|
161
|
+
...(settings.allowPorts !== undefined ? [`allowPorts=${settings.allowPorts.join('|')}`] : [])
|
|
162
|
+
];
|
|
163
|
+
const name = relaxations.length > 0 ? `blockPrivateNetworks(${relaxations.join(', ')})` : 'blockPrivateNetworks';
|
|
164
|
+
return {
|
|
165
|
+
name,
|
|
166
|
+
check: async (chain) => {
|
|
167
|
+
var _a;
|
|
168
|
+
const hop = (_a = chain[chain.length - 1]) !== null && _a !== void 0 ? _a : undefined;
|
|
169
|
+
if (hop === undefined) {
|
|
170
|
+
return (0, ts_utils_1.fail)(`${name}: hop chain is empty.`);
|
|
171
|
+
}
|
|
172
|
+
// The format applies to the resolution failure only, and deliberately does not wrap the
|
|
173
|
+
// policy's. The two layers are named separately on purpose: this guard is the resolving,
|
|
174
|
+
// chain-aware half and reports as `blockPrivateNetworks(...)`, while the pure classifier
|
|
175
|
+
// beneath it reports as `blockPrivateNetworksPolicy(...)`. A reader of either message can
|
|
176
|
+
// therefore tell which layer said no — whether DNS failed or an address was classified and
|
|
177
|
+
// refused — which is the distinction the guard/policy split exists to make legible.
|
|
178
|
+
// Flattening both to the guard name would read as more consistent and carry strictly less
|
|
179
|
+
// information. `FetchFailureReason.blocked-by-guard.guard` names the guard either way.
|
|
180
|
+
const url = _checkUrl(name, hop.url, settings);
|
|
181
|
+
if (url.isFailure()) {
|
|
182
|
+
return (0, ts_utils_1.fail)(url.message);
|
|
183
|
+
}
|
|
184
|
+
return (await _addressesFor(hop.url.hostname, resolve))
|
|
185
|
+
.withErrorFormat((message) => `${name}: ${message}`)
|
|
186
|
+
.onSuccess((addresses) => policy.checkAddresses(addresses))
|
|
187
|
+
.onSuccess(() =>
|
|
188
|
+
// `pinnedAddress` is deliberately left undefined. The guard validated a resolved
|
|
189
|
+
// address, but `platformFetchTransport` connects by hostname and would have to fail
|
|
190
|
+
// rather than honor a pin — so claiming one here would either break every call or,
|
|
191
|
+
// worse, advertise a rebinding defense that is not in place.
|
|
192
|
+
(0, ts_utils_1.succeed)({ url: hop.url }));
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//# 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,62 @@
|
|
|
1
|
+
import { type Result } from '@fgv/ts-utils';
|
|
2
|
+
import type { SaferFetchMethod } from './model';
|
|
3
|
+
/**
|
|
4
|
+
* Builds the set of header names to drop on a cross-origin hop.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Lowercased on the way in so the comparison is case-insensitive: HTTP header names are, and a
|
|
8
|
+
* request guard returning a replacement request spelled `Authorization` must not slip a
|
|
9
|
+
* credential past a set keyed on `authorization`.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export declare function sensitiveHeaderSet(extra?: ReadonlyArray<string>): ReadonlySet<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Resolves a `Location` header against the URL of the hop that sent it.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Resolved against the **current** hop, not the caller's original URL: a relative `Location` on
|
|
18
|
+
* the third hop of a chain is relative to the third hop.
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveLocation(location: string, base: URL): Result<URL>;
|
|
22
|
+
/** The request to issue on the next hop. @internal */
|
|
23
|
+
export interface IRedirectRewrite {
|
|
24
|
+
readonly method: SaferFetchMethod;
|
|
25
|
+
/** Header names are lowercased; credential headers are absent on a cross-origin hop. */
|
|
26
|
+
readonly headers: Record<string, string>;
|
|
27
|
+
readonly body: string | Uint8Array | undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Inputs to {@link rewriteForRedirect}. @internal */
|
|
30
|
+
export interface IRedirectRewriteParams {
|
|
31
|
+
/** The URL that was requested and answered with the redirect. */
|
|
32
|
+
readonly from: URL;
|
|
33
|
+
/** The already-resolved redirect target. */
|
|
34
|
+
readonly to: URL;
|
|
35
|
+
readonly status: number;
|
|
36
|
+
readonly method: SaferFetchMethod;
|
|
37
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
38
|
+
readonly body: string | Uint8Array | undefined;
|
|
39
|
+
readonly sensitiveHeaders: ReadonlySet<string>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Derives the next hop's method, headers and body from the hop that redirected.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* **Method and body** follow the platform, because a primitive that redirects differently from
|
|
46
|
+
* `fetch` surprises callers in a way no documentation repairs. Per the Fetch standard: `301` and
|
|
47
|
+
* `302` convert a `POST` to a `GET` and drop the body, and leave every other method alone; `303`
|
|
48
|
+
* converts anything that is not already `GET`/`HEAD`; `307` and `308` preserve both. `status` is
|
|
49
|
+
* on `IRequestHop` for exactly this reason. A dropped body takes `Content-Type` and
|
|
50
|
+
* `Content-Length` with it — describing a body that is no longer there is not a rounding error
|
|
51
|
+
* on a request a guard is about to inspect.
|
|
52
|
+
*
|
|
53
|
+
* **Credential stripping is monotonic.** The comparison is against the hop that redirected, and
|
|
54
|
+
* the headers carried in are the *previous hop's* headers, not the caller's original set. That
|
|
55
|
+
* distinction is the whole game: `A`(authenticated) → `B` → `A` must not re-attach on the final
|
|
56
|
+
* hop. Re-deriving each hop's headers from the caller's original request and comparing to the
|
|
57
|
+
* caller's original origin finds the last hop same-origin with `A` and hands the token back —
|
|
58
|
+
* after `B` has already observed the chain. It reviews as correct and it is a real leak.
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
export declare function rewriteForRedirect(params: IRedirectRewriteParams): IRedirectRewrite;
|
|
62
|
+
//# sourceMappingURL=redirect.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/redirect.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,eAAe,CAAC;AAG3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAKhD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,WAAW,CAAC,MAAM,CAAC,CAErF;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,CAIxE;AAED,sDAAsD;AACtD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,wFAAwF;IACxF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;CAChD;AAED,sDAAsD;AACtD,MAAM,WAAW,sBAAsB;IACrC,iEAAiE;IACjE,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC;IACnB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,EAAE,GAAG,CAAC;IACjB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,gBAAgB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CAChD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,gBAAgB,CAkBnF"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) 2026 Erik Fortune
|
|
3
|
+
//
|
|
4
|
+
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
// of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
// in the Software without restriction, including without limitation the rights
|
|
7
|
+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
// copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
// furnished to do so, subject to the following conditions:
|
|
10
|
+
//
|
|
11
|
+
// The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
// copies or substantial portions of the Software.
|
|
13
|
+
//
|
|
14
|
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
// SOFTWARE.
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.sensitiveHeaderSet = sensitiveHeaderSet;
|
|
23
|
+
exports.resolveLocation = resolveLocation;
|
|
24
|
+
exports.rewriteForRedirect = rewriteForRedirect;
|
|
25
|
+
const ts_utils_1 = require("@fgv/ts-utils");
|
|
26
|
+
const defaults_1 = require("./defaults");
|
|
27
|
+
/** Headers describing a body that is about to be dropped, so they must go with it. */
|
|
28
|
+
const BODY_DESCRIBING_HEADERS = ['content-type', 'content-length'];
|
|
29
|
+
/**
|
|
30
|
+
* Builds the set of header names to drop on a cross-origin hop.
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* Lowercased on the way in so the comparison is case-insensitive: HTTP header names are, and a
|
|
34
|
+
* request guard returning a replacement request spelled `Authorization` must not slip a
|
|
35
|
+
* credential past a set keyed on `authorization`.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
function sensitiveHeaderSet(extra) {
|
|
39
|
+
return new Set([...defaults_1.ALWAYS_STRIPPED_HEADERS, ...(extra !== null && extra !== void 0 ? extra : [])].map((n) => n.toLowerCase()));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolves a `Location` header against the URL of the hop that sent it.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* Resolved against the **current** hop, not the caller's original URL: a relative `Location` on
|
|
46
|
+
* the third hop of a chain is relative to the third hop.
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
function resolveLocation(location, base) {
|
|
50
|
+
return (0, ts_utils_1.captureResult)(() => new URL(location, base)).withErrorFormat((message) => `cannot resolve redirect target "${location}": ${message}`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Derives the next hop's method, headers and body from the hop that redirected.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* **Method and body** follow the platform, because a primitive that redirects differently from
|
|
57
|
+
* `fetch` surprises callers in a way no documentation repairs. Per the Fetch standard: `301` and
|
|
58
|
+
* `302` convert a `POST` to a `GET` and drop the body, and leave every other method alone; `303`
|
|
59
|
+
* converts anything that is not already `GET`/`HEAD`; `307` and `308` preserve both. `status` is
|
|
60
|
+
* on `IRequestHop` for exactly this reason. A dropped body takes `Content-Type` and
|
|
61
|
+
* `Content-Length` with it — describing a body that is no longer there is not a rounding error
|
|
62
|
+
* on a request a guard is about to inspect.
|
|
63
|
+
*
|
|
64
|
+
* **Credential stripping is monotonic.** The comparison is against the hop that redirected, and
|
|
65
|
+
* the headers carried in are the *previous hop's* headers, not the caller's original set. That
|
|
66
|
+
* distinction is the whole game: `A`(authenticated) → `B` → `A` must not re-attach on the final
|
|
67
|
+
* hop. Re-deriving each hop's headers from the caller's original request and comparing to the
|
|
68
|
+
* caller's original origin finds the last hop same-origin with `A` and hands the token back —
|
|
69
|
+
* after `B` has already observed the chain. It reviews as correct and it is a real leak.
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
function rewriteForRedirect(params) {
|
|
73
|
+
const crossOrigin = params.to.origin !== params.from.origin;
|
|
74
|
+
const method = _rewriteMethod(params.status, params.method);
|
|
75
|
+
const dropsBody = method !== params.method;
|
|
76
|
+
const drop = new Set([
|
|
77
|
+
...(crossOrigin ? params.sensitiveHeaders : []),
|
|
78
|
+
...(dropsBody ? BODY_DESCRIBING_HEADERS : [])
|
|
79
|
+
]);
|
|
80
|
+
const headers = {};
|
|
81
|
+
for (const [name, value] of Object.entries(params.headers)) {
|
|
82
|
+
const lowered = name.toLowerCase();
|
|
83
|
+
if (!drop.has(lowered)) {
|
|
84
|
+
headers[lowered] = value;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { method, headers, body: dropsBody ? undefined : params.body };
|
|
88
|
+
}
|
|
89
|
+
function _rewriteMethod(status, method) {
|
|
90
|
+
if ((status === 301 || status === 302) && method === 'POST') {
|
|
91
|
+
return 'GET';
|
|
92
|
+
}
|
|
93
|
+
if (status === 303 && method !== 'GET' && method !== 'HEAD') {
|
|
94
|
+
return 'GET';
|
|
95
|
+
}
|
|
96
|
+
return method;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=redirect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redirect.js","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/redirect.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;;AAmBZ,gDAEC;AAUD,0CAIC;AA2CD,gDAkBC;AA9FD,4CAA2D;AAE3D,yCAAqD;AAGrD,sFAAsF;AACtF,MAAM,uBAAuB,GAA0B,CAAC,cAAc,EAAE,gBAAgB,CAAC,CAAC;AAE1F;;;;;;;;GAQG;AACH,SAAgB,kBAAkB,CAAC,KAA6B;IAC9D,OAAO,IAAI,GAAG,CAAS,CAAC,GAAG,kCAAuB,EAAE,GAAG,CAAC,KAAK,aAAL,KAAK,cAAL,KAAK,GAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;AACrG,CAAC;AAED;;;;;;;GAOG;AACH,SAAgB,eAAe,CAAC,QAAgB,EAAE,IAAS;IACzD,OAAO,IAAA,wBAAa,EAAC,GAAG,EAAE,CAAC,IAAI,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,eAAe,CACjE,CAAC,OAAO,EAAE,EAAE,CAAC,mCAAmC,QAAQ,MAAM,OAAO,EAAE,CACxE,CAAC;AACJ,CAAC;AAuBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,SAAgB,kBAAkB,CAAC,MAA8B;IAC/D,MAAM,WAAW,GAAY,MAAM,CAAC,EAAE,CAAC,MAAM,KAAK,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;IACrE,MAAM,MAAM,GAAqB,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;IAC9E,MAAM,SAAS,GAAY,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC;IACpD,MAAM,IAAI,GAAwB,IAAI,GAAG,CAAS;QAChD,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/C,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,EAAE,CAAC;KAC9C,CAAC,CAAC;IAEH,MAAM,OAAO,GAA2B,EAAE,CAAC;IAC3C,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;QAC3D,MAAM,OAAO,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QACnC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YACvB,OAAO,CAAC,OAAO,CAAC,GAAG,KAAK,CAAC;QAC3B,CAAC;IACH,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;AACxE,CAAC;AAED,SAAS,cAAc,CAAC,MAAc,EAAE,MAAwB;IAC9D,IAAI,CAAC,MAAM,KAAK,GAAG,IAAI,MAAM,KAAK,GAAG,CAAC,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QAC5D,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,MAAM,KAAK,GAAG,IAAI,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QAC5D,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC","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"]}
|