From 6b618b581387917ffe147c057a3b19c7ad72e67a Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:18:29 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20allowed-origins=20credential=20boundary?= =?UTF-8?q?=20=E2=80=94=20stop=20leaking=20credentials=20to=20third-party?= =?UTF-8?q?=20401s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the reactive layer patches global `fetch`, every request the page makes — including to third-party origins (a CDN, image host, analytics beacon, or a URL embedded in fetched data) — flows through it. Today ANY origin that answers 401 triggers a provider `upgrade` and receives a retry carrying the user's Solid credentials (the `Authorization` access token minted for that origin's URL). That hands the user's identity/credential to origins they never chose to trust. Add an opt-in `allowedOrigins` credential boundary on `ReactiveFetchManager` and `ReactiveAuthenticationClient`: a 401 from an out-of-boundary origin is returned untouched — no provider consulted, no credentials attached. The `CredentialBoundary` compares canonical origins (not URL substrings, so a look-alike host cannot slip past) and can be widened in place after construction without disrupting a live session. Backwards-compatible: omitting `allowedOrigins` preserves the previous "upgrade every 401" behaviour and logs a one-time warning recommending it. Co-Authored-By: Claude Opus 4.8 --- README.md | 26 +++++ package.json | 12 ++- src/CredentialBoundary.ts | 103 ++++++++++++++++++ src/ReactiveAuthenticationClient.ts | 52 ++++++++- src/ReactiveFetchManager.ts | 55 +++++++++- src/mod.ts | 1 + test/CredentialBoundary.test.ts | 162 ++++++++++++++++++++++++++++ 7 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 src/CredentialBoundary.ts create mode 100644 test/CredentialBoundary.test.ts diff --git a/README.md b/README.md index c22a9a6..c8f0375 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,32 @@ manager.registerGlobally() const response = await fetch(requestUri) ``` +### Restrict which origins receive credentials (recommended) + +Once global `fetch` is patched, **every** request the page makes flows through +the reactive layer — including requests to third-party origins (a CDN, an image +host, an analytics beacon, a URL embedded in fetched data). Without a boundary, +any of those origins can answer `401` to trigger the authorization flow and +receive a retry carrying the user's credentials. + +Pass `allowedOrigins` — your pod/storage origins — so credentials are only ever +attached to origins you trust. A `401` from any other origin is returned +untouched: + +```js +import { CredentialBoundary, ReactiveFetchManager } from "@solid/reactive-authentication" + +const boundary = new CredentialBoundary(["https://alice.pod.example"]) +const manager = new ReactiveFetchManager([provider], { allowedOrigins: boundary }) + +// The boundary can be widened in place later, without disrupting the session: +boundary.add("https://shared.pod.example") +``` + +`allowedOrigins` also accepts a plain iterable of origins or origin predicates. +It applies to `ReactiveAuthenticationClient` too. When omitted, the previous +"upgrade every `401`" behaviour is preserved and a one-time warning is logged. + ## Run the demo To compile, diff --git a/package.json b/package.json index db6dd7a..4f79354 100644 --- a/package.json +++ b/package.json @@ -26,21 +26,23 @@ "url": "git+https://github.com/solid-contrib/reactive-authentication.git" }, "scripts": { - "build": "tsc" + "build": "tsc", + "test": "vitest run" }, "license": "MIT", "dependencies": { - "oauth4webapi": "^3", - "dpop": "^2", "@rdfjs/wrapper": "^0.34", - "n3": "^2" + "dpop": "^2", + "n3": "^2", + "oauth4webapi": "^3" }, "devDependencies": { "@rdfjs/types": "^2", "@types/n3": "^1", "typedoc": "^0.28.18", "typedoc-plugin-mdn-links": "^5.1.1", - "typescript": "^6" + "typescript": "^6", + "vitest": "^4.1.10" }, "engines": { "node": ">=24.0.0" diff --git a/src/CredentialBoundary.ts b/src/CredentialBoundary.ts new file mode 100644 index 0000000..d5d0cb8 --- /dev/null +++ b/src/CredentialBoundary.ts @@ -0,0 +1,103 @@ +/** + * A value that names one or more trusted origins: an origin string or `URL` + * (reduced to its {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/origin origin}), + * or a predicate that receives a request's origin and returns whether it is trusted. + */ +export type OriginMatcher = string | URL | ((origin: string) => boolean) + +/** + * The set of origins to which the reactive layer may attach the user's + * credentials when retrying a request that was rejected with `401`. + * + * @remarks + * Why this exists — a credential-leak boundary. + * + * {@link ReactiveFetchManager.registerGlobally} replaces `globalThis.fetch`, so + * *every* fetch the page makes flows through the reactive layer — including + * requests to third-party origins the app never meant to authenticate to (a CDN, + * an image host, an analytics beacon, a URL embedded in fetched data). Without a + * boundary, *any* of those origins can answer `401` to trigger a + * {@link TokenProvider.upgrade} and receive a retry carrying the user's Solid + * credentials — the `Authorization` access token (and a DPoP proof) minted for + * that origin's URL. That hands the user's identity/credential to an origin they + * never chose to trust. + * + * A `CredentialBoundary` is the allow-list of origins the consumer *does* trust + * with credentials — typically their pod/storage origins (and the issuer). A + * `401` from an out-of-boundary origin is passed through untouched: no upgrade, + * no credential attachment. + * + * The boundary can be {@link add | widened} in place after construction (for + * example when a session legitimately gains access to a further storage origin), + * without disrupting any established session — the reactive layer holds no + * per-origin state to re-grant. + * + * @example + * ```ts + * const boundary = new CredentialBoundary(["https://alice.pod.example"]) + * new ReactiveFetchManager([provider], {allowedOrigins: boundary}).registerGlobally() + * // later, having discovered another storage the user can reach: + * boundary.add("https://shared.pod.example") + * ``` + */ +export class CredentialBoundary { + readonly #origins = new Set() + readonly #predicates: Array<(origin: string) => boolean> = [] + + /** + * @param matchers - Origins (or origin predicates) to trust with credentials. + */ + constructor(matchers: Iterable = []) { + this.add(...matchers) + } + + /** + * Widens the boundary to trust further origins, in place. Safe to call on a + * live boundary while requests are in flight; it never narrows the boundary. + * + * @returns This boundary, for chaining. + */ + add(...matchers: OriginMatcher[]): this { + for (const matcher of matchers) { + if (typeof matcher === "function") { + this.#predicates.push(matcher) + continue + } + + const origin = originOf(matcher) + if (origin !== undefined) { + this.#origins.add(origin) + } + } + + return this + } + + /** + * Whether `origin` is trusted with credentials. `origin` is expected to be a + * canonical origin string (as produced by {@link originOf}); a value that is + * not exactly a trusted origin and matches no predicate is not trusted. + */ + allows(origin: string): boolean { + return this.#origins.has(origin) || this.#predicates.some(predicate => predicate(origin)) + } +} + +/** + * Reduces a URL(-ish) value to its canonical + * {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/origin origin} + * (scheme + host + port), or `undefined` when it is not a valid absolute URL. + * + * @remarks + * Comparing canonical origins — rather than raw URL prefixes — is what makes the + * boundary robust: `https://pod.example` and `https://pod.example:443/anything` + * share one origin, while `https://pod.example.attacker.test` does not, so a + * look-alike host cannot slip past a substring check. + */ +export function originOf(url: string | URL): string | undefined { + try { + return new URL(url).origin + } catch { + return undefined + } +} diff --git a/src/ReactiveAuthenticationClient.ts b/src/ReactiveAuthenticationClient.ts index 46b3c8d..52d1753 100644 --- a/src/ReactiveAuthenticationClient.ts +++ b/src/ReactiveAuthenticationClient.ts @@ -1,12 +1,32 @@ import type { TokenProvider } from "./TokenProvider.js" +import { CredentialBoundary, type OriginMatcher, originOf } from "./CredentialBoundary.js" + +/** Options for {@link ReactiveAuthenticationClient}. */ +export interface ReactiveAuthenticationClientOptions { + /** + * The origins the client may attach the user's credentials to when retrying a + * request rejected with `401` — see {@link CredentialBoundary}. Strongly + * recommended: set this to your pod/storage origins. When omitted, every + * `401` is upgraded regardless of origin (the previous behaviour) and a + * one-time warning is logged. + */ + allowedOrigins?: CredentialBoundary | Iterable +} export class ReactiveAuthenticationClient { readonly #fetch: typeof globalThis.fetch readonly #providers: Iterable + readonly #boundary: CredentialBoundary | undefined + #warnedNoBoundary = false - constructor(fetch: typeof globalThis.fetch, providers: Iterable) { + constructor( + fetch: typeof globalThis.fetch, + providers: Iterable, + options: ReactiveAuthenticationClientOptions = {}, + ) { this.#fetch = fetch this.#providers = providers + this.#boundary = toBoundary(options.allowedOrigins) } async fetch(request: Request): Promise { @@ -15,6 +35,13 @@ export class ReactiveAuthenticationClient { return response } + // Credential boundary: an out-of-boundary 401 is returned untouched, so + // the user's credentials are never attached to a retry against an origin + // the consumer has not trusted (see CredentialBoundary). + if (!this.#withinBoundary(request.url)) { + return response + } + const provider = await this.#findProvider(request) if (provider === undefined) { return response @@ -24,6 +51,21 @@ export class ReactiveAuthenticationClient { return this.#fetch.call(undefined, upgraded) } + #withinBoundary(url: string): boolean { + if (this.#boundary === undefined) { + if (!this.#warnedNoBoundary) { + this.#warnedNoBoundary = true + console.warn( + "ReactiveAuthenticationClient: no `allowedOrigins` credential boundary is set, so every 401 response — including from third-party origins — triggers a credentialed retry. Pass `allowedOrigins` (your pod/storage origins) to prevent leaking the user's credentials.", + ) + } + return true + } + + const origin = originOf(url) + return origin !== undefined && this.#boundary.allows(origin) + } + async #findProvider(request: Request): Promise { for (const provider of this.#providers) { if (await provider.matches(request)) { @@ -34,3 +76,11 @@ export class ReactiveAuthenticationClient { return undefined } } + +function toBoundary(input: ReactiveAuthenticationClientOptions["allowedOrigins"]): CredentialBoundary | undefined { + if (input === undefined) { + return undefined + } + + return input instanceof CredentialBoundary ? input : new CredentialBoundary(input) +} diff --git a/src/ReactiveFetchManager.ts b/src/ReactiveFetchManager.ts index 6fffbfb..a45de62 100644 --- a/src/ReactiveFetchManager.ts +++ b/src/ReactiveFetchManager.ts @@ -1,15 +1,38 @@ import type { TokenProvider } from "./TokenProvider.js" +import { CredentialBoundary, type OriginMatcher, originOf } from "./CredentialBoundary.js" + +/** Options for {@link ReactiveFetchManager}. */ +export interface ReactiveFetchManagerOptions { + /** + * The origins the reactive layer may attach the user's credentials to when + * retrying a request rejected with `401` — see {@link CredentialBoundary}. + * + * Strongly recommended: set this to your pod/storage origins so that a `401` + * from a third-party origin cannot harvest the user's credentials. Pass a + * {@link CredentialBoundary} instance (which you can later + * {@link CredentialBoundary.add | widen}) or any iterable of + * {@link OriginMatcher}s (origins / predicates). + * + * When omitted, every `401` is upgraded regardless of origin (the previous + * behaviour) and a one-time warning is logged. + */ + allowedOrigins?: CredentialBoundary | Iterable +} export class ReactiveFetchManager extends EventTarget { readonly #globalFetch: typeof globalThis.fetch readonly #providers: Iterable + readonly #boundary: CredentialBoundary | undefined + #warnedNoBoundary = false - constructor(providers: Iterable) { + constructor(providers: Iterable, options: ReactiveFetchManagerOptions = {}) { super() this.#providers = providers this.#globalFetch = globalThis.fetch + + this.#boundary = toBoundary(options.allowedOrigins) } registerGlobally() { @@ -27,6 +50,13 @@ export class ReactiveFetchManager extends EventTarget { return response } + // Credential boundary: never attach the user's credentials to a retry + // against an origin the consumer has not trusted. An out-of-boundary + // `401` is returned untouched — no provider is consulted, no upgrade runs. + if (!this.#withinBoundary(request.url)) { + return response + } + const provider = await this.#findProvider(request) if (provider === undefined) { return response @@ -36,6 +66,21 @@ export class ReactiveFetchManager extends EventTarget { return this.#globalFetch.call(undefined, upgraded) } + #withinBoundary(url: string): boolean { + if (this.#boundary === undefined) { + if (!this.#warnedNoBoundary) { + this.#warnedNoBoundary = true + console.warn( + "ReactiveFetchManager: no `allowedOrigins` credential boundary is set, so every 401 response — including from third-party origins — triggers a credentialed retry. Pass `allowedOrigins` (your pod/storage origins) to prevent leaking the user's credentials.", + ) + } + return true + } + + const origin = originOf(url) + return origin !== undefined && this.#boundary.allows(origin) + } + async #findProvider(request: Request): Promise { for (const provider of this.#providers) { if (await provider.matches(request)) { @@ -46,3 +91,11 @@ export class ReactiveFetchManager extends EventTarget { return undefined } } + +function toBoundary(input: ReactiveFetchManagerOptions["allowedOrigins"]): CredentialBoundary | undefined { + if (input === undefined) { + return undefined + } + + return input instanceof CredentialBoundary ? input : new CredentialBoundary(input) +} diff --git a/src/mod.ts b/src/mod.ts index 0342cf6..6424482 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,5 +1,6 @@ export * from "./AuthorizationCodeFlow.js" export * from "./BearerTokenProvider.js" +export * from "./CredentialBoundary.js" export * from "./DPoPTokenProvider.js" export * from "./ReactiveFetchManager.js" export * from "./ReactiveFetchWorkerManager.js" diff --git a/test/CredentialBoundary.test.ts b/test/CredentialBoundary.test.ts new file mode 100644 index 0000000..67baf4f --- /dev/null +++ b/test/CredentialBoundary.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { CredentialBoundary, originOf } from "../src/CredentialBoundary.js" +import { ReactiveFetchManager } from "../src/ReactiveFetchManager.js" +import { ReactiveAuthenticationClient } from "../src/ReactiveAuthenticationClient.js" +import type { TokenProvider } from "../src/TokenProvider.js" + +/** + * A provider that matches everything and records every request it is asked to + * upgrade — standing in for a real token provider so the tests can assert + * whether the reactive layer would have attached credentials to a given origin. + */ +function recordingProvider(): {provider: TokenProvider; upgraded: Request[]} { + const upgraded: Request[] = [] + const provider: TokenProvider = { + matches: async () => true, + upgrade: async request => { + upgraded.push(request) + const headers = new Headers(request.headers) + headers.set("Authorization", "DPoP secret-access-token") + return new Request(request, {headers}) + }, + } + return {provider, upgraded} +} + +describe("originOf", () => { + it("reduces a URL to its canonical origin", () => { + expect(originOf("https://pod.example/a/b?x=1")).toBe("https://pod.example") + expect(originOf("https://pod.example:443/a")).toBe("https://pod.example") + expect(originOf(new URL("https://pod.example:8443/a"))).toBe("https://pod.example:8443") + }) + + it("returns undefined for a non-absolute URL", () => { + expect(originOf("/relative/path")).toBeUndefined() + expect(originOf("not a url")).toBeUndefined() + }) +}) + +describe("CredentialBoundary", () => { + it("trusts exactly the configured origins", () => { + const boundary = new CredentialBoundary(["https://pod.example", new URL("https://other.example/ignored/path")]) + expect(boundary.allows("https://pod.example")).toBe(true) + expect(boundary.allows("https://other.example")).toBe(true) + expect(boundary.allows("https://untrusted.example")).toBe(false) + }) + + it("does not trust a look-alike host (origin, not substring, comparison)", () => { + const boundary = new CredentialBoundary(["https://pod.example"]) + expect(boundary.allows("https://pod.example.attacker.test")).toBe(false) + expect(boundary.allows("http://pod.example")).toBe(false) // scheme differs + }) + + it("supports origin predicates", () => { + const boundary = new CredentialBoundary([origin => origin.endsWith(".trusted.example")]) + expect(boundary.allows("https://a.trusted.example")).toBe(true) + expect(boundary.allows("https://b.other.example")).toBe(false) + }) + + it("widens in place via add() without narrowing", () => { + const boundary = new CredentialBoundary(["https://pod.example"]) + expect(boundary.allows("https://shared.example")).toBe(false) + boundary.add("https://shared.example") + expect(boundary.allows("https://pod.example")).toBe(true) + expect(boundary.allows("https://shared.example")).toBe(true) + }) +}) + +describe("ReactiveFetchManager credential boundary", () => { + let unauthorized: () => Response + + beforeEach(() => { + unauthorized = () => new Response(null, {status: 401}) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it("does not upgrade — nor leak credentials to — an out-of-boundary origin", async () => { + vi.stubGlobal("fetch", vi.fn(async () => unauthorized())) + const {provider, upgraded} = recordingProvider() + const manager = new ReactiveFetchManager([provider], {allowedOrigins: ["https://pod.example"]}) + + const response = await manager.fetch("https://tracker.attacker.test/pixel") + + expect(response.status).toBe(401) + expect(upgraded).toHaveLength(0) // provider.upgrade never called → no credentials minted + }) + + it("upgrades an in-boundary origin", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const request = new Request(input) + // The retry (which carries credentials) succeeds; the first attempt is 401. + return request.headers.get("Authorization") === null ? unauthorized() : new Response("ok") + }) + vi.stubGlobal("fetch", fetchMock) + const {provider, upgraded} = recordingProvider() + const manager = new ReactiveFetchManager([provider], {allowedOrigins: ["https://pod.example"]}) + + const response = await manager.fetch("https://pod.example/private") + + expect(response.status).toBe(200) + expect(upgraded).toHaveLength(1) + expect(upgraded[0]!.url).toBe("https://pod.example/private") + }) + + it("honours a boundary widened after construction (reArm)", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const request = new Request(input) + return request.headers.get("Authorization") === null ? unauthorized() : new Response("ok") + }) + vi.stubGlobal("fetch", fetchMock) + const {provider, upgraded} = recordingProvider() + const boundary = new CredentialBoundary(["https://pod.example"]) + const manager = new ReactiveFetchManager([provider], {allowedOrigins: boundary}) + + // Before widening: not upgraded. + expect((await manager.fetch("https://shared.example/x")).status).toBe(401) + expect(upgraded).toHaveLength(0) + + boundary.add("https://shared.example") + + // After widening: upgraded, no re-construction required. + expect((await manager.fetch("https://shared.example/x")).status).toBe(200) + expect(upgraded).toHaveLength(1) + }) + + it("warns once and preserves legacy upgrade-everything behaviour when no boundary is set", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const request = new Request(input) + return request.headers.get("Authorization") === null ? unauthorized() : new Response("ok") + }) + vi.stubGlobal("fetch", fetchMock) + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const {provider, upgraded} = recordingProvider() + const manager = new ReactiveFetchManager([provider]) + + expect((await manager.fetch("https://any.example/x")).status).toBe(200) + expect((await manager.fetch("https://another.example/y")).status).toBe(200) + + expect(upgraded).toHaveLength(2) // legacy: every 401 upgraded + expect(warn).toHaveBeenCalledTimes(1) // but the operator is warned exactly once + }) +}) + +describe("ReactiveAuthenticationClient credential boundary", () => { + afterEach(() => vi.restoreAllMocks()) + + it("does not upgrade an out-of-boundary origin", async () => { + const baseFetch = vi.fn(async () => new Response(null, {status: 401})) + const {provider, upgraded} = recordingProvider() + const client = new ReactiveAuthenticationClient(baseFetch, [provider], { + allowedOrigins: ["https://pod.example"], + }) + + const response = await client.fetch(new Request("https://tracker.attacker.test/pixel")) + + expect(response.status).toBe(401) + expect(upgraded).toHaveLength(0) + }) +})