diff --git a/AGENTS.md b/AGENTS.md index 8117788..4baa9c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,9 @@ secret. It does not store, resolve or authorize credentials; the host and header, host-supplied extra origins, and no header for an empty or keyless secret. - Extra origins are host-supplied and keyed by the pinned origin. No vendor - origin lives in source. + origin lives in source. A secret is sent only to `https`, or to `http` on + loopback (`localhost`, `127.0.0.1`, `::1`). Other schemes are refused before + the credential is attached. - `FetchLike` comes from `@intx/harness`; do not redeclare it. ## Local development diff --git a/README.md b/README.md index 4e38a0a..62fe269 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ For plain Bearer APIs, use the built-in `http` provider from `@intx/harness`. ## Install ```bash -bun add @corbits/credential-http @intx/harness@^0.4.0 @intx/types@^0.4.0 +bun add @corbits/credential-http @intx/harness@^0.5.0 @intx/types@^0.5.0 ``` -Runs on Bun >= 1.2 or Node >= 24. +Runs on Bun >= 1.2 or Node >= 24. `@intx/harness` and `@intx/types` `^0.4.0` are also accepted peer ranges. ## Quickstart @@ -90,7 +90,7 @@ The keys are also exported as `X_API_KEY_PROVIDER_KEY`, `RAW_AUTHORIZATION_PROVI | `extraOrigins` | `Record` | `{}` | | `fetch` | `FetchLike` from `@intx/harness` | global `fetch` | -`extraOrigins` is keyed by the pinned origin, so one credential's allowance never applies to another. `{ "https://mcp.example.com": ["https://auth.example.com"] }` lets only a credential pinned to `https://mcp.example.com` also call `https://auth.example.com`. +`extraOrigins` is keyed by the pinned origin, so one credential's allowance never applies to another. `{ "https://mcp.example.com": ["https://auth.example.com"] }` lets only a credential pinned to `https://mcp.example.com` also call `https://auth.example.com`. Every origin a secret can be sent to, including these, must be `https`. `http` is accepted only for loopback (`localhost`, `127.0.0.1`, and `::1`). Anything else is refused before the secret is attached. A redirect is still never followed. ### `createHeaderCredentialProvider(opts)` @@ -106,7 +106,7 @@ createHeaderCredentialProvider({ ### `createOriginPinnedFetch(opts)` -The pinned `fetch` every handle uses, for code that holds a secret outside the provider registry, such as a hub route. `opts` is `{ origin, header, readValue, extraOrigins?, fetch? }`. `readValue` runs per request; `undefined` or `""` sends no header. +The pinned `fetch` every handle uses, for code that holds a secret outside the provider registry, such as a hub route. `opts` is `{ origin, header, readValue, extraOrigins?, fetch? }`. `readValue` runs per request; `undefined` or `""` sends no header. `Request` and `RequestInit` follow fetch's override rules, except the credential header and `redirect: "manual"` always win. Passing a `Request` does not drop `init` (including its abort signal). The URL is copied to a string and authorized before headers are read or the credential is attached, and the delegate is called with that string rather than the original `URL` or `Request`. `blob:` and every protocol other than `https` (or `http` on loopback) is refused before the secret is read. ### `MCP_NO_TOKEN_SENTINEL` diff --git a/bun.lock b/bun.lock index c485c22..348d796 100644 --- a/bun.lock +++ b/bun.lock @@ -14,8 +14,8 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@intx/harness": "^0.5.0", - "@intx/types": "^0.5.0", + "@intx/harness": "^0.4.0 || ^0.5.0", + "@intx/types": "^0.4.0 || ^0.5.0", }, }, }, diff --git a/package.json b/package.json index 781daf4..569c483 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,8 @@ "typescript": "6.0.3" }, "peerDependencies": { - "@intx/harness": "^0.5.0", - "@intx/types": "^0.5.0" + "@intx/harness": "^0.4.0 || ^0.5.0", + "@intx/types": "^0.4.0 || ^0.5.0" }, "engines": { "bun": ">=1.2.0", diff --git a/src/credential-providers.ts b/src/credential-providers.ts index 01e9ffc..bbcce4d 100644 --- a/src/credential-providers.ts +++ b/src/credential-providers.ts @@ -12,6 +12,7 @@ import type { import { assertHeaderName, createOriginPinnedFetch, + credentialTransportOrigin, } from "./origin-pinned-fetch.js"; /** Plugin key of the `x-api-key` preset. */ @@ -113,8 +114,8 @@ function createPinnedProvider( const fetchImpl: FetchLike = opts.fetch ?? globalThis.fetch; const extraOrigins = new Map( Object.entries(opts.extraOrigins ?? {}).map(([origin, extras]) => [ - new URL(origin).origin, - extras, + credentialTransportOrigin(origin), + extras.map((extra) => credentialTransportOrigin(extra)), ]), ); diff --git a/src/origin-pinned-fetch.test.ts b/src/origin-pinned-fetch.test.ts new file mode 100644 index 0000000..2d8e175 --- /dev/null +++ b/src/origin-pinned-fetch.test.ts @@ -0,0 +1,449 @@ +import { expect, test } from "bun:test"; +import type { FetchLike } from "@intx/harness"; + +import { createXApiKeyCredentialProvider } from "./credential-providers.js"; +import { createOriginPinnedFetch } from "./origin-pinned-fetch.js"; + +const SECRET = "super-secret"; + +function pinned(opts?: { + readonly origin?: string; + readonly extraOrigins?: readonly string[]; + readonly fetch?: FetchLike; + readonly readValue?: () => string | undefined; +}) { + return createOriginPinnedFetch({ + origin: opts?.origin ?? "https://api.example.com", + header: "x-api-key", + readValue: opts?.readValue ?? (() => SECRET), + ...(opts?.extraOrigins !== undefined + ? { extraOrigins: opts.extraOrigins } + : {}), + ...(opts?.fetch !== undefined ? { fetch: opts.fetch } : {}), + }); +} + +test("RequestInit overrides a Request and still forces the credential and manual redirect", async () => { + let seen: { input: unknown; init: RequestInit | undefined } | undefined; + const fetchImpl: FetchLike = async (input, init) => { + seen = { input, init }; + return new Response("ok"); + }; + const req = new Request("https://api.example.com/v1", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "forged" }, + body: '{"a":1}', + redirect: "follow", + }); + const response = await pinned({ fetch: fetchImpl })(req, { + method: "PUT", + headers: { "x-trace": "1" }, + body: "replaced", + redirect: "follow", + }); + expect(response.status).toBe(200); + if (seen === undefined) throw new Error("delegate was not called"); + expect(seen.input).toBe("https://api.example.com/v1"); + expect(seen.init?.method).toBe("PUT"); + expect(seen.init?.body).toBe("replaced"); + expect(seen.init?.redirect).toBe("manual"); + const headers = new Headers(seen.init?.headers); + expect(headers.get("x-trace")).toBe("1"); + expect(headers.get("content-type")).toBeNull(); + expect(headers.get("x-api-key")).toBe(SECRET); +}); + +test("a Request without init keeps its method, body, and headers", async () => { + let seen: { input: unknown; init: RequestInit | undefined } | undefined; + const fetchImpl: FetchLike = async (input, init) => { + seen = { input, init }; + return new Response("ok"); + }; + await pinned({ fetch: fetchImpl })( + new Request("https://api.example.com/v1", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + ); + if (seen === undefined) throw new Error("delegate was not called"); + expect(seen.input).toBe("https://api.example.com/v1"); + expect(seen.init?.method).toBe("POST"); + expect(seen.init?.redirect).toBe("manual"); + const headers = new Headers(seen.init?.headers); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-api-key")).toBe(SECRET); + expect(await new Response(seen.init?.body).text()).toBe("{}"); +}); + +test("a URL input keeps RequestInit, including the header and manual redirect", async () => { + let seen: { input: unknown; init: RequestInit | undefined } | undefined; + const fetchImpl: FetchLike = async (input, init) => { + seen = { input, init }; + return new Response("ok"); + }; + await pinned({ fetch: fetchImpl })(new URL("https://api.example.com/v1"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + redirect: "follow", + }); + if (seen === undefined) throw new Error("delegate was not called"); + expect(seen.input).toBe("https://api.example.com/v1"); + expect(seen.init?.method).toBe("POST"); + expect(seen.init?.redirect).toBe("manual"); + expect(seen.init?.body).toBe("{}"); + const headers = new Headers(seen.init?.headers); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-api-key")).toBe(SECRET); +}); + +test("an abort signal on a URL input rejects the fetch", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Promise(() => undefined), + }); + try { + const controller = new AbortController(); + const pending = pinned({ origin: server.url.origin })("/slow", { + signal: controller.signal, + }); + controller.abort(); + await expect(pending).rejects.toThrow(/abort/i); + } finally { + server.stop(true); + } +}); + +test("an abort signal on a Request rejects the fetch", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Promise(() => undefined), + }); + try { + const controller = new AbortController(); + const pending = pinned({ origin: server.url.origin })( + new Request(`${server.url.origin}/slow`, { signal: controller.signal }), + ); + controller.abort(); + await expect(pending).rejects.toThrow(/abort/i); + } finally { + server.stop(true); + } +}); + +test("RequestInit signal overrides the Request signal", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Promise(() => undefined), + }); + try { + const requestController = new AbortController(); + const initController = new AbortController(); + const pending = pinned({ origin: server.url.origin })( + new Request(`${server.url.origin}/slow`, { + signal: requestController.signal, + }), + { signal: initController.signal }, + ); + requestController.abort(); + const stillPending = await Promise.race([ + pending.then( + () => false, + () => false, + ), + Bun.sleep(40).then(() => true), + ]); + expect(stillPending).toBe(true); + initController.abort(); + await expect(pending).rejects.toThrow(/abort/i); + } finally { + server.stop(true); + } +}); + +test("https and loopback http origins are allowed; other schemes are refused before any fetch", () => { + const calls: unknown[] = []; + const fetchImpl: FetchLike = async (...args) => { + calls.push(args); + return new Response("ok"); + }; + expect(() => + pinned({ origin: "https://api.example.com", fetch: fetchImpl }), + ).not.toThrow(); + for (const origin of [ + "http://127.0.0.1:9", + "http://localhost:9", + "http://[::1]:9", + ]) { + expect(() => pinned({ origin, fetch: fetchImpl })).not.toThrow(); + } + for (const origin of [ + "http://api.example.com", + "http://127.0.0.2", + "http://localhost.example.com", + "http://[::2]", + "ws://localhost", + "file:///tmp/secret", + ]) { + let error: unknown; + try { + pinned({ + origin, + fetch: fetchImpl, + readValue: () => SECRET, + }); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("must be https"); + expect((error as Error).message).not.toContain(SECRET); + } + expect(calls).toHaveLength(0); +}); + +test("host extra origins reject non-https and do not leak the secret", async () => { + const calls: unknown[] = []; + const fetchImpl: FetchLike = async (...args) => { + calls.push(args); + return new Response("ok"); + }; + expect(() => + createXApiKeyCredentialProvider({ + fetch: fetchImpl, + extraOrigins: { + "https://mcp.example.com": [ + "https://auth.example.com", + "http://127.0.0.1:9", + ], + "http://localhost": ["http://[::1]:9"], + }, + }), + ).not.toThrow(); + + let error: unknown; + try { + createXApiKeyCredentialProvider({ + fetch: fetchImpl, + extraOrigins: { + "https://mcp.example.com": ["http://evil.example/steal"], + }, + }); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "credential transport http://evil.example must be https", + ); + expect((error as Error).message).not.toContain(SECRET); + expect(calls).toHaveLength(0); + + let reads = 0; + const handle = createXApiKeyCredentialProvider({ fetch: fetchImpl }).shape({ + origin: "https://api.example.com/v1", + readCurrentMaterial: () => { + reads += 1; + return { secret: SECRET }; + }, + }); + await expect(handle.fetch("http://evil.example/steal")).rejects.toThrow( + "refusing cross-origin request to http://evil.example", + ); + expect(reads).toBe(0); + expect(calls).toHaveLength(0); +}); + +test("a redirect is returned unfollowed for Request and URL inputs", async () => { + const seen: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(req) { + seen.push(new URL(req.url).pathname); + return Response.redirect("http://evil.example/steal", 302); + }, + }); + try { + const fetchPinned = pinned({ origin: server.url.origin }); + const fromUrl = await fetchPinned("/redirect", { redirect: "follow" }); + expect(fromUrl.status).toBe(302); + expect(fromUrl.headers.get("location")).toBe("http://evil.example/steal"); + const fromRequest = await fetchPinned( + new Request(`${server.url.origin}/from-request`, { redirect: "follow" }), + { redirect: "follow" }, + ); + expect(fromRequest.status).toBe(302); + expect(seen).toEqual(["/redirect", "/from-request"]); + } finally { + server.stop(true); + } +}); + +function attackerServer() { + const seen: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(req) { + seen.push(req.headers.get("x-api-key") ?? ""); + return new Response("stolen"); + }, + }); + return { server, seen }; +} + +test("a URL subclass cannot spoof origin and move the fetched host", async () => { + const attacker = attackerServer(); + const calls: string[] = []; + let reads = 0; + class SpoofedOrigin extends URL { + override get origin(): string { + return "https://api.example.com"; + } + } + const fetchImpl: FetchLike = async (input, init) => { + calls.push(new Headers(init?.headers).get("x-api-key") ?? ""); + return globalThis.fetch(input, init); + }; + try { + const target = new SpoofedOrigin( + `http://127.0.0.1:${String(attacker.server.port)}/steal`, + ); + await expect( + pinned({ + fetch: fetchImpl, + readValue: () => { + reads += 1; + return SECRET; + }, + })(target), + ).rejects.toThrow( + "refusing cross-origin request to http://127.0.0.1:" + + String(attacker.server.port), + ); + expect(reads).toBe(0); + expect(calls).toHaveLength(0); + expect(attacker.seen).toHaveLength(0); + } finally { + attacker.server.stop(true); + } +}); + +test("a headers getter cannot move a pinned loopback URL before the secret is read", async () => { + const attacker = attackerServer(); + const calls: string[] = []; + let reads = 0; + const fetchImpl: FetchLike = async (input, init) => { + calls.push(new Headers(init?.headers).get("x-api-key") ?? ""); + return globalThis.fetch(input, init); + }; + const url = new URL("http://127.0.0.1/ok"); + try { + await expect( + pinned({ + origin: "http://127.0.0.1", + fetch: fetchImpl, + readValue: () => { + reads += 1; + return SECRET; + }, + })(url, { + get headers() { + url.port = String(attacker.server.port); + return { "x-trace": "1" }; + }, + }), + ).rejects.toThrow( + `refusing cross-origin request to http://127.0.0.1:${String(attacker.server.port)}`, + ); + expect(reads).toBe(0); + expect(calls).toHaveLength(0); + expect(attacker.seen).toHaveLength(0); + } finally { + attacker.server.stop(true); + } +}); + +test("a Request url getter cannot move the host on a later read", async () => { + const attacker = attackerServer(); + const calls: string[] = []; + let reads = 0; + const pin = "https://api.example.com/v1"; + const attack = `http://127.0.0.1:${String(attacker.server.port)}/steal`; + const request = new Request(pin); + let urlReads = 0; + const flipping = new Proxy(request, { + get(target, prop, receiver) { + if (prop === "url") { + urlReads += 1; + return urlReads === 1 ? pin : attack; + } + const value: unknown = Reflect.get(target, prop, receiver); + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }); + const fetchImpl: FetchLike = async (input, init) => { + calls.push(new Headers(init?.headers).get("x-api-key") ?? ""); + return globalThis.fetch(input, init); + }; + try { + await expect( + pinned({ + fetch: fetchImpl, + readValue: () => { + reads += 1; + return SECRET; + }, + })(flipping), + ).rejects.toThrow( + `refusing cross-origin request to http://127.0.0.1:${String(attacker.server.port)}`, + ); + expect(reads).toBe(0); + expect(calls).toHaveLength(0); + expect(attacker.seen).toHaveLength(0); + } finally { + attacker.server.stop(true); + } +}); + +test("blob URLs are refused before the credential is read", async () => { + const attacker = attackerServer(); + const calls: string[] = []; + const fetchImpl: FetchLike = async (input, init) => { + calls.push(new Headers(init?.headers).get("x-api-key") ?? ""); + return globalThis.fetch(input, init); + }; + const cases: ReadonlyArray<{ origin: string; raw: string }> = [ + { + origin: "https://api.example.com", + raw: "blob:https://api.example.com/00000000-0000-0000-0000-000000000000", + }, + { + origin: "http://127.0.0.1", + raw: "blob:http://127.0.0.1/00000000-0000-0000-0000-000000000000", + }, + ]; + try { + for (const { origin, raw } of cases) { + let reads = 0; + await expect( + pinned({ + origin, + fetch: fetchImpl, + readValue: () => { + reads += 1; + return SECRET; + }, + })(raw), + ).rejects.toThrow(/blob:/); + expect(reads).toBe(0); + } + expect(calls).toHaveLength(0); + expect(attacker.seen).toHaveLength(0); + } finally { + attacker.server.stop(true); + } +}); diff --git a/src/origin-pinned-fetch.ts b/src/origin-pinned-fetch.ts index 231973e..b7db626 100644 --- a/src/origin-pinned-fetch.ts +++ b/src/origin-pinned-fetch.ts @@ -4,6 +4,10 @@ // The protections are the same: every request is origin-checked before the // credential is attached, and `redirect: "manual"` is forced so a 3xx is // returned unfollowed rather than carrying the credential to another host. +// Transport is https, except http on loopback for a same-machine dev server. +// The URL is snapshotted to a string before headers, the credential, or fetch, +// and that string is what fetch is called with. A live URL or Request cannot +// move the host afterward. import type { FetchLike } from "@intx/harness"; @@ -11,6 +15,15 @@ import type { FetchLike } from "@intx/harness"; const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; const HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/; +// http is not a production credential transport. These hosts have no network +// hop; anything else (including other loopback addresses) stays refused. +const LOOPBACK_HOSTS: ReadonlySet = new Set([ + "localhost", + "127.0.0.1", + "[::1]", + "::1", +]); + /** Throw unless `name` is a valid HTTP header name. */ export function assertHeaderName(name: string): void { if (!HEADER_NAME.test(name)) { @@ -18,6 +31,26 @@ export function assertHeaderName(name: string): void { } } +/** + * Origin of `raw` when it is a credential transport we will send a secret to. + * https is required. http is accepted only for loopback. `blob:` and every + * other protocol are refused, even when their origin string matches the pin. + */ +export function credentialTransportOrigin(raw: string): string { + const url = new URL(raw); + const loopback = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname); + if (url.protocol !== "https:" && !loopback) { + const label = + url.protocol === "http:" || url.protocol === "https:" + ? url.origin + : url.href; + throw new Error( + `credential transport ${label} must be https (http is allowed only on loopback)`, + ); + } + return url.origin; +} + export interface OriginPinnedFetchOptions { /** Origin every request must target. A full URL is reduced to its origin. */ origin: string; @@ -32,6 +65,7 @@ export interface OriginPinnedFetchOptions { /** * Further origins a request may target, for a server whose protocol origin * differs from its stored URL. A redirect to one is still never followed. + * Each entry must be https, or http on loopback. */ extraOrigins?: readonly string[]; /** The `fetch` to delegate to. Defaults to the global `fetch`. */ @@ -41,15 +75,21 @@ export interface OriginPinnedFetchOptions { /** * Build a `fetch` that refuses any request outside the pinned origin (plus * `extraOrigins`), sets or strips `header`, and never follows a redirect. + * A `Request` plus `RequestInit` keeps fetch's override rules: init replaces + * request fields it sets, then the credential header and `redirect: "manual"` + * are forced. The delegate receives the snapshotted URL string and that init, + * never the caller's live `URL` or `Request`. */ export function createOriginPinnedFetch( opts: OriginPinnedFetchOptions, ): FetchLike { assertHeaderName(opts.header); const fetchImpl: FetchLike = opts.fetch ?? globalThis.fetch; - const pinnedOrigin = new URL(opts.origin).origin; + const pinnedOrigin = credentialTransportOrigin(opts.origin); const allowed = new Set( - (opts.extraOrigins ?? []).map((origin) => new URL(origin).origin), + (opts.extraOrigins ?? []).map((origin) => + credentialTransportOrigin(origin), + ), ); allowed.add(pinnedOrigin); @@ -57,17 +97,37 @@ export function createOriginPinnedFetch( input: string | URL | Request, init?: RequestInit, ): Promise => { - const target = resolveTargetUrl(input, pinnedOrigin); + // Read the target once, before headers. A URL subclass can lie about + // `origin`, and a Request `url` getter can change on the next read. + const snapshot = snapshotHref(input, pinnedOrigin); + const target = new URL(snapshot); if (!allowed.has(target.origin)) { - throw new Error( - `credential is pinned to ${pinnedOrigin}; refusing cross-origin request to ${target.origin}`, - ); + throw crossOrigin(pinnedOrigin, target.origin); + } + // Allowlist entries were checked at construction. Check this URL's own + // protocol, not `target.origin`: `blob:` URLs report the creator's origin. + credentialTransportOrigin(snapshot); + + // Read init headers before the second URL read so a headers getter that + // mutates `url.port` is visible. Do not touch `input.headers` yet: a + // Request proxy's header getter brands `this` and would mask the move. + const initHeaders = init?.headers; + if (typeof input !== "string") { + const current = snapshotHref(input, pinnedOrigin); + if (current !== snapshot) { + throw crossOrigin(pinnedOrigin, new URL(current).origin); + } } - const value = opts.readValue(); const headers = new Headers( - input instanceof Request ? input.headers : init?.headers, + initHeaders !== undefined + ? initHeaders + : input instanceof Request + ? input.headers + : undefined, ); + + const value = opts.readValue(); if (value === undefined || value === "") { headers.delete(opts.header); } else { @@ -79,10 +139,7 @@ export function createOriginPinnedFetch( headers.set(opts.header, value); } - if (input instanceof Request) { - return fetchImpl(new Request(input, { headers, redirect: "manual" })); - } - return fetchImpl(target, { ...init, headers, redirect: "manual" }); + return fetchImpl(snapshot, pinnedInit(input, init, headers)); }; } @@ -90,16 +147,55 @@ export function createOriginPinnedFetch( * Resolve the URL a request targets, as `@intx/harness` does: a relative * string resolves against the pinned origin, an absolute string or URL keeps * its own origin, and a `Request` already carries an absolute URL. + * The result is a fresh href string, not the caller's object. */ -function resolveTargetUrl( +function snapshotHref( input: string | URL | Request, pinnedOrigin: string, -): URL { +): string { if (typeof input === "string") { - return new URL(input, pinnedOrigin); + return new URL(input, pinnedOrigin).href; } if (input instanceof URL) { - return input; + return new URL(input.href).href; + } + return new URL(input.url).href; +} + +/** + * Keep fetch's override rules without handing fetch the live Request. + * Init wins when it sets method, body, or signal; otherwise those come from + * the Request. The credential header and `redirect: "manual"` are already + * applied by the caller. + */ +function pinnedInit( + input: string | URL | Request, + init: RequestInit | undefined, + headers: Headers, +): RequestInit { + const next: RequestInit = { ...init, headers, redirect: "manual" }; + if (!(input instanceof Request)) { + return next; } - return new URL(input.url); + if (init?.method === undefined) { + next.method = input.method; + } + if (init?.signal === undefined) { + next.signal = input.signal; + } + if ( + init?.body === undefined && + next.method !== "GET" && + next.method !== "HEAD" && + input.body !== null + ) { + next.body = input.body; + } + return next; +} + +function crossOrigin(pinnedOrigin: string, target: string): Error { + return new Error( + `credential is pinned to ${pinnedOrigin}; refusing cross-origin request to ${target}`, + ); }