diff --git a/docs/review-consent-ui.md b/docs/review-consent-ui.md new file mode 100644 index 0000000..7de25eb --- /dev/null +++ b/docs/review-consent-ui.md @@ -0,0 +1,13 @@ +# Artifact consent page + +`/review-link` serves a separate page without the ordinary site's third-party widget or profile header. Its content security policy allows only first-party scripts and connections. The page rejects URL queries and noncanonical aliases, disables caching and referrers, and accepts only filename-shaped manifest entries. + +The handoff fragment contains exactly `clientId`, `intentId` and `token`. The application removes it from the current history entry before starting application requests or the router. Intent credentials and signed preview tickets stay in a controller closure. They are not rendered or written to browser storage. Reloading loses the handoff; users must reopen a new link from their submission. Signed-out users sign in before reopening the link. + +Owners enter the anonymous repository ID and request a preview. The backend supplies the repository name, access policy, retention deadline and expiry. Confirmation requires two separate unchecked approvals. A failed confirmation keeps the same signed ticket and random request ID for an explicit retry. Repository changes are disabled while that confirmation is uncertain. Account changes and page disposal cancel requests and discard late results. Success says that consent is recorded and that artifact linking remains incomplete. + +The page does not mount the consent API or advertise available integration capabilities. Startup configuration, final binding exchange, provider activation and a review-side link launcher remain unfinished. The consent HTTP router still needs to be mounted after authentication and before body parsing. + +Validation uses the compiled frontend bundles. Targeted tests cover fragment removal, storage, approval flags, replay identity, departure during CSRF, account changes during preview, invalid handoffs and policies, endpoint unavailability, expiry, sign-in and mismatched receipts. Server tests check canonical paths, headers and unsafe manifests. Chromium checks at 1280 and 320 pixels exercise the full synthetic form flow with CSP enabled and check overflow, external requests, script errors and WCAG A/AA rules. No live database or provider request is involved. + +Run `npm run build:ui`, then `npx mocha test/review-consent-page.test.js test/review-consent-ui.test.js`. Set `TEST_REVIEW_PAGE_PERF_REPORT` to a private output path to measure 100 isolated HTML responses over loopback HTTP. This benchmark excludes browser rendering, database operations and provider latency. diff --git a/public/partials/reviewConsent.htm b/public/partials/reviewConsent.htm new file mode 100644 index 0000000..a7f61ff --- /dev/null +++ b/public/partials/reviewConsent.htm @@ -0,0 +1,24 @@ +
+ Anonymous GitHub +

Approve artifact access

+

This records your approval as the repository owner or an authorized coauthor.

+

Sign in first, then reopen the artifact link from your submission. Sign in

+

{{ error }}

+

Consent recorded. Artifact linking is not complete. Return to your submission to check its status.

+
+ + +

Use the ID after /r/ in the anonymous repository URL.

+ +
+
+

{{ preview.repositoryName }}

+

Access: {{ preview.policy.access === 'anonymous-link' ? 'Anyone with the anonymous link' : 'Restricted review access' }}.

+

Retain until {{ preview.policy.retainUntil }}. Policy version {{ preview.policy.version }}.

+

This preview expired. Open a new artifact link from your submission.

+
+
+ +
+

Waiting for the server…

+
diff --git a/public/script/main.js b/public/script/main.js index e5e1ec1..81ea179 100644 --- a/public/script/main.js +++ b/public/script/main.js @@ -1,3 +1,4 @@ +import { takeReviewFragment } from "./review-consent.js"; import { loadLibrary } from "./lazy-assets.js"; import { createApp, h, watch, provide, inject, onBeforeUnmount } from "vue"; import { createRouter, createWebHistory, RouterView } from "vue-router"; @@ -33,6 +34,9 @@ export function safeUrl(value) { } export function mountApplication(target = "#app", options = {}) { + const isolatedConsent = !!document.querySelector('meta[name="review-consent-page"][content="1"]'); + let reviewHandoff = isolatedConsent && window.location.pathname === "/review-link" ? takeReviewFragment(window) : null; + const takeReviewHandoff = () => { const value = reviewHandoff; reviewHandoff = null; return value; }; // AngularJS links stored the repository route in the fragment. Normalize it // before web history reads the URL, replacing the entry so Back still works. if (!options.history && window.location.pathname === "/" && /^#!\/r\/[^/?#]+/.test(window.location.hash)) { @@ -53,7 +57,7 @@ export function mountApplication(target = "#app", options = {}) { const value = router.currentRoute.value.params[key]; return Array.isArray(value) ? value.join("/") : value; } }); - return { http, ...timers, location, params, window, promises, html: { trustAsHtml: value => value }, translate: (key, params) => Promise.resolve(translate(key, params)), quotaService: createQuotaService(http) }; + return { takeReviewHandoff, http, ...timers, location, params, window, promises, html: { trustAsHtml: value => value }, translate: (key, params) => Promise.resolve(translate(key, params)), quotaService: createQuotaService(http) }; } function pageComponent(definition) { return { @@ -97,7 +101,7 @@ export function mountApplication(target = "#app", options = {}) { const href = anchor.getAttribute("href"); if (href.startsWith("#")) return; const url = new URL(anchor.href, window.location.href); - if (url.origin !== window.location.origin || serverPaths.test(url.pathname)) return; + if (isolatedConsent || /^\/review-link\/?$/i.test(url.pathname) || url.origin !== window.location.origin || serverPaths.test(url.pathname)) return; event.preventDefault(); router.push(url.pathname + url.search + url.hash); $("#navbarSupportedContent.show").collapse("hide"); @@ -106,7 +110,7 @@ export function mountApplication(target = "#app", options = {}) { onBeforeUnmount(() => document.removeEventListener("click", navigate)); return () => [ iconsTemplate(root, []), - h("header", { class: "app-header" }, templates["partials/header.htm"](root, headerCache)), + isolatedConsent ? null : h("header", { class: "app-header" }, templates["partials/header.htm"](root, headerCache)), h("main", { class: "app-view align-items-stretch w-100" }, h(RouterView, null, { default: ({ Component, route }) => Component ? h(Component, { key: route.meta.preserveExplorer ? route.matched[0]?.path : route.path }) : null, })), @@ -129,6 +133,10 @@ export function mountApplication(target = "#app", options = {}) { app.directive("paper-scrollspy", paperScrollspy); app.use(router); router.beforeEach(async to => { + if ((to.path === "/review-link" && !isolatedConsent) || (isolatedConsent && to.path !== "/review-link")) { + window.location.assign(to.fullPath); + return false; + } root?.emit("routeLeave"); if (/^\/(r|repository|anonymize|pull-request-anonymize|gist-anonymize|pr|gist)(\/|$)/.test(to.path)) { await loadLibrary("markdown"); diff --git a/public/script/review-consent.js b/public/script/review-consent.js new file mode 100644 index 0000000..e58240a --- /dev/null +++ b/public/script/review-consent.js @@ -0,0 +1,72 @@ +// Read once, before the router or application requests can observe the fragment. +export function takeReviewFragment(browser) { + const fragment = browser.location.hash.slice(1); + browser.history.replaceState(null, "", "/review-link"); + if (fragment.length > 2048) return null; + const values = new URLSearchParams(fragment); + if ([...values.keys()].length !== 3 || [...values.keys()].some(key => !["clientId", "intentId", "token"].includes(key))) return null; + const clientId = values.get("clientId"), intentId = values.get("intentId"), token = values.get("token"); + if (!/^[a-f0-9]{32}$/.test(clientId || "") || !/^[a-f0-9]{32}$/.test(intentId || "") || !/^[a-f0-9]{64}$/.test(token || "")) return null; + return { contract: "4open.artifacts/1", clientId, intentId, token }; +} + +export function reviewConsentController(state, services) { + const { http, promises, window: browser } = services; + let handoff = services.takeReviewHandoff(); + let ticket, csrf, confirmId, consumeId, identity; + let active = true, expiryTimer; + const stop = promises.defer(); + const requestId = () => [...browser.crypto.getRandomValues(new Uint8Array(16))].map(byte => byte.toString(16).padStart(2, "0")).join(""); + Object.assign(state, { repositoryId: "", busy: false, preview: null, acceptAccess: false, acceptRetention: false, uncertain: false, saved: false, expired: false, error: handoff ? "" : "Open a new artifact link from your submission. This link is missing or invalid." }); + const current = () => active && state.user?.username === identity; + function dispose() { + active = false; handoff = ticket = csrf = undefined; + stop.resolve(); browser.clearTimeout(expiryTimer); + } + state.on("dispose", dispose); + state.on("routeLeave", dispose); + state.watch(() => state.user?.username, username => { + if (!identity && username) identity = username; + else if (identity && username !== identity) { + dispose(); state.preview = null; state.busy = false; + state.error = "Your account changed. Open a new artifact link from your submission."; + } + }); + const options = () => ({ timeout: stop.promise, headers: { "Content-Type": "application/json", "X-Review-CSRF": csrf } }); + const message = error => error?.status === 404 ? "Artifact linking is not available on this server." : error?.status === 401 ? "Sign in, then open a new artifact link from your submission." : error?.status === 403 ? "This account cannot approve this repository or this link." : "The request could not be completed. You can retry from this page."; + state.loadPreview = async () => { + if (!active || !handoff || state.busy || state.uncertain || state.saved || !state.user?.username) return; + identity = state.user.username; + const repositoryId = state.repositoryId.trim(); + if (!/^[A-Za-z0-9_-]{3,128}$/.test(repositoryId)) { state.error = "Enter the anonymous repository ID from its URL."; return; } + state.busy = true; state.error = ""; state.preview = null; ticket = undefined; + state.acceptAccess = state.acceptRetention = false; state.expired = false; + browser.clearTimeout(expiryTimer); + consumeId ||= requestId(); + try { + csrf = (await http.get("/api/review-consent/csrf", { timeout: stop.promise })).data.csrf; + if (!current()) return; + if (!/^[a-f0-9]{64}$/.test(csrf || "")) throw new Error("invalid_csrf"); + const result = (await http.post("/api/review-consent/preview", { repositoryId, intent: { ...handoff, requestId: consumeId } }, options())).data; + if (!current()) return; + if (!result || typeof result.ticket !== "string" || !result.ticket || result.ticket.length > 8192 || typeof result.repositoryName !== "string" || !["restricted-review", "anonymous-link"].includes(result.policy?.access) || !Number.isSafeInteger(result.policy.version) || !Number.isFinite(Date.parse(result.policy.retainUntil)) || !Number.isFinite(Date.parse(result.expiresAt)) || Date.parse(result.expiresAt) <= Date.now() || Date.parse(result.expiresAt) > Date.now() + 600000) throw new Error("invalid_preview"); + ticket = result.ticket; confirmId = requestId(); + state.preview = { repositoryName: result.repositoryName, policy: { ...result.policy }, expiresAt: result.expiresAt }; + expiryTimer = browser.setTimeout(() => { if (active) state.expired = true; }, Math.max(0, Date.parse(result.expiresAt) - Date.now())); + } catch (error) { if (current()) state.error = message(error); } + finally { if (current()) state.busy = false; } + }; + state.confirmConsent = async () => { + if (!current() || state.busy || state.saved || !ticket || !state.acceptAccess || !state.acceptRetention) return; + if (Date.parse(state.preview.expiresAt) <= Date.now()) { state.expired = true; return; } + state.busy = true; state.error = ""; + try { + const result = (await http.post("/api/review-consent/confirm", { ticket, requestId: confirmId, acceptAccess: true, acceptRetention: true }, options())).data; + if (!current()) return; + if (result?.requestId !== confirmId || !Number.isFinite(Date.parse(result.confirmedAt))) throw new Error("invalid_receipt"); + state.saved = true; state.uncertain = false; handoff = ticket = undefined; + } catch (error) { + if (current()) { state.uncertain = true; state.error = "The confirmation was not verified. Retry with the same approval, or return to your submission to check its status."; } + } finally { if (current()) state.busy = false; } + }; +} diff --git a/public/script/routes.js b/public/script/routes.js index 364fe48..c7ca3a9 100644 --- a/public/script/routes.js +++ b/public/script/routes.js @@ -1,7 +1,9 @@ +import { reviewConsentController } from "./review-consent.js"; import * as pages from "./app.js"; import * as admin from "./admin.js"; export const pageRoutes = [ +{path: "/review-link", template: "partials/reviewConsent.htm", title: "Artifact consent – Anonymous GitHub", setup: (state, services) => reviewConsentController(state, services)}, {path: "/connections", template: "partials/connections.htm", title: "GitHub connections – Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.connectionsController(state, services.http)}, {path: "/signin", template: "partials/signin.htm", title: "Sign in – Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.signinController(state, services.http)}, {path: "/", template: "partials/home.htm", title: "Anonymous GitHub – Share the code, not the author", preserveExplorer: false, setup: (state, services) => pages.homeController(state, services.http, services.location, services.window, services.timeout)}, diff --git a/public/script/templates.js b/public/script/templates.js index 6386558..f68d321 100644 --- a/public/script/templates.js +++ b/public/script/templates.js @@ -1,3 +1,4 @@ +import { render as reviewConsentTemplate } from "../partials/reviewConsent.htm"; import { render as connectionsTemplate } from "../partials/connections.htm"; import { render as signinTemplate } from "../partials/signin.htm"; import { render as template0 } from "../partials/404.htm"; @@ -27,6 +28,7 @@ import { render as template23 } from "../partials/profile.htm"; import { render as template24 } from "../partials/pullRequest.htm"; import { render as template25 } from "../partials/status.htm"; export const templates = { +"partials/reviewConsent.htm": reviewConsentTemplate, "partials/connections.htm": connectionsTemplate, "partials/signin.htm": signinTemplate, "partials/404.htm": template0, diff --git a/src/server/index.ts b/src/server/index.ts index c134c97..cd44437 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -43,6 +43,9 @@ import { getAnonymizationPoolStats } from "../core/anonymization-pool"; import { createReviewCapabilities } from "./service/review-capabilities"; +import { createReviewConsentPage, isReviewConsentPagePath } from "./review-consent-page"; + +const reviewConsentPage = createReviewConsentPage(); const logger = createLogger("server"); // Lazily build the templated index.html on first request so the server @@ -80,6 +83,7 @@ function getIndexHtml(): string { } function indexResponse(req: express.Request, res: express.Response) { + if (isReviewConsentPagePath(req.path)) return reviewConsentPage(req, res); if ( req.path.startsWith("/script") || req.path.startsWith("/css") || diff --git a/src/server/review-consent-page.ts b/src/server/review-consent-page.ts new file mode 100644 index 0000000..62e15c0 --- /dev/null +++ b/src/server/review-consent-page.ts @@ -0,0 +1,58 @@ +import { Request, Response } from "express"; +import { existsSync, readFileSync } from "fs"; +import { resolve } from "path"; + +// Reserve aliases too: they must never fall through to the ordinary page, +// which loads third-party scripts. Only the canonical URL serves this page. +export function isReviewConsentPagePath(path: string): boolean { + try { + return ( + decodeURIComponent(path).toLowerCase().replace(/\/+$/, "") === + "/review-link" + ); + } catch { + return false; + } +} + +export function createReviewConsentPage( + manifestPath = resolve("public", "asset-manifest.json"), +) { + return (req: Request, res: Response): void => { + res.set({ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Content-Security-Policy": + "default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + }); + if ( + req.originalUrl !== "/review-link" || + (req.method !== "GET" && req.method !== "HEAD") + ) { + res.status(404).json({ error: "not_found" }); + return; + } + let manifest: Record = {}; + try { + if (existsSync(manifestPath)) { + const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) + manifest = parsed as Record; + } + } catch { + // A missing or malformed manifest uses the ordinary unhashed assets. + } + const asset = (name: string): string => { + const value = manifest[name]; + const extension = name.endsWith(".js") ? "js" : "css"; + return typeof value === "string" && + new RegExp(`^[A-Za-z0-9][A-Za-z0-9_.-]*\\.${extension}$`).test(value) + ? value + : name; + }; + res.type("html").send(` +Artifact consent · Anonymous GitHub
`); + }; +} diff --git a/test/review-consent-page.test.js b/test/review-consent-page.test.js new file mode 100644 index 0000000..2dffd37 --- /dev/null +++ b/test/review-consent-page.test.js @@ -0,0 +1,127 @@ +require("ts-node/register/transpile-only"); +const { expect } = require("chai"); +const express = require("express"); +const http = require("http"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { + createReviewConsentPage, + isReviewConsentPagePath, +} = require("../src/server/review-consent-page"); + +describe("isolated review consent page", function () { + let server, directory, manifest; + beforeEach(async function () { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "review-page-")); + manifest = path.join(directory, "manifest.json"); + const app = express(); + const page = createReviewConsentPage(manifest); + app.use((req, res) => + isReviewConsentPagePath(req.path) + ? page(req, res) + : res.status(418).end(), + ); + server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + }); + afterEach(async function () { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(directory, { recursive: true, force: true }); + }); + function get(url, method = "GET") { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port: server.address().port, + path: url, + method, + }, + (res) => { + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => + resolve({ status: res.statusCode, headers: res.headers, body }), + ); + }, + ); + req.on("error", reject); + req.end(); + }); + } + it("serves only first-party assets with no inline scripts or external frames", async function () { + const response = await get("/review-link"); + expect(response.status).equal(200); + expect(response.headers["cache-control"]).equal("no-store"); + expect(response.headers["referrer-policy"]).equal("no-referrer"); + expect(response.headers["x-frame-options"]).equal("DENY"); + expect(response.headers["content-security-policy"]) + .include("script-src 'self'") + .include("frame-ancestors 'none'"); + expect(response.body).include('name="review-consent-page"'); + expect(response.body).not.match(/https?:|kofi|ko-fi|/i); + expect(response.body.match(/