From 2ec248e1451ed79adf3c30eac8ffc48d6243ae1f Mon Sep 17 00:00:00 2001 From: brett Date: Tue, 20 Oct 2026 22:40:01 +0000 Subject: [PATCH 1/3] fix(browser-pay): emit origin-only event URLs and sanitized refusal reasons Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/browser-pay-event-redaction.md | 5 + packages/browser-pay/README.md | 4 +- packages/browser-pay/src/cdp.ts | 22 +- .../src/interceptor.event-redaction.test.ts | 731 ++++++++++++++++++ .../src/interceptor.integration.test.ts | 2 +- .../src/interceptor.protocol.test.ts | 6 +- packages/browser-pay/src/interceptor.ts | 79 +- 7 files changed, 819 insertions(+), 30 deletions(-) create mode 100644 .changeset/browser-pay-event-redaction.md create mode 100644 packages/browser-pay/src/interceptor.event-redaction.test.ts diff --git a/.changeset/browser-pay-event-redaction.md b/.changeset/browser-pay-event-redaction.md new file mode 100644 index 000000000..63b64100a --- /dev/null +++ b/.changeset/browser-pay-event-redaction.md @@ -0,0 +1,5 @@ +--- +"@sapiom/browser-pay": minor +--- + +Interceptor event URLs now expose only the request origin, and refusal reasons are restricted to known relay values. CDP attach errors use fixed messages to avoid forwarding transport details. diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index f383e182c..549b59895 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -53,7 +53,9 @@ Refusal, nothing forwarded: { "outcome": "refused", "reason": "already_relayed" } ``` -The reason string is passed through. Reasons David returns include `already_relayed`, `total_changed`, `key_rotated`, `card_removed`, `unknown_injection`, `injection_expired`, `payment_released`, `relay_in_progress`, `payment_not_authorized`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`. `relay_in_progress` is not terminal. The paused request stays open and the same idempotency key is posted again until the outcome is terminal or the deadline passes. +The `refused` event and logger pass through only these known reasons: `unknown_injection`, `injection_expired`, `payment_released`, `key_rotated`, `already_relayed`, `relay_in_progress`, `card_removed`, `payment_not_authorized`, `total_changed`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`. Any other reason becomes `unrecognized`. `relay_in_progress` is not terminal. The paused request stays open and the same idempotency key is posted again until the outcome is terminal or the deadline passes. + +Event `url` is origin-only (`scheme://host[:port]`), with any held placeholder masked. An opaque origin uses its scheme (for example, `about:`); an unparseable URL is `""`. Paths, queries, fragments, and userinfo are never included. Attach errors use fixed messages and do not include transport details. No answer after the send: diff --git a/packages/browser-pay/src/cdp.ts b/packages/browser-pay/src/cdp.ts index b18f437cc..8b31fee62 100644 --- a/packages/browser-pay/src/cdp.ts +++ b/packages/browser-pay/src/cdp.ts @@ -18,6 +18,7 @@ export interface CdpEvent { } type Pending = { + method: string; resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; @@ -78,16 +79,20 @@ class WebSocketClient { this.closed = true; this.notifyClose(); }); - socket.on("error", (error: Error) => { - this.fail( - error instanceof Error ? error : new Error("CDP websocket error"), - ); + socket.on("error", () => { + this.fail(new Error("CDP websocket error")); }); } static connect(url: string, timeoutMs: number): Promise { return new Promise((resolve, reject) => { - const target = new URL(url); + let target: URL; + try { + target = new URL(url); + } catch { + reject(new Error("CDP websocket URL is invalid")); + return; + } const key = crypto.randomBytes(16).toString("base64"); const transport = target.protocol === "wss:" ? https : http; const request = transport.request({ @@ -130,7 +135,9 @@ class WebSocketClient { request.on("timeout", () => fail(new Error("CDP websocket connect timeout")), ); - request.on("error", fail); + request.on("error", () => + fail(new Error("CDP websocket connect failed")), + ); request.end(); }); } @@ -338,7 +345,7 @@ export class CdpConnection { this.pending.delete(message.id); clearTimeout(pending.timer); if (message.errorMessage) { - pending.reject(new Error(`CDP ${message.errorMessage}`)); + pending.reject(new Error(`CDP ${pending.method} failed`)); } else { pending.resolve(message.result); } @@ -392,6 +399,7 @@ export class CdpConnection { reject(new Error(`CDP ${method} timed out`)); }, timeoutMs); this.pending.set(id, { + method, resolve, reject, timer, diff --git a/packages/browser-pay/src/interceptor.event-redaction.test.ts b/packages/browser-pay/src/interceptor.event-redaction.test.ts new file mode 100644 index 000000000..b774279e7 --- /dev/null +++ b/packages/browser-pay/src/interceptor.event-redaction.test.ts @@ -0,0 +1,731 @@ +import crypto from "node:crypto"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; +import { textHoldsPlaceholderNumber } from "./detect"; +import type { PayLogger } from "./logger"; +import { attachInterceptor, type InterceptorEvent } from "./interceptor"; +import type { RelayRequest, RelayResponse } from "./contract"; +import { stubPaymentRelay } from "../test/stub-relay"; + +const HELD = { + number: "4242424242424242", + injectionRef: "inj_event_redaction", +}; + +const PLACEHOLDER_FORMS = [ + "4242424242424242", + "4242 4242 4242 4242", + "4242-4242-4242-4242", + "4242%204242%204242%204242", + "4242+4242+4242+4242", + String.raw`4242\u00204242\u00204242\u00204242`, +]; + +const MARKERS = [ + "synthuser", + "synthpass", + "synthopaquesession", + "synthqueryvalue", + "synthtoken", + "synthfragment", + ...PLACEHOLDER_FORMS, +]; + +const ORIGIN = "https://checkout.example.test:8443"; +const BODY = '{"note":"synthbody"}'; + +interface Command { + method: string; + params: Record; + sessionId?: string; +} + +interface StubOptions { + failFetch?: boolean; + failFulfill?: boolean; + errorOnMethod?: { method: string; message: string }; + attachOnAutoAttach?: { targetId: string; url: string }; +} + +function serverFrame(text: string): Buffer { + const payload = Buffer.from(text); + if (payload.length < 126) { + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); + } + if (payload.length < 65536) { + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(payload.length, 2); + return Buffer.concat([header, payload]); + } + const header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 127; + header.writeBigUInt64BE(BigInt(payload.length), 2); + return Buffer.concat([header, payload]); +} + +async function startCdp(options: StubOptions = {}): Promise<{ + url: string; + commands: Command[]; + emit: (event: Record) => void; + close: () => Promise; +}> { + const commands: Command[] = []; + let socket: Duplex | undefined; + const chunks: Buffer[] = []; + let buffered = 0; + let emittedAttachedTarget = false; + + const reply = (id: number, result: unknown, error?: { message: string }) => { + socket?.write( + serverFrame(JSON.stringify(error ? { id, error } : { id, result })), + ); + }; + + const consume = (length: number): Buffer | null => { + if (buffered < length) return null; + const out = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const head = chunks[0]; + const need = length - offset; + if (head.length <= need) { + head.copy(out, offset); + offset += head.length; + chunks.shift(); + } else { + head.copy(out, offset, 0, need); + chunks[0] = head.subarray(need); + offset += need; + } + } + buffered -= length; + return out; + }; + + const peek = (length: number): Buffer | null => { + if (buffered < length) return null; + if (chunks[0] && chunks[0].length >= length) { + return chunks[0].subarray(0, length); + } + const out = Buffer.allocUnsafe(length); + let offset = 0; + for (const chunk of chunks) { + const take = Math.min(chunk.length, length - offset); + chunk.copy(out, offset, 0, take); + offset += take; + if (offset >= length) break; + } + return out; + }; + + const emit = (event: Record) => { + socket?.write(serverFrame(JSON.stringify(event))); + }; + + const onMessage = (text: string) => { + const message = JSON.parse(text) as { + id?: number; + method?: string; + params?: Record; + sessionId?: string; + }; + if (!message.id || !message.method) return; + commands.push({ + method: message.method, + params: message.params ?? {}, + ...(message.sessionId ? { sessionId: message.sessionId } : {}), + }); + + if (options.errorOnMethod?.method === message.method) { + reply(message.id, undefined, { message: options.errorOnMethod.message }); + return; + } + if ( + message.method === "Target.setAutoAttach" && + !message.sessionId && + options.attachOnAutoAttach && + !emittedAttachedTarget + ) { + emittedAttachedTarget = true; + emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: options.attachOnAutoAttach.targetId, + type: "page", + url: options.attachOnAutoAttach.url, + }, + }, + }); + } + if (message.method === "Fetch.enable" && options.failFetch) { + reply(message.id, undefined, { message: "Fetch is unsupported" }); + return; + } + if (message.method === "Fetch.fulfillRequest" && options.failFulfill) { + reply(message.id, undefined, { message: "request is gone" }); + return; + } + if (message.method === "Target.getTargets") { + reply(message.id, { targetInfos: [] }); + return; + } + if (message.method === "Runtime.evaluate") { + reply(message.id, {}); + return; + } + reply(message.id, {}); + }; + + const parse = () => { + for (;;) { + const header = peek(2); + if (!header) return; + const second = header[1]; + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + const extended = peek(4); + if (!extended) return; + length = extended.readUInt16BE(2); + offset = 4; + } else if (length === 127) { + const extended = peek(10); + if (!extended) return; + length = Number(extended.readBigUInt64BE(2)); + offset = 10; + } + const masked = (second & 0x80) !== 0; + const maskOffset = offset; + if (masked) offset += 4; + const frame = consume(offset + length); + if (!frame) return; + let payload = frame.subarray(offset, offset + length); + if (masked) { + const mask = frame.subarray(maskOffset, maskOffset + 4); + const unmasked = Buffer.allocUnsafe(payload.length); + for (let index = 0; index < payload.length; index++) { + unmasked[index] = payload[index] ^ mask[index % 4]; + } + payload = unmasked; + } + if ((header[0] & 0x0f) === 0x8) return; + if ((header[0] & 0x0f) !== 0x1) continue; + onMessage(payload.toString("utf8")); + } + }; + + const server = http.createServer(); + server.on("upgrade", (request, upgraded, head) => { + const key = String(request.headers["sec-websocket-key"] ?? ""); + const accept = crypto + .createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + upgraded.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket = upgraded; + if (head?.length) { + chunks.push(Buffer.from(head)); + buffered += head.length; + } + upgraded.on("data", (chunk: Buffer) => { + chunks.push(chunk); + buffered += chunk.length; + parse(); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + return { + url: `ws://127.0.0.1:${address.port}`, + commands, + emit, + close() { + socket?.destroy(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function checkoutUrl(placeholderForm: string): string { + return `https://synthuser:synthpass@checkout.example.test:8443/s/synthopaquesession/pay/${placeholderForm}?session=synthqueryvalue&token=synthtoken#synthfragment`; +} + +function loggerFor( + warnings: { + message: string; + fields?: Readonly>; + }[], +): PayLogger { + return { + warn(message, fields) { + warnings.push({ message, fields }); + }, + }; +} + +function assertNoMarkers(value: unknown): void { + const json = JSON.stringify(value); + for (const marker of MARKERS) expect(json).not.toContain(marker); + expect(textHoldsPlaceholderNumber(json, HELD.number)).toBe(false); +} + +function emitPage(stub: { emit: (event: Record) => void }) { + stub.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); +} + +function emitPause( + stub: { emit: (event: Record) => void }, + requestId: string, + url: string, + body = BODY, +): void { + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId, + request: { + url, + method: "POST", + headers: { "content-type": "application/json" }, + postData: body, + }, + }, + }); +} + +async function waitFor(predicate: () => boolean, label: string): Promise { + const deadline = Date.now() + 3_000; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${label}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function success(): RelayResponse { + return { + outcome: "relayed", + response: { + status: 201, + headers: { "content-type": "application/json" }, + body: '{"ok":true}', + }, + }; +} + +function attachOptions( + relay: ReturnType, + logger?: PayLogger, +) { + return { + relay, + placeholder: HELD, + profileId: "synthetic-profile", + ...(logger ? { logger } : {}), + }; +} + +async function captureError(promise: Promise): Promise { + try { + await promise; + } catch (error) { + if (error instanceof Error) return error; + throw new Error("expected an Error rejection"); + } + throw new Error("expected the promise to reject"); +} + +function assertSafeError(error: Error): void { + const values = [ + error.message, + String(error), + JSON.stringify(error, Object.getOwnPropertyNames(error)), + ]; + for (const value of values) { + for (const marker of MARKERS) expect(value).not.toContain(marker); + expect(textHoldsPlaceholderNumber(value, HELD.number)).toBe(false); + } +} + +describe("interceptor event redaction", () => { + it("emits only origins for every held URL form and relays the raw request unchanged", async () => { + const stub = await startCdp(); + const events: InterceptorEvent[] = []; + const calls: RelayRequest[] = []; + const warnings: { + message: string; + fields?: Readonly>; + }[] = []; + try { + const handle = await attachInterceptor( + stub.url, + attachOptions( + stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + loggerFor(warnings), + ), + ); + handle.on((event) => events.push(event)); + emitPage(stub); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + + for (let index = 0; index < PLACEHOLDER_FORMS.length; index++) { + const rawUrl = checkoutUrl(PLACEHOLDER_FORMS[index]); + emitPause(stub, `req-${index}`, rawUrl); + await waitFor( + () => + events.filter((event) => event.type === "relayed").length === + index + 1, + `relayed ${index + 1}`, + ); + expect(calls[index].request.url).toBe(rawUrl); + expect(calls[index].request.body).toBe(BODY); + expect(calls[index].idempotencyKey).toBe( + crypto + .createHash("sha256") + .update(`POST\0${rawUrl}\0${BODY}`) + .digest("hex"), + ); + } + + expect(events).toContainEqual({ + type: "paused", + url: ORIGIN, + method: "POST", + targetType: "page", + }); + expect(events).toContainEqual({ + type: "relayed", + url: ORIGIN, + status: 201, + }); + expect(calls).toHaveLength(PLACEHOLDER_FORMS.length); + assertNoMarkers(events); + assertNoMarkers(warnings); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("preserves known refusal reasons and sanitizes custom reasons in events and logs", async () => { + const stub = await startCdp(); + const events: InterceptorEvent[] = []; + const warnings: { + message: string; + fields?: Readonly>; + }[] = []; + let calls = 0; + try { + const handle = await attachInterceptor( + stub.url, + attachOptions( + stubPaymentRelay(async () => { + calls += 1; + return calls === 1 + ? { outcome: "refused", reason: "total_changed" } + : { + outcome: "refused", + reason: "bad synthtoken https://x/?session=synthqueryvalue", + }; + }), + loggerFor(warnings), + ), + ); + handle.on((event) => events.push(event)); + emitPage(stub); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + + emitPause(stub, "known-refusal", checkoutUrl(PLACEHOLDER_FORMS[1])); + await waitFor( + () => events.filter((event) => event.type === "refused").length === 1, + "known refusal", + ); + emitPause(stub, "custom-refusal", checkoutUrl(PLACEHOLDER_FORMS[2])); + await waitFor( + () => events.filter((event) => event.type === "refused").length === 2, + "custom refusal", + ); + + expect(events).toContainEqual({ + type: "refused", + url: ORIGIN, + reason: "total_changed", + }); + expect(events).toContainEqual({ + type: "refused", + url: ORIGIN, + reason: "unrecognized", + }); + expect(warnings.map((warning) => warning.fields)).toEqual([ + { reason: "total_changed" }, + { reason: "unrecognized" }, + ]); + assertNoMarkers(events); + assertNoMarkers(warnings); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("maps custom relay failures to network and suppresses reposts after unknown", async () => { + const stub = await startCdp(); + const events: InterceptorEvent[] = []; + const calls: RelayRequest[] = []; + try { + const handle = await attachInterceptor( + stub.url, + attachOptions( + stubPaymentRelay(async (request) => { + calls.push(request); + if (calls.length === 1) { + throw new Error( + `boom synthtoken ${checkoutUrl(PLACEHOLDER_FORMS[0])}`, + ); + } + return { outcome: "unknown" }; + }), + ), + ); + handle.on((event) => events.push(event)); + emitPage(stub); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + + const failureUrl = checkoutUrl(PLACEHOLDER_FORMS[3]); + emitPause(stub, "network-failure", failureUrl); + await waitFor( + () => events.some((event) => event.type === "failed"), + "network failure", + ); + expect(events).toContainEqual({ + type: "failed", + url: ORIGIN, + reason: "network", + }); + + const unknownUrl = checkoutUrl(PLACEHOLDER_FORMS[4]); + emitPause(stub, "unknown-once", unknownUrl); + await waitFor( + () => + events.filter( + (event) => event.type === "failed" && event.reason === "unknown", + ).length === 1, + "unknown outcome", + ); + emitPause(stub, "unknown-twice", unknownUrl); + await waitFor( + () => + events.filter( + (event) => event.type === "failed" && event.reason === "unknown", + ).length === 2, + "suppressed unknown outcome", + ); + expect(calls).toHaveLength(2); + expect(events).toContainEqual({ + type: "failed", + url: ORIGIN, + reason: "unknown", + }); + assertNoMarkers(events); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("uses origin-only URLs for untracked sessions and opaque or invalid inputs", async () => { + const stub = await startCdp(); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor( + stub.url, + attachOptions(stubPaymentRelay(async () => success())), + ); + handle.on((event) => events.push(event)); + + const urls = [ + checkoutUrl(PLACEHOLDER_FORMS[0]), + "https://4242424242424242.example.test/x", + "about:blank", + "not a URL", + ]; + for (let index = 0; index < urls.length; index++) { + stub.emit({ + method: "Fetch.requestPaused", + sessionId: `untracked-${index}`, + params: { + requestId: `req-${index}`, + request: { url: urls[index], method: "GET" }, + }, + }); + } + await waitFor(() => events.length === urls.length, "unknown sessions"); + + expect(events).toEqual([ + { + type: "failed", + url: ORIGIN, + reason: "unknown_session", + }, + { + type: "failed", + url: "https://••••.example.test", + reason: "unknown_session", + }, + { type: "failed", url: "about:", reason: "unknown_session" }, + { type: "failed", url: "", reason: "unknown_session" }, + ]); + assertNoMarkers(events); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("delivers a buffered intercept_unavailable event with only the target origin", async () => { + const targetUrl = checkoutUrl(PLACEHOLDER_FORMS[0]); + const stub = await startCdp({ + failFetch: true, + attachOnAutoAttach: { targetId: "target-sensitive", url: targetUrl }, + }); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor( + stub.url, + attachOptions(stubPaymentRelay(async () => success())), + ); + handle.on((event) => events.push(event)); + expect(events).toEqual([ + { + type: "failed", + url: ORIGIN, + reason: "intercept_unavailable", + }, + ]); + assertNoMarkers(events); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("uses origin-only URLs for relayed_unfulfilled events", async () => { + const stub = await startCdp({ failFulfill: true }); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor( + stub.url, + attachOptions(stubPaymentRelay(async () => success())), + ); + handle.on((event) => events.push(event)); + emitPage(stub); + await waitFor( + () => + stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + emitPause(stub, "unfulfilled", checkoutUrl(PLACEHOLDER_FORMS[0])); + await waitFor( + () => events.some((event) => event.type === "relayed_unfulfilled"), + "relayed_unfulfilled", + ); + expect(events).toContainEqual({ + type: "relayed_unfulfilled", + url: ORIGIN, + status: 201, + }); + assertNoMarkers(events); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("does not expose invalid URLs, transport errors, or CDP error text", async () => { + const invalidUrlError = await captureError( + attachInterceptor( + "ws://synthuser:synthpass@[bad", + attachOptions(stubPaymentRelay(async () => success())), + ), + ); + expect(invalidUrlError.message).toBe("CDP websocket URL is invalid"); + expect(Object.prototype.hasOwnProperty.call(invalidUrlError, "input")).toBe( + false, + ); + assertSafeError(invalidUrlError); + + const closed = http.createServer(); + await new Promise((resolve) => { + closed.listen(0, "127.0.0.1", resolve); + }); + const closedPort = (closed.address() as AddressInfo).port; + await new Promise((resolve) => closed.close(() => resolve())); + const connectError = await captureError( + attachInterceptor( + `ws://127.0.0.1:${closedPort}/devtools/browser/synthopaquesession?token=synthtoken`, + attachOptions(stubPaymentRelay(async () => success())), + ), + ); + expect(connectError.message).toBe("CDP websocket connect failed"); + assertSafeError(connectError); + + const stub = await startCdp({ + errorOnMethod: { + method: "Target.setAutoAttach", + message: "synthtoken synthfragment synthqueryvalue synthopaquesession", + }, + }); + try { + const protocolError = await captureError( + attachInterceptor( + stub.url, + attachOptions(stubPaymentRelay(async () => success())), + ), + ); + expect(protocolError.message).toBe("CDP Target.setAutoAttach failed"); + assertSafeError(protocolError); + } finally { + await stub.close(); + } + }); +}); diff --git a/packages/browser-pay/src/interceptor.integration.test.ts b/packages/browser-pay/src/interceptor.integration.test.ts index 3681c5a33..4e00afc96 100644 --- a/packages/browser-pay/src/interceptor.integration.test.ts +++ b/packages/browser-pay/src/interceptor.integration.test.ts @@ -463,7 +463,7 @@ if (!chromeBin) { expect(events.map((event) => event.type)).toEqual(["paused", "refused"]); expect(events[1]).toEqual({ type: "refused", - url: `http://127.0.0.1:${card.port}/sessions`, + url: `http://127.0.0.1:${card.port}`, reason: "already_relayed", }); expect(JSON.stringify(events)).not.toContain(PLACEHOLDER.number); diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 7187b7dda..f5ee02dc4 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -310,7 +310,7 @@ describe("interceptor CDP decisions", () => { placeholder: HELD, profileId: "shopify-card-vault", }), - ).rejects.toThrow("CDP auto-attach refused"); + ).rejects.toThrow("CDP Target.setAutoAttach failed"); await waitFor(() => stub.sawClose(), "websocket close"); } finally { await stub.close(); @@ -346,7 +346,7 @@ describe("interceptor CDP decisions", () => { ); expect(events).toContainEqual({ type: "failed", - url: "https://shop.example/checkout", + url: "https://shop.example", reason: "intercept_unavailable", }); await handle.detach(); @@ -1557,7 +1557,7 @@ describe("interceptor CDP decisions", () => { handle.on((event) => events.push(event)); expect(events).toContainEqual({ type: "failed", - url: "https://checkout.example/card", + url: "https://checkout.example", reason: "intercept_unavailable", }); await handle.detach(); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index c15d16918..f62b4758b 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -56,6 +56,12 @@ export type FailureReason = | "network" | "response_credential"; +/** + * Event `url` is the request origin (`scheme://host[:port]`) with any held + * placeholder masked, `""` when unparseable, or the scheme (for example, + * `about:`) for opaque origins. Path, query, fragment, and userinfo are never + * included. + */ export type InterceptorEvent = | { type: "paused"; url: string; method: string; targetType: string } | { type: "relayed"; url: string; status: number } @@ -84,9 +90,41 @@ const DEFAULT_TIMEOUT_MS = 55_000; const RELAY_POLL_INITIAL_MS = 200; const RELAY_POLL_MAX_MS = 2_000; +const KNOWN_REFUSAL_REASONS = new Set([ + "unknown_injection", + "injection_expired", + "payment_released", + "key_rotated", + "already_relayed", + "relay_in_progress", + "card_removed", + "payment_not_authorized", + "total_changed", + "destination_not_allowed", + "card_fields_invalid", + "merchant_mismatch", + "provider_unavailable", +]); + const TARGET_FILTER = [{ type: "page" }, { type: "iframe" }]; const CHILD_FILTER = [{ type: "iframe" }]; +function eventUrl(value: string, number: string): string { + let origin = ""; + try { + const parsed = new URL(value); + origin = parsed.origin !== "null" ? parsed.origin : parsed.protocol; + } catch { + return ""; + } + const redacted = redactHeld(origin, number); + return textHoldsPlaceholderNumber(redacted, number) ? "" : redacted; +} + +function publicReason(reason: string): string { + return KNOWN_REFUSAL_REASONS.has(reason) ? reason : "unrecognized"; +} + /** GET/HEAD with no body and no held number in the URL. Safe to continue immediately. */ function canContinueWithoutReading( method: string, @@ -385,7 +423,7 @@ class PaymentInterceptor { if (this.closed) return false; this.emit({ type: "failed", - url: this.safe(session.url || session.targetId), + url: eventUrl(session.url, this.number), reason: "intercept_unavailable", }); await this.connection @@ -461,7 +499,7 @@ class PaymentInterceptor { void failRequest(this.connection, sessionId, requestId); this.emit({ type: "failed", - url: this.safe(request.url), + url: eventUrl(request.url, this.number), reason: "unknown_session", }); return; @@ -493,20 +531,22 @@ class PaymentInterceptor { entry: Inflight, request: PausedRequest, ): Promise { - const safeUrl = this.safe(request.url); try { - const decision = await this.decide(entry, request, safeUrl); - await this.apply(entry, safeUrl, decision); + const decision = await this.decide(entry, request); + await this.apply(entry, request.url, decision); } catch { await this.settle(entry, "fail"); - this.emit({ type: "failed", url: safeUrl, reason: "network" }); + this.emit({ + type: "failed", + url: eventUrl(request.url, this.number), + reason: "network", + }); } } private async decide( entry: Inflight, request: PausedRequest, - safeUrl: string, ): Promise { const read = await this.readHeld(entry.sessionId, request); if (read.kind === "unreadable") { @@ -517,7 +557,7 @@ class PaymentInterceptor { const targetType = this.sessions.get(entry.sessionId)?.type ?? ""; this.emit({ type: "paused", - url: safeUrl, + url: eventUrl(request.url, this.number), method: request.method, targetType, }); @@ -684,35 +724,42 @@ class PaymentInterceptor { private logRefusalOnce(idempotencyKey: string, reason: string): void { if (this.loggedRefusals.has(idempotencyKey)) return; this.loggedRefusals.add(idempotencyKey); - this.logger.warn("payment relay refused the paused request", { reason }); + this.logger.warn("payment relay refused the paused request", { + reason: publicReason(reason), + }); } private async apply( entry: Inflight, - safeUrl: string, + requestUrl: string, decision: Decision, ): Promise { + const url = eventUrl(requestUrl, this.number); if (decision.action === "continue") { await this.settle(entry, "continue"); return; } if (decision.action === "fail") { await this.settle(entry, "fail"); - this.emit({ type: "failed", url: safeUrl, reason: decision.reason }); + this.emit({ type: "failed", url, reason: decision.reason }); return; } if (decision.action === "refused") { await this.settle(entry, "fail"); - this.emit({ type: "refused", url: safeUrl, reason: decision.reason }); + this.emit({ + type: "refused", + url, + reason: publicReason(decision.reason), + }); return; } const fulfilled = await this.settle(entry, { fulfill: decision.response }); this.emit( fulfilled - ? { type: "relayed", url: safeUrl, status: decision.response.status } + ? { type: "relayed", url, status: decision.response.status } : { type: "relayed_unfulfilled", - url: safeUrl, + url, status: decision.response.status, }, ); @@ -848,10 +895,6 @@ class PaymentInterceptor { // A listener must not break interception. } } - - private safe(value: string): string { - return redactHeld(value, this.number); - } } function inflightKey(sessionId: string, requestId: string): string { From bca36b6429aee6acfce4dcc2fb88e5e3c76a6292 Mon Sep 17 00:00:00 2001 From: brett Date: Tue, 20 Oct 2026 22:48:10 +0000 Subject: [PATCH 2/3] fix(browser-pay): use generic CDP command error text Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/browser-pay/README.md | 2 +- packages/browser-pay/src/cdp.ts | 12 ++---- .../src/interceptor.event-redaction.test.ts | 39 ++++++++++++++++++- .../src/interceptor.protocol.test.ts | 2 +- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index 549b59895..1c55c8ca0 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -55,7 +55,7 @@ Refusal, nothing forwarded: The `refused` event and logger pass through only these known reasons: `unknown_injection`, `injection_expired`, `payment_released`, `key_rotated`, `already_relayed`, `relay_in_progress`, `card_removed`, `payment_not_authorized`, `total_changed`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`. Any other reason becomes `unrecognized`. `relay_in_progress` is not terminal. The paused request stays open and the same idempotency key is posted again until the outcome is terminal or the deadline passes. -Event `url` is origin-only (`scheme://host[:port]`), with any held placeholder masked. An opaque origin uses its scheme (for example, `about:`); an unparseable URL is `""`. Paths, queries, fragments, and userinfo are never included. Attach errors use fixed messages and do not include transport details. +Event `url` is origin-only (`scheme://host[:port]`), with any held placeholder masked. An opaque origin uses its scheme (for example, `about:`); an unparseable URL is `""`. Paths, queries, fragments, and userinfo are never included. Attach errors use fixed messages and do not include transport details. CDP command failures and timeouts use the generic messages `CDP command failed` and `CDP command timed out`. No answer after the send: diff --git a/packages/browser-pay/src/cdp.ts b/packages/browser-pay/src/cdp.ts index 8b31fee62..66449b501 100644 --- a/packages/browser-pay/src/cdp.ts +++ b/packages/browser-pay/src/cdp.ts @@ -18,7 +18,6 @@ export interface CdpEvent { } type Pending = { - method: string; resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; @@ -345,7 +344,7 @@ export class CdpConnection { this.pending.delete(message.id); clearTimeout(pending.timer); if (message.errorMessage) { - pending.reject(new Error(`CDP ${pending.method} failed`)); + pending.reject(new Error("CDP command failed")); } else { pending.resolve(message.result); } @@ -396,10 +395,9 @@ export class CdpConnection { } const timer = setTimeout(() => { this.pending.delete(id); - reject(new Error(`CDP ${method} timed out`)); + reject(new Error("CDP command timed out")); }, timeoutMs); this.pending.set(id, { - method, resolve, reject, timer, @@ -413,12 +411,10 @@ export class CdpConnection { ...(sessionId ? { sessionId } : {}), }), ); - } catch (error) { + } catch { clearTimeout(timer); this.pending.delete(id); - reject( - error instanceof Error ? error : new Error("CDP connection closed"), - ); + reject(new Error("CDP connection closed")); } }); } diff --git a/packages/browser-pay/src/interceptor.event-redaction.test.ts b/packages/browser-pay/src/interceptor.event-redaction.test.ts index b774279e7..c017fffae 100644 --- a/packages/browser-pay/src/interceptor.event-redaction.test.ts +++ b/packages/browser-pay/src/interceptor.event-redaction.test.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import http from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; +import { CdpConnection } from "./cdp.js"; import { textHoldsPlaceholderNumber } from "./detect"; import type { PayLogger } from "./logger"; import { attachInterceptor, type InterceptorEvent } from "./interceptor"; @@ -45,6 +46,7 @@ interface StubOptions { failFetch?: boolean; failFulfill?: boolean; errorOnMethod?: { method: string; message: string }; + noReplyOnMethod?: string; attachOnAutoAttach?: { targetId: string; url: string }; } @@ -144,6 +146,7 @@ async function startCdp(options: StubOptions = {}): Promise<{ reply(message.id, undefined, { message: options.errorOnMethod.message }); return; } + if (options.noReplyOnMethod === message.method) return; if ( message.method === "Target.setAutoAttach" && !message.sessionId && @@ -722,10 +725,44 @@ describe("interceptor event redaction", () => { attachOptions(stubPaymentRelay(async () => success())), ), ); - expect(protocolError.message).toBe("CDP Target.setAutoAttach failed"); + expect(protocolError.message).toBe("CDP command failed"); assertSafeError(protocolError); } finally { await stub.close(); } }); + + it("uses generic errors for secret-bearing CDP methods", async () => { + const method = "Synthetic.method?token=SYNTHETIC_METHOD_SECRET_1"; + const errorStub = await startCdp({ + errorOnMethod: { + method, + message: "SYNTHETIC_METHOD_SECRET_1 peer rejected command", + }, + }); + const errorConnection = await CdpConnection.connect(errorStub.url); + try { + const error = await captureError( + errorConnection.send(method, {}, undefined, 1_000), + ); + expect(error.message).toBe("CDP command failed"); + expect(error.message).not.toContain("SYNTHETIC_METHOD_SECRET"); + } finally { + errorConnection.close(); + await errorStub.close(); + } + + const timeoutStub = await startCdp({ noReplyOnMethod: method }); + const timeoutConnection = await CdpConnection.connect(timeoutStub.url); + try { + const error = await captureError( + timeoutConnection.send(method, {}, undefined, 10), + ); + expect(error.message).toBe("CDP command timed out"); + expect(error.message).not.toContain("SYNTHETIC_METHOD_SECRET"); + } finally { + timeoutConnection.close(); + await timeoutStub.close(); + } + }); }); diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index f5ee02dc4..22884240b 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -310,7 +310,7 @@ describe("interceptor CDP decisions", () => { placeholder: HELD, profileId: "shopify-card-vault", }), - ).rejects.toThrow("CDP Target.setAutoAttach failed"); + ).rejects.toThrow("CDP command failed"); await waitFor(() => stub.sawClose(), "websocket close"); } finally { await stub.close(); From 6e222e0b64ec57a01c40540fd83beaf5a2ccc4a9 Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Tue, 20 Oct 2026 15:58:45 -0700 Subject: [PATCH 3/3] refactor(browser-pay): sanitize event URLs and refusal reasons in one place - Cut event URLs to the origin inside emit() instead of at each call site, so no event can leave with a path, query, or held number. - Normalize refusal reasons once in decisionForOutcome and type them as the exported RefusalReason. - Drop the remote CDP error text in parseCdpMessage so it never reaches a caller. - Tidy the event-redaction tests: shared attach helper, reuse paymentIdempotencyKey, remove unreachable stub code, and split the combined error test. Co-Authored-By: Claude Opus 5.5 --- .changeset/browser-pay-event-redaction.md | 2 +- packages/browser-pay/src/cdp.ts | 13 +- packages/browser-pay/src/index.ts | 1 + .../src/interceptor.event-redaction.test.ts | 241 +++++++----------- packages/browser-pay/src/interceptor.ts | 93 +++---- 5 files changed, 151 insertions(+), 199 deletions(-) diff --git a/.changeset/browser-pay-event-redaction.md b/.changeset/browser-pay-event-redaction.md index 63b64100a..f33c02153 100644 --- a/.changeset/browser-pay-event-redaction.md +++ b/.changeset/browser-pay-event-redaction.md @@ -2,4 +2,4 @@ "@sapiom/browser-pay": minor --- -Interceptor event URLs now expose only the request origin, and refusal reasons are restricted to known relay values. CDP attach errors use fixed messages to avoid forwarding transport details. +Interceptor event URLs now expose only the request origin, and refusal reasons are restricted to known relay values (exported as the `RefusalReason` type). CDP attach errors use fixed messages to avoid forwarding transport details. diff --git a/packages/browser-pay/src/cdp.ts b/packages/browser-pay/src/cdp.ts index 66449b501..f8ae53cc9 100644 --- a/packages/browser-pay/src/cdp.ts +++ b/packages/browser-pay/src/cdp.ts @@ -30,7 +30,7 @@ function parseCdpMessage(value: unknown): params: Record; sessionId?: string; result?: unknown; - errorMessage?: string; + failed?: true; } | undefined { if (!isRecord(value)) return undefined; @@ -40,19 +40,14 @@ function parseCdpMessage(value: unknown): const params = isRecord(value.params) ? value.params : {}; const sessionId = typeof value.sessionId === "string" ? value.sessionId : undefined; - let errorMessage: string | undefined; - if (isRecord(value.error) && typeof value.error.message === "string") { - errorMessage = value.error.message; - } else if (value.error) { - errorMessage = "CDP call failed"; - } + // The remote error text is dropped here so it can never reach a caller. return { ...(id !== undefined ? { id } : {}), ...(method !== undefined ? { method } : {}), params, ...(sessionId ? { sessionId } : {}), result: value.result, - ...(errorMessage ? { errorMessage } : {}), + ...(value.error ? { failed: true as const } : {}), }; } @@ -343,7 +338,7 @@ export class CdpConnection { if (pending && message.id !== undefined) { this.pending.delete(message.id); clearTimeout(pending.timer); - if (message.errorMessage) { + if (message.failed) { pending.reject(new Error("CDP command failed")); } else { pending.resolve(message.result); diff --git a/packages/browser-pay/src/index.ts b/packages/browser-pay/src/index.ts index 71a4769b8..fcd581d19 100644 --- a/packages/browser-pay/src/index.ts +++ b/packages/browser-pay/src/index.ts @@ -5,6 +5,7 @@ export type { InterceptorEvent, InterceptorHandle, InterceptorTarget, + RefusalReason, } from "./interceptor.js"; export { httpPaymentRelay } from "./relay.js"; diff --git a/packages/browser-pay/src/interceptor.event-redaction.test.ts b/packages/browser-pay/src/interceptor.event-redaction.test.ts index c017fffae..1464c3cfd 100644 --- a/packages/browser-pay/src/interceptor.event-redaction.test.ts +++ b/packages/browser-pay/src/interceptor.event-redaction.test.ts @@ -5,8 +5,16 @@ import type { Duplex } from "node:stream"; import { CdpConnection } from "./cdp.js"; import { textHoldsPlaceholderNumber } from "./detect"; import type { PayLogger } from "./logger"; -import { attachInterceptor, type InterceptorEvent } from "./interceptor"; -import type { RelayRequest, RelayResponse } from "./contract"; +import { + attachInterceptor, + type InterceptorEvent, + type InterceptorHandle, +} from "./interceptor"; +import { + paymentIdempotencyKey, + type RelayRequest, + type RelayResponse, +} from "./contract"; import { stubPaymentRelay } from "../test/stub-relay"; const HELD = { @@ -55,20 +63,15 @@ function serverFrame(text: string): Buffer { if (payload.length < 126) { return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); } - if (payload.length < 65536) { - const header = Buffer.alloc(4); - header[0] = 0x81; - header[1] = 126; - header.writeUInt16BE(payload.length, 2); - return Buffer.concat([header, payload]); - } - const header = Buffer.alloc(10); + const header = Buffer.alloc(4); header[0] = 0x81; - header[1] = 127; - header.writeBigUInt64BE(BigInt(payload.length), 2); + header[1] = 126; + header.writeUInt16BE(payload.length, 2); return Buffer.concat([header, payload]); } +type CdpStub = Awaited>; + async function startCdp(options: StubOptions = {}): Promise<{ url: string; commands: Command[]; @@ -179,10 +182,6 @@ async function startCdp(options: StubOptions = {}): Promise<{ reply(message.id, { targetInfos: [] }); return; } - if (message.method === "Runtime.evaluate") { - reply(message.id, {}); - return; - } reply(message.id, {}); }; @@ -198,11 +197,6 @@ async function startCdp(options: StubOptions = {}): Promise<{ if (!extended) return; length = extended.readUInt16BE(2); offset = 4; - } else if (length === 127) { - const extended = peek(10); - if (!extended) return; - length = Number(extended.readBigUInt64BE(2)); - offset = 10; } const masked = (second & 0x80) !== 0; const maskOffset = offset; @@ -265,12 +259,12 @@ function checkoutUrl(placeholderForm: string): string { return `https://synthuser:synthpass@checkout.example.test:8443/s/synthopaquesession/pay/${placeholderForm}?session=synthqueryvalue&token=synthtoken#synthfragment`; } -function loggerFor( - warnings: { - message: string; - fields?: Readonly>; - }[], -): PayLogger { +type Warning = { + message: string; + fields?: Parameters[1]; +}; + +function loggerFor(warnings: Warning[]): PayLogger { return { warn(message, fields) { warnings.push({ message, fields }); @@ -278,13 +272,16 @@ function loggerFor( }; } +function assertCleanText(text: string): void { + for (const marker of MARKERS) expect(text).not.toContain(marker); + expect(textHoldsPlaceholderNumber(text, HELD.number)).toBe(false); +} + function assertNoMarkers(value: unknown): void { - const json = JSON.stringify(value); - for (const marker of MARKERS) expect(json).not.toContain(marker); - expect(textHoldsPlaceholderNumber(json, HELD.number)).toBe(false); + assertCleanText(JSON.stringify(value)); } -function emitPage(stub: { emit: (event: Record) => void }) { +function emitPage(stub: CdpStub) { stub.emit({ method: "Target.attachedToTarget", params: { @@ -299,12 +296,7 @@ function emitPage(stub: { emit: (event: Record) => void }) { }); } -function emitPause( - stub: { emit: (event: Record) => void }, - requestId: string, - url: string, - body = BODY, -): void { +function emitPause(stub: CdpStub, requestId: string, url: string): void { stub.emit({ method: "Fetch.requestPaused", sessionId: "page-1", @@ -314,7 +306,7 @@ function emitPause( url, method: "POST", headers: { "content-type": "application/json" }, - postData: body, + postData: BODY, }, }, }); @@ -353,6 +345,26 @@ function attachOptions( }; } +/** Attaches, records events, and waits until the page session is armed. */ +async function attachArmed( + stub: CdpStub, + relay: ReturnType, + logger?: PayLogger, +): Promise<{ handle: InterceptorHandle; events: InterceptorEvent[] }> { + const events: InterceptorEvent[] = []; + const handle = await attachInterceptor( + stub.url, + attachOptions(relay, logger), + ); + handle.on((event) => events.push(event)); + emitPage(stub); + await waitFor( + () => stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + return { handle, events }; +} + async function captureError(promise: Promise): Promise { try { await promise; @@ -369,38 +381,22 @@ function assertSafeError(error: Error): void { String(error), JSON.stringify(error, Object.getOwnPropertyNames(error)), ]; - for (const value of values) { - for (const marker of MARKERS) expect(value).not.toContain(marker); - expect(textHoldsPlaceholderNumber(value, HELD.number)).toBe(false); - } + for (const value of values) assertCleanText(value); } describe("interceptor event redaction", () => { it("emits only origins for every held URL form and relays the raw request unchanged", async () => { const stub = await startCdp(); - const events: InterceptorEvent[] = []; const calls: RelayRequest[] = []; - const warnings: { - message: string; - fields?: Readonly>; - }[] = []; + const warnings: Warning[] = []; try { - const handle = await attachInterceptor( - stub.url, - attachOptions( - stubPaymentRelay(async (request) => { - calls.push(request); - return success(); - }), - loggerFor(warnings), - ), - ); - handle.on((event) => events.push(event)); - emitPage(stub); - await waitFor( - () => - stub.commands.some((command) => command.method === "Fetch.enable"), - "Fetch.enable", + const { handle, events } = await attachArmed( + stub, + stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + loggerFor(warnings), ); for (let index = 0; index < PLACEHOLDER_FORMS.length; index++) { @@ -415,10 +411,7 @@ describe("interceptor event redaction", () => { expect(calls[index].request.url).toBe(rawUrl); expect(calls[index].request.body).toBe(BODY); expect(calls[index].idempotencyKey).toBe( - crypto - .createHash("sha256") - .update(`POST\0${rawUrl}\0${BODY}`) - .digest("hex"), + paymentIdempotencyKey("POST", rawUrl, BODY), ); } @@ -444,34 +437,21 @@ describe("interceptor event redaction", () => { it("preserves known refusal reasons and sanitizes custom reasons in events and logs", async () => { const stub = await startCdp(); - const events: InterceptorEvent[] = []; - const warnings: { - message: string; - fields?: Readonly>; - }[] = []; + const warnings: Warning[] = []; let calls = 0; try { - const handle = await attachInterceptor( - stub.url, - attachOptions( - stubPaymentRelay(async () => { - calls += 1; - return calls === 1 - ? { outcome: "refused", reason: "total_changed" } - : { - outcome: "refused", - reason: "bad synthtoken https://x/?session=synthqueryvalue", - }; - }), - loggerFor(warnings), - ), - ); - handle.on((event) => events.push(event)); - emitPage(stub); - await waitFor( - () => - stub.commands.some((command) => command.method === "Fetch.enable"), - "Fetch.enable", + const { handle, events } = await attachArmed( + stub, + stubPaymentRelay(async () => { + calls += 1; + return calls === 1 + ? { outcome: "refused", reason: "total_changed" } + : { + outcome: "refused", + reason: "bad synthtoken https://x/?session=synthqueryvalue", + }; + }), + loggerFor(warnings), ); emitPause(stub, "known-refusal", checkoutUrl(PLACEHOLDER_FORMS[1])); @@ -507,35 +487,24 @@ describe("interceptor event redaction", () => { } }); - it("maps custom relay failures to network and suppresses reposts after unknown", async () => { + it("uses origin-only URLs for network and unknown failures", async () => { const stub = await startCdp(); - const events: InterceptorEvent[] = []; - const calls: RelayRequest[] = []; + let calls = 0; try { - const handle = await attachInterceptor( - stub.url, - attachOptions( - stubPaymentRelay(async (request) => { - calls.push(request); - if (calls.length === 1) { - throw new Error( - `boom synthtoken ${checkoutUrl(PLACEHOLDER_FORMS[0])}`, - ); - } - return { outcome: "unknown" }; - }), - ), - ); - handle.on((event) => events.push(event)); - emitPage(stub); - await waitFor( - () => - stub.commands.some((command) => command.method === "Fetch.enable"), - "Fetch.enable", + const { handle, events } = await attachArmed( + stub, + stubPaymentRelay(async () => { + calls += 1; + if (calls === 1) { + throw new Error( + `boom synthtoken ${checkoutUrl(PLACEHOLDER_FORMS[0])}`, + ); + } + return { outcome: "unknown" }; + }), ); - const failureUrl = checkoutUrl(PLACEHOLDER_FORMS[3]); - emitPause(stub, "network-failure", failureUrl); + emitPause(stub, "network-failure", checkoutUrl(PLACEHOLDER_FORMS[3])); await waitFor( () => events.some((event) => event.type === "failed"), "network failure", @@ -546,24 +515,14 @@ describe("interceptor event redaction", () => { reason: "network", }); - const unknownUrl = checkoutUrl(PLACEHOLDER_FORMS[4]); - emitPause(stub, "unknown-once", unknownUrl); + emitPause(stub, "unknown", checkoutUrl(PLACEHOLDER_FORMS[4])); await waitFor( () => - events.filter( + events.some( (event) => event.type === "failed" && event.reason === "unknown", - ).length === 1, + ), "unknown outcome", ); - emitPause(stub, "unknown-twice", unknownUrl); - await waitFor( - () => - events.filter( - (event) => event.type === "failed" && event.reason === "unknown", - ).length === 2, - "suppressed unknown outcome", - ); - expect(calls).toHaveLength(2); expect(events).toContainEqual({ type: "failed", url: ORIGIN, @@ -654,18 +613,10 @@ describe("interceptor event redaction", () => { it("uses origin-only URLs for relayed_unfulfilled events", async () => { const stub = await startCdp({ failFulfill: true }); - const events: InterceptorEvent[] = []; try { - const handle = await attachInterceptor( - stub.url, - attachOptions(stubPaymentRelay(async () => success())), - ); - handle.on((event) => events.push(event)); - emitPage(stub); - await waitFor( - () => - stub.commands.some((command) => command.method === "Fetch.enable"), - "Fetch.enable", + const { handle, events } = await attachArmed( + stub, + stubPaymentRelay(async () => success()), ); emitPause(stub, "unfulfilled", checkoutUrl(PLACEHOLDER_FORMS[0])); await waitFor( @@ -684,7 +635,7 @@ describe("interceptor event redaction", () => { } }); - it("does not expose invalid URLs, transport errors, or CDP error text", async () => { + it("does not expose an invalid CDP websocket URL", async () => { const invalidUrlError = await captureError( attachInterceptor( "ws://synthuser:synthpass@[bad", @@ -696,7 +647,9 @@ describe("interceptor event redaction", () => { false, ); assertSafeError(invalidUrlError); + }); + it("does not expose transport details when the connect fails", async () => { const closed = http.createServer(); await new Promise((resolve) => { closed.listen(0, "127.0.0.1", resolve); @@ -711,7 +664,9 @@ describe("interceptor event redaction", () => { ); expect(connectError.message).toBe("CDP websocket connect failed"); assertSafeError(connectError); + }); + it("does not expose CDP error text", async () => { const stub = await startCdp({ errorOnMethod: { method: "Target.setAutoAttach", diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index f62b4758b..cb435f73c 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -56,6 +56,27 @@ export type FailureReason = | "network" | "response_credential"; +const KNOWN_REFUSAL_REASONS = [ + "unknown_injection", + "injection_expired", + "payment_released", + "key_rotated", + "already_relayed", + "relay_in_progress", + "card_removed", + "payment_not_authorized", + "total_changed", + "destination_not_allowed", + "card_fields_invalid", + "merchant_mismatch", + "provider_unavailable", +] as const; + +/** A relay refusal reason. Any reason outside the known set is `unrecognized`. */ +export type RefusalReason = + | (typeof KNOWN_REFUSAL_REASONS)[number] + | "unrecognized"; + /** * Event `url` is the request origin (`scheme://host[:port]`) with any held * placeholder masked, `""` when unparseable, or the scheme (for example, @@ -66,7 +87,7 @@ export type InterceptorEvent = | { type: "paused"; url: string; method: string; targetType: string } | { type: "relayed"; url: string; status: number } | { type: "relayed_unfulfilled"; url: string; status: number } - | { type: "refused"; url: string; reason: string } + | { type: "refused"; url: string; reason: RefusalReason } | { type: "failed"; url: string; reason: FailureReason }; export interface InterceptorHandle { @@ -90,39 +111,26 @@ const DEFAULT_TIMEOUT_MS = 55_000; const RELAY_POLL_INITIAL_MS = 200; const RELAY_POLL_MAX_MS = 2_000; -const KNOWN_REFUSAL_REASONS = new Set([ - "unknown_injection", - "injection_expired", - "payment_released", - "key_rotated", - "already_relayed", - "relay_in_progress", - "card_removed", - "payment_not_authorized", - "total_changed", - "destination_not_allowed", - "card_fields_invalid", - "merchant_mismatch", - "provider_unavailable", -]); - const TARGET_FILTER = [{ type: "page" }, { type: "iframe" }]; const CHILD_FILTER = [{ type: "iframe" }]; function eventUrl(value: string, number: string): string { - let origin = ""; + let parsed: URL; try { - const parsed = new URL(value); - origin = parsed.origin !== "null" ? parsed.origin : parsed.protocol; + parsed = new URL(value); } catch { return ""; } + const origin = parsed.origin !== "null" ? parsed.origin : parsed.protocol; const redacted = redactHeld(origin, number); + // Fail closed if masking ever leaves a held number behind. return textHoldsPlaceholderNumber(redacted, number) ? "" : redacted; } -function publicReason(reason: string): string { - return KNOWN_REFUSAL_REASONS.has(reason) ? reason : "unrecognized"; +function publicReason(reason: string): RefusalReason { + return (KNOWN_REFUSAL_REASONS as readonly string[]).includes(reason) + ? (reason as RefusalReason) + : "unrecognized"; } /** GET/HEAD with no body and no held number in the URL. Safe to continue immediately. */ @@ -240,7 +248,7 @@ type HeldRead = type Decision = | { action: "continue" } | { action: "fail"; reason: FailureReason } - | { action: "refused"; reason: string } + | { action: "refused"; reason: RefusalReason } | { action: "relayed"; response: RelayedHttpResponse }; type SettleAction = "continue" | "fail" | { fulfill: RelayedHttpResponse }; @@ -423,7 +431,7 @@ class PaymentInterceptor { if (this.closed) return false; this.emit({ type: "failed", - url: eventUrl(session.url, this.number), + url: session.url, reason: "intercept_unavailable", }); await this.connection @@ -499,7 +507,7 @@ class PaymentInterceptor { void failRequest(this.connection, sessionId, requestId); this.emit({ type: "failed", - url: eventUrl(request.url, this.number), + url: request.url, reason: "unknown_session", }); return; @@ -536,11 +544,7 @@ class PaymentInterceptor { await this.apply(entry, request.url, decision); } catch { await this.settle(entry, "fail"); - this.emit({ - type: "failed", - url: eventUrl(request.url, this.number), - reason: "network", - }); + this.emit({ type: "failed", url: request.url, reason: "network" }); } } @@ -557,7 +561,7 @@ class PaymentInterceptor { const targetType = this.sessions.get(entry.sessionId)?.type ?? ""; this.emit({ type: "paused", - url: eventUrl(request.url, this.number), + url: request.url, method: request.method, targetType, }); @@ -712,8 +716,9 @@ class PaymentInterceptor { return { action: "fail", reason: "unknown" }; } if (response.outcome === "refused") { - this.logRefusalOnce(idempotencyKey, response.reason); - return { action: "refused", reason: response.reason }; + const reason = publicReason(response.reason); + this.logRefusalOnce(idempotencyKey, reason); + return { action: "refused", reason }; } if (textHoldsPlaceholderNumber(response.response.body, this.number)) { return { action: "fail", reason: "response_credential" }; @@ -721,20 +726,17 @@ class PaymentInterceptor { return { action: "relayed", response: response.response }; } - private logRefusalOnce(idempotencyKey: string, reason: string): void { + private logRefusalOnce(idempotencyKey: string, reason: RefusalReason): void { if (this.loggedRefusals.has(idempotencyKey)) return; this.loggedRefusals.add(idempotencyKey); - this.logger.warn("payment relay refused the paused request", { - reason: publicReason(reason), - }); + this.logger.warn("payment relay refused the paused request", { reason }); } private async apply( entry: Inflight, - requestUrl: string, + url: string, decision: Decision, ): Promise { - const url = eventUrl(requestUrl, this.number); if (decision.action === "continue") { await this.settle(entry, "continue"); return; @@ -746,11 +748,7 @@ class PaymentInterceptor { } if (decision.action === "refused") { await this.settle(entry, "fail"); - this.emit({ - type: "refused", - url, - reason: publicReason(decision.reason), - }); + this.emit({ type: "refused", url, reason: decision.reason }); return; } const fulfilled = await this.settle(entry, { fulfill: decision.response }); @@ -880,12 +878,15 @@ class PaymentInterceptor { }; } + // Callers pass the raw request URL. It is cut to the origin here, once, so + // no event leaves with a path, query, or held number. private emit(event: InterceptorEvent): void { + const safe = { ...event, url: eventUrl(event.url, this.number) }; if (this.buffering && this.backlog) { - this.backlog.push(event); + this.backlog.push(safe); return; } - for (const listener of this.listeners) this.deliver(listener, event); + for (const listener of this.listeners) this.deliver(listener, safe); } private deliver(listener: Listener, event: InterceptorEvent): void {