diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be4c47a..4cabbf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,11 +60,14 @@ jobs: env: TEST_REVIEW_CONSENT_MONGO: mongodb://127.0.0.1:28743/?replicaSet=review-consent-test TEST_REVIEW_CONSENT_PERF_REPORT: review-consent-perf.json - run: npx mocha test/review-owner-consent.test.js + TEST_REVIEW_CONSENT_HTTP_PERF: review-consent-http-perf.json + run: npx mocha test/review-owner-consent.test.js test/review-consent-http.test.js - uses: actions/upload-artifact@v4 with: name: review-consent-performance - path: review-consent-perf.json + path: | + review-consent-perf.json + review-consent-http-perf.json - name: Remove disposable database if: always() run: docker rm -f review-consent-test || true diff --git a/docs/review-consent-http.md b/docs/review-consent-http.md new file mode 100644 index 0000000..3415727 --- /dev/null +++ b/docs/review-consent-http.md @@ -0,0 +1,21 @@ +# Browser transport for review consent + +`createReviewConsentRouter` exposes the consent backend through three routes when explicitly mounted at `/api/review-consent`: `GET /csrf`, `POST /preview` and `POST /confirm`. Application startup does not mount this router. Browser UI, private configuration, startup ordering and activation remain unfinished. + +The factory requires a canonical HTTPS origin, the consent backend and a dedicated private 32-byte CSRF key shared by serving processes. Mount it after Passport/session authentication and before any JSON parser. The current startup ordering must change before integration; mounting after a parser fails closed. Transport must be HTTPS directly or through the application's explicitly trusted proxy configuration. + +Identity comes from Passport's authenticated account and a fresh lookup in the session store. Neither request JSON nor a bearer credential can supply it. POSTs require the exact configured Origin and a constant-time-checked CSRF token bound to the account and session. CSRF tokens are derived with a protocol-specific HMAC; issuing one never saves a session. A late CSRF response therefore cannot recreate a session destroyed by a concurrent logout. Authority is reloaded after backend work before returning a preview or confirmation response. + +Requests use exact paths without queries, strict methods, closed JSON fields and literal access/retention acceptance. Cross-origin or cross-site context, bearer authentication, duplicate security headers, content encoding and unexpected content types are rejected. The CSRF GET accepts no body. JSON is limited to 12 KiB; tickets to 8 KiB. A 15-second total deadline closes incomplete requests, and browser disconnects cancel pending preview consumption. Authenticated requests are limited to 60 per account per minute with at most 1,024 live counters. + +Responses disable caching and referrers. Confirmations include only the request ID, accepted policy and confirmation time. Internal account/repository identifiers and provider/database errors are omitted. A confirmation already accepted by the backend can commit despite a lost response; the UI must preserve the same ticket and request ID for explicit recovery. This transport does not create artifact bindings or bypass final review-side authority checks. + +## Validation + +The tests use real Express and express-session middleware with a disposable in-memory session store, a synthetic backend and loopback HTTP representing the trusted TLS proxy boundary. They do not measure real MongoDB or provider latency. TLS verification of the service client and MongoDB authority transactions are tested separately. + +Coverage includes session-derived identity, CSRF replay across sessions, cross-origin/bearer rejection, bounded closed payloads, exact routes, session revocation during preview, no session resurrection during a CSRF/logout race, response redaction, fixed errors, rate limits, unauthorized incomplete uploads, browser cancellation and the authenticated 15-second slow-upload deadline. The optional benchmark performs 50 fresh loopback HTTP preview requests. The CI database job runs these HTTP tests alongside the consent transaction tests and retains both performance reports. + +The final local targeted run passes 12 cases in 16 seconds. Transport-only timings are mean 4.94 ms, median 4.51 ms and p95 7.26 ms. TypeScript and targeted lint pass. + +The final local broader suite passes 723 cases with 63 opt-in/environment-dependent cases pending. The separate CI replica-set job exercises the otherwise skipped consent database tests as well as this transport suite. diff --git a/docs/review-owner-consent.md b/docs/review-owner-consent.md index 7dc9105..d323a8a 100644 --- a/docs/review-owner-consent.md +++ b/docs/review-owner-consent.md @@ -20,4 +20,4 @@ The opt-in suite runs against a disposable MongoDB replica set. It covers litera Run `TEST_REVIEW_CONSENT_MONGO='mongodb://127.0.0.1:28743/?replicaSet=review-consent-test' npx mocha test/review-owner-consent.test.js`. Set `TEST_REVIEW_CONSENT_PERF_REPORT=/tmp/review-consent-perf.json` to include 100 durable receipt replays. The CI job starts and removes its own pinned MongoDB image and preserves the performance report. Local timings are synthetic and are not a production latency guarantee. -Local validation passes 12 targeted cases with the benchmark enabled, 723 full-suite cases with 51 opt-in/environment-dependent cases pending, the full TypeScript check and targeted lint. The final 100-replay run measured mean 12.85 ms, median 12.82 ms and p95 14.89 ms against a one-CPU, 512 MiB disposable database. An earlier performance attempt encountered a database conflict while the privacy fixture automatically built indexes; the final fixture disables automatic schema/index creation and creates its collections explicitly. +Local validation passes 12 targeted cases with the benchmark enabled, 723 full-suite cases with 51 opt-in/environment-dependent cases pending, the full TypeScript check and targeted lint. The final 100-replay run measured mean 12.85 ms, median 12.82 ms and p95 14.89 ms against a one-CPU, 512 MiB disposable database. An earlier performance attempt returned `unavailable` while the privacy fixture was automatically building indexes. The log does not establish the underlying database error. The final fixture disables automatic schema/index creation and creates its collections explicitly. diff --git a/src/server/service/review-consent-http.ts b/src/server/service/review-consent-http.ts new file mode 100644 index 0000000..262a067 --- /dev/null +++ b/src/server/service/review-consent-http.ts @@ -0,0 +1,308 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import * as express from "express"; +import { Request, Response } from "express"; +import { createReviewOwnerConsent } from "./review-owner-consent"; + +type BrowserSession = { passport?: { user?: unknown } }; +type Context = { accountId: string; sessionId: string }; +const opaque = /^[a-f0-9]{32}$/; +const secret = /^[a-f0-9]{64}$/; +function fields( + value: unknown, + names: string[], +): value is Record { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).sort().join(",") === names.sort().join(",") + ); +} +function reject(res: Response, status: number, code: string) { + if (!res.headersSent && !res.destroyed) res.setHeader("Connection", "close"); + if (!res.headersSent && !res.destroyed) + res.status(status).json({ error: { code } }); +} +async function current(req: Request): Promise { + const id = (req.user as { user?: { _id?: unknown } } | undefined)?.user?._id; + const accountId = id === undefined ? "" : String(id); + if ( + !req.isAuthenticated?.() || + !/^[a-f0-9]{24}$/.test(accountId) || + !/^[A-Za-z0-9_-]{24,256}$/.test(req.sessionID || "") + ) + return undefined; + const session = await new Promise( + (resolve, fail) => + req.sessionStore.get(req.sessionID, (error, value) => + error + ? fail(new Error("Session unavailable")) + : resolve(value as BrowserSession | undefined), + ), + ); + if (session?.passport?.user !== accountId) return undefined; + return { accountId, sessionId: req.sessionID }; +} + +/** Mount after current Passport/session authentication and BEFORE any JSON + * parser. This factory is not registered by application startup. TLS must be + * established directly or identified through the configured trusted proxy. */ +export function createReviewConsentRouter( + origin: string, + backend: ReturnType, + csrfKey: Buffer, +) { + try { + if (new URL(origin).origin !== origin || !origin.startsWith("https://")) + throw new Error(); + } catch { + throw new Error("Invalid review consent origin"); + } + if (!Buffer.isBuffer(csrfKey) || csrfKey.length !== 32) + throw new Error("Invalid review consent CSRF key"); + const key = Buffer.from(csrfKey); + const csrf = (actor: Context) => + createHmac("sha256", key) + .update( + "4open.review-consent-csrf/1." + + actor.accountId + + "." + + actor.sessionId, + ) + .digest(); + const router = express.Router({ strict: true, caseSensitive: true }); + const rates = new Map(); + router.use((req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.removeHeader("Access-Control-Allow-Origin"); + if (!["/csrf", "/preview", "/confirm"].includes(req.url)) + return reject(res, 404, "not-found"); + if (req.method !== (req.url === "/csrf" ? "GET" : "POST")) + return reject(res, 405, "invalid-request"); + if ( + req.url === "/csrf" && + (req.headers["transfer-encoding"] !== undefined || + (req.headers["content-length"] !== undefined && + req.headers["content-length"] !== "0")) + ) + return reject(res, 400, "invalid-request"); + if ( + !req.secure || + req.headers.authorization !== undefined || + req.headers["content-encoding"] !== undefined + ) + return reject(res, 403, "forbidden"); + for (const name of [ + "origin", + "content-type", + "x-review-csrf", + "sec-fetch-site", + ]) { + if ( + req.rawHeaders.filter((v, i) => i % 2 === 0 && v.toLowerCase() === name) + .length > 1 + ) + return reject(res, 403, "forbidden"); + } + if ( + (req.headers.origin !== undefined && req.headers.origin !== origin) || + (req.method === "POST" && req.headers.origin !== origin) || + (req.headers["sec-fetch-site"] !== undefined && + req.headers["sec-fetch-site"] !== "same-origin") + ) + return reject(res, 403, "forbidden"); + if ( + req.method === "POST" && + !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test( + req.headers["content-type"] || "", + ) + ) + return reject(res, 415, "invalid-request"); + // Fail closed if mounted after a body parser that bypassed our byte limit. + if (req.body !== undefined) return reject(res, 503, "unavailable"); + const controller = new AbortController(); + res.locals.consentSignal = controller.signal; + const timer = setTimeout(() => { + controller.abort(); + reject(res, 408, "expired"); + req.destroy(); + }, 15000); + const done = () => { + clearTimeout(timer); + controller.abort(); + }; + res.once("close", done); + res.once("finish", done); + next(); + }); + router.use(async (req, res, next) => { + try { + const actor = await current(req); + if (res.destroyed || res.headersSent || res.locals.consentSignal.aborted) + return; + if (!actor) return reject(res, 401, "unauthorized"); + if (req.method === "POST") { + const supplied = req.headers["x-review-csrf"]; + if ( + typeof supplied !== "string" || + !secret.test(supplied) || + !timingSafeEqual(Buffer.from(supplied, "hex"), csrf(actor)) + ) + return reject(res, 403, "forbidden"); + } + const now = Date.now(); + for (const [id, rate] of rates) if (rate.until <= now) rates.delete(id); + const rate = rates.get(actor.accountId) || { + until: now + 60000, + count: 0, + }; + if ( + rate.count >= 60 || + (!rates.has(actor.accountId) && rates.size >= 1024) + ) { + res.setHeader("Retry-After", "60"); + return reject(res, 429, "rate-limited"); + } + rate.count++; + rates.set(actor.accountId, rate); + res.locals.consentActor = actor; + next(); + } catch { + reject(res, 503, "unavailable"); + } + }); + router.get("/csrf", async (req, res) => { + try { + const fresh = await current(req); + if (!fresh || fresh.accountId !== res.locals.consentActor.accountId) + return reject(res, 401, "unauthorized"); + // Derive without saving the session: a late CSRF response must never + // resurrect a session that a concurrent logout has destroyed. + res.json({ csrf: csrf(fresh).toString("hex") }); + } catch { + reject(res, 503, "unavailable"); + } + }); + router.use(express.json({ limit: 12288, strict: true, inflate: false })); + router.post(["/preview", "/confirm"], async (req, res) => { + const actor = res.locals.consentActor as Context; + try { + let result: unknown; + if (req.url === "/preview") { + if ( + !fields(req.body, ["repositoryId", "intent"]) || + typeof req.body.repositoryId !== "string" || + !/^[A-Za-z0-9_-]{3,128}$/.test(req.body.repositoryId) || + !fields(req.body.intent, [ + "contract", + "clientId", + "intentId", + "token", + "requestId", + ]) + ) + return reject(res, 422, "invalid-request"); + const input = req.body.intent; + if ( + input.contract !== "4open.artifacts/1" || + ![input.clientId, input.intentId, input.requestId].every( + (v) => typeof v === "string" && opaque.test(v), + ) || + typeof input.token !== "string" || + !secret.test(input.token) + ) + return reject(res, 422, "invalid-request"); + const preview = await backend.preview( + actor, + req.body.repositoryId, + { + contract: input.contract, + clientId: input.clientId as string, + intentId: input.intentId as string, + requestId: input.requestId as string, + token: input.token, + }, + res.locals.consentSignal, + ); + result = { + ticket: preview.ticket, + repositoryName: preview.repositoryName, + policy: { + version: preview.policy.version, + access: preview.policy.access, + retainUntil: preview.policy.retainUntil, + }, + expiresAt: preview.expiresAt, + }; + } else { + if ( + !fields(req.body, [ + "ticket", + "requestId", + "acceptAccess", + "acceptRetention", + ]) || + typeof req.body.ticket !== "string" || + req.body.ticket.length > 8192 || + typeof req.body.requestId !== "string" || + !opaque.test(req.body.requestId) || + req.body.acceptAccess !== true || + req.body.acceptRetention !== true + ) + return reject(res, 422, "invalid-request"); + const saved = await backend.confirm(actor, req.body.ticket, { + requestId: req.body.requestId, + acceptAccess: true, + acceptRetention: true, + }); + result = { + requestId: saved.requestId, + policy: { + version: saved.policy.version, + access: saved.policy.access, + retainUntil: saved.policy.retainUntil, + }, + confirmedAt: saved.confirmedAt, + }; + } + const fresh = await current(req); + if ( + !fresh || + fresh.accountId !== actor.accountId || + fresh.sessionId !== actor.sessionId + ) + return reject(res, 401, "unauthorized"); + if (!res.headersSent && !res.destroyed) res.json(result); + } catch (error) { + const kind = (error as { kind?: string })?.kind; + const status = + kind === "invalid" || kind === "protocol" + ? 422 + : kind === "forbidden" || kind === "rejected" + ? 403 + : kind === "expired" + ? 410 + : kind === "conflict" + ? 409 + : 503; + reject( + res, + status, + status === 503 + ? "unavailable" + : status === 422 + ? "invalid-request" + : kind!, + ); + } + }); + router.use(((error, _req, res, _next) => + reject( + res, + error?.status === 413 ? 413 : 400, + "invalid-request", + )) as express.ErrorRequestHandler); + return router; +} diff --git a/test/review-consent-http.test.js b/test/review-consent-http.test.js new file mode 100644 index 0000000..896b348 --- /dev/null +++ b/test/review-consent-http.test.js @@ -0,0 +1,503 @@ +require("ts-node/register/transpile-only"); +const { expect } = require("chai"); +const express = require("express"); +const session = require("express-session"); +const http = require("http"); +const { randomBytes } = require("crypto"); +const { + createReviewConsentRouter, +} = require("../src/server/service/review-consent-http"); +const origin = "https://anonymous.example.test"; +const owner = "a".repeat(24); +const intent = { + contract: "4open.artifacts/1", + clientId: "1".repeat(32), + intentId: "2".repeat(32), + token: "3".repeat(64), + requestId: "4".repeat(32), +}; +const previewInput = () => ({ repositoryId: "synthetic-repository", intent }); +const confirmInput = () => ({ + ticket: "synthetic-signed-ticket", + requestId: "5".repeat(32), + acceptAccess: true, + acceptRetention: true, +}); +const policy = { + version: 1, + access: "restricted-review", + retainUntil: "2099-01-01T00:00:00Z", +}; + +describe("review consent browser HTTP transport", function () { + this.timeout(20000); + let server, store, cookie, sid, csrf, backend, calls; + function request(path, options = {}) { + const body = + options.raw === undefined + ? options.body === undefined + ? undefined + : JSON.stringify(options.body) + : options.raw; + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port: server.address().port, + path, + method: options.method || (body === undefined ? "GET" : "POST"), + headers: { + "X-Forwarded-Proto": "https", + ...(cookie ? { Cookie: cookie } : {}), + ...(body === undefined + ? {} + : { + Origin: origin, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + "X-Review-CSRF": csrf || "", + }), + ...options.headers, + }, + }, + (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString(); + resolve({ + status: res.statusCode, + headers: res.headers, + body: raw ? JSON.parse(raw) : null, + }); + }); + }, + ); + req.on("error", reject); + if (body !== undefined) req.write(body); + req.end(); + }); + } + beforeEach(async () => { + cookie = ""; + sid = ""; + csrf = ""; + calls = []; + store = new session.MemoryStore(); + backend = { + preview: async (actor, repository, input) => { + calls.push({ actor, repository, input }); + return { + ticket: "synthetic-signed-ticket", + repositoryName: repository, + policy, + expiresAt: "2098-01-01T00:00:00Z", + }; + }, + confirm: async (actor, ticket, input) => { + calls.push({ actor, ticket, input }); + return { + clientId: intent.clientId, + intentId: intent.intentId, + accountId: actor.accountId, + repositoryId: "b".repeat(24), + role: "owner", + requestId: input.requestId, + policy, + confirmedAt: "2026-01-01T00:00:00Z", + }; + }, + }; + const app = express(); + // Model a TLS-terminating trusted loopback proxy, never trust arbitrary peers. + app.set("trust proxy", "loopback"); + app.use( + session({ + secret: "synthetic-local-session-secret", + resave: false, + saveUninitialized: false, + store, + }), + ); + // Test-only login stands in for Passport's existing authenticated session. + app.get("/test-login", (req, res) => { + req.session.passport = { user: owner }; + req.session.save((error) => + error ? res.sendStatus(500) : res.json({ sid: req.sessionID }), + ); + }); + app.use((req, _res, next) => { + const id = req.session.passport?.user; + if (id) req.user = { user: { _id: id } }; + req.isAuthenticated = () => !!id; + next(); + }); + app.use( + "/api/review-consent", + createReviewConsentRouter(origin, backend, randomBytes(32)), + ); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const login = await request("/test-login"); + cookie = login.headers["set-cookie"][0].split(";")[0]; + sid = login.body.sid; + const issued = await request("/api/review-consent/csrf"); + expect(issued.status).to.equal(200); + csrf = issued.body.csrf; + }); + afterEach(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + store.clear(); + }); + it("binds CSRF to the current Passport session and omits owner identifiers from confirmation responses", async () => { + const preview = await request("/api/review-consent/preview", { + body: previewInput(), + }); + expect(preview.status).to.equal(200); + expect(calls[0].actor).to.deep.equal({ accountId: owner, sessionId: sid }); + expect(calls[0].input).to.deep.equal(intent); + expect(preview.headers["cache-control"]).to.equal("no-store"); + expect(preview.headers["referrer-policy"]).to.equal("no-referrer"); + expect(preview.headers["access-control-allow-origin"]).to.equal(undefined); + const result = await request("/api/review-consent/confirm", { + body: confirmInput(), + }); + expect(result.status).to.equal(200); + expect(result.body).to.deep.equal({ + requestId: "5".repeat(32), + policy, + confirmedAt: "2026-01-01T00:00:00Z", + }); + expect(JSON.stringify(result.body)).not.to.include(owner); + }); + it("rejects insecure, cross-origin, bearer and missing-CSRF requests before backend work", async () => { + for (const headers of [ + { "X-Forwarded-Proto": "http" }, + { Origin: "https://attacker.example" }, + { Origin: "null" }, + { Authorization: "Bearer synthetic" }, + { "X-Review-CSRF": "0".repeat(64) }, + { "X-Review-CSRF": "" }, + { "Sec-Fetch-Site": "same-site" }, + { "Content-Encoding": "gzip" }, + ]) + expect( + ( + await request("/api/review-consent/preview", { + body: previewInput(), + headers, + }) + ).status, + ).to.equal(403); + expect( + ( + await request("/api/review-consent/preview", { + body: previewInput(), + headers: { Cookie: "" }, + }) + ).status, + ).to.equal(401); + expect(calls).to.have.length(0); + }); + it("accepts only bounded JSON with the exact operation fields and explicit acceptance", async () => { + for (const input of [ + { ...previewInput(), accountId: owner }, + { ...previewInput(), policy }, + { ...previewInput(), repositoryId: "../other" }, + { ...previewInput(), intent: { ...intent, requestId: "bad" } }, + ]) + expect( + (await request("/api/review-consent/preview", { body: input })).status, + ).to.equal(422); + for (const input of [ + { ...confirmInput(), acceptAccess: false }, + { ...confirmInput(), acceptRetention: "true" }, + { ...confirmInput(), policy }, + ]) + expect( + (await request("/api/review-consent/confirm", { body: input })).status, + ).to.equal(422); + expect( + (await request("/api/review-consent/preview", { raw: "{" })).status, + ).to.equal(400); + expect( + (await request("/api/review-consent/preview", { raw: " ".repeat(13000) })) + .status, + ).to.equal(413); + expect( + ( + await request("/api/review-consent/preview", { + body: previewInput(), + headers: { "Content-Type": "text/plain" }, + }) + ).status, + ).to.equal(415); + for (const path of ["/csrf?x=1", "/csrf/", "/CSRF", "/%63srf", "/other"]) + expect((await request("/api/review-consent" + path)).status).to.equal( + 404, + ); + expect((await request("/api/review-consent/preview")).status).to.equal(405); + expect( + (await request("/api/review-consent/csrf", { method: "GET", body: {} })) + .status, + ).to.equal(400); + expect(calls).to.have.length(0); + }); + it("rejects a token copied from another session of the same account", async () => { + const old = csrf; + cookie = ""; + const login = await request("/test-login"); + cookie = login.headers["set-cookie"][0].split(";")[0]; + expect( + ( + await request("/api/review-consent/preview", { + body: previewInput(), + headers: { "X-Review-CSRF": old }, + }) + ).status, + ).to.equal(403); + expect(calls).to.have.length(0); + }); + it("reloads session authority before returning a pending policy preview", async () => { + let arrived, release; + const ready = new Promise((resolve) => { + arrived = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + backend.preview = async () => { + arrived(); + await gate; + return { + ticket: "private-policy-preview", + policy, + repositoryName: "synthetic-repository", + expiresAt: "2098-01-01T00:00:00Z", + }; + }; + const pending = request("/api/review-consent/preview", { + body: previewInput(), + }); + await ready; + await new Promise((resolve) => store.destroy(sid, resolve)); + release(); + const result = await pending; + expect(result.status).to.equal(401); + expect(JSON.stringify(result.body)).not.to.include( + "private-policy-preview", + ); + }); + it("never rewrites or resurrects a session when a CSRF read races with logout", async () => { + const original = store.get.bind(store); + let reads = 0, + arrived, + release; + const ready = new Promise((resolve) => { + arrived = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + store.get = (id, callback) => + original(id, (error, value) => { + if (++reads === 3) { + arrived(); + gate.then(() => callback(error, value)); + } else callback(error, value); + }); + let writes = 0; + const set = store.set.bind(store); + store.set = (...args) => { + writes++; + return set(...args); + }; + const pending = request("/api/review-consent/csrf"); + await ready; + await new Promise((resolve) => store.destroy(sid, resolve)); + release(); + await pending; + expect(writes).to.equal(0); + expect( + await new Promise((resolve) => + original(sid, (_error, value) => resolve(value)), + ), + ).to.equal(undefined); + expect( + (await request("/api/review-consent/preview", { body: previewInput() })) + .status, + ).to.equal(401); + expect(calls).to.have.length(0); + }); + it("returns fixed failure codes without exposing provider or database messages", async () => { + for (const [kind, status] of [ + ["forbidden", 403], + ["conflict", 409], + ["expired", 410], + ["invalid", 422], + ["unavailable", 503], + [undefined, 503], + ]) { + backend.preview = async () => { + const error = new Error("private credential and database details"); + error.kind = kind; + throw error; + }; + const result = await request("/api/review-consent/preview", { + body: previewInput(), + }); + expect(result.status).to.equal(status); + expect(JSON.stringify(result.body)).not.to.include("private"); + } + }); + it("bounds authenticated request rates without accepting a different client identity", async () => { + for (let n = 0; n < 59; n++) + expect((await request("/api/review-consent/csrf")).status).to.equal(200); + const response = await request("/api/review-consent/csrf"); + expect(response.status).to.equal(429); + expect(response.headers["retry-after"]).to.equal("60"); + expect(calls).to.have.length(0); + }); + it("closes an unauthorized incomplete upload without waiting for its declared body", async () => { + const started = Date.now(); + let pending; + try { + const result = await new Promise((resolve, reject) => { + pending = http.request( + { + host: "127.0.0.1", + port: server.address().port, + path: "/api/review-consent/preview", + method: "POST", + headers: { + "X-Forwarded-Proto": "https", + Origin: origin, + "Content-Type": "application/json", + "Content-Length": 10000000, + }, + }, + (response) => { + response.resume(); + response.on("end", () => + resolve({ + status: response.statusCode, + connection: response.headers.connection, + }), + ); + }, + ); + pending.on("error", reject); + pending.write("{"); + }); + expect(result).to.deep.equal({ status: 401, connection: "close" }); + expect(Date.now() - started).to.be.lessThan(2000); + expect(calls).to.have.length(0); + } finally { + pending?.destroy(); + } + }); + it("cancels a pending preview when its browser connection closes", async () => { + let entered, aborted; + const ready = new Promise((resolve) => { + entered = resolve; + }); + const cancelled = new Promise((resolve) => { + aborted = resolve; + }); + backend.preview = async (_actor, _repo, _input, signal) => { + entered(); + await new Promise((resolve) => + signal.addEventListener("abort", resolve, { once: true }), + ); + aborted(); + throw new Error("private pending request cancelled"); + }; + const body = JSON.stringify(previewInput()); + const pending = http.request({ + host: "127.0.0.1", + port: server.address().port, + path: "/api/review-consent/preview", + method: "POST", + headers: { + Cookie: cookie, + "X-Forwarded-Proto": "https", + Origin: origin, + "Content-Type": "application/json", + "X-Review-CSRF": csrf, + "Content-Length": Buffer.byteLength(body), + }, + }); + pending.on("error", () => undefined); + pending.end(body); + await ready; + pending.destroy(); + await cancelled; + }); + it("expires an authenticated incomplete upload at the total request deadline", async () => { + const started = Date.now(); + let pending; + try { + const status = await new Promise((resolve) => { + pending = http.request( + { + host: "127.0.0.1", + port: server.address().port, + path: "/api/review-consent/preview", + method: "POST", + headers: { + Cookie: cookie, + "X-Forwarded-Proto": "https", + Origin: origin, + "Content-Type": "application/json", + "Content-Length": 1000, + "X-Review-CSRF": csrf, + }, + }, + (response) => { + response.resume(); + response.on("end", () => resolve(response.statusCode)); + }, + ); + pending.on("error", () => resolve("closed")); + pending.write("{"); + }); + expect([408, "closed"]).to.include(status); + expect(Date.now() - started).to.be.within(14000, 19000); + expect(calls).to.have.length(0); + } finally { + pending?.destroy(); + } + }); + it("measures authenticated HTTP preview overhead when explicitly requested", async function () { + const process = require("process"); + if (!process.env.TEST_REVIEW_CONSENT_HTTP_PERF) this.skip(); + const times = []; + for (let n = 0; n < 50; n++) { + const start = process.hrtime.bigint(); + expect( + (await request("/api/review-consent/preview", { body: previewInput() })) + .status, + ).to.equal(200); + times.push(Number(process.hrtime.bigint() - start) / 1e6); + } + times.sort((a, b) => a - b); + expect(calls).to.have.length(50); + require("fs").writeFileSync( + process.env.TEST_REVIEW_CONSENT_HTTP_PERF, + JSON.stringify( + { + calls: times.length, + transport: "loopback HTTP with synthetic trusted-proxy TLS metadata", + sessionStore: "in-memory test store", + backend: "synthetic response, no MongoDB or provider timing", + meanMs: times.reduce((a, b) => a + b, 0) / times.length, + medianMs: times[25], + p95Ms: times[47], + }, + null, + 2, + ) + "\n", + ); + }); +});