diff --git a/src/auth/callback-page.test.ts b/src/auth/callback-page.test.ts index 695ab46f4..15f68b1f3 100644 --- a/src/auth/callback-page.test.ts +++ b/src/auth/callback-page.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; +import { + PRODUCT_GITHUB_LABEL, + PRODUCT_GITHUB_URL, + PRODUCT_SITE_LABEL, + PRODUCT_SITE_URL, +} from "../branding.js"; import { callbackPageHtml, humanizeIdentifier } from "./callback-page.js"; describe("humanizeIdentifier", () => { @@ -42,8 +48,49 @@ describe("callbackPageHtml", () => { ); }); - test("the page reaches for nothing off the machine", () => { + test("the footer links to the product site and the GitHub org", () => { const html = callbackPageHtml({ subject: "linear" }); - expect(html).not.toMatch(/https?:\/\/(?!www\.w3\.org)/); + const link = (url: string, label: string) => + `${label}`; + expect(html).toContain(link(PRODUCT_SITE_URL, PRODUCT_SITE_LABEL)); + expect(html).toContain(link(PRODUCT_GITHUB_URL, PRODUCT_GITHUB_LABEL)); }); + + test("each footer label names the destination its URL actually points at", () => { + expect(PRODUCT_SITE_URL).toContain(PRODUCT_SITE_LABEL); + expect(PRODUCT_GITHUB_URL).toContain(PRODUCT_GITHUB_LABEL); + }); + + // An allowlist rather than a shape match: an unexpected origin fails loudly + // instead of passing because it happened to be wrapped in an anchor tag. + const allowedOrigins = new Set([ + PRODUCT_SITE_URL, + PRODUCT_GITHUB_URL, + // The SVG namespace the wordmark declares; a URI, never fetched. + "http://www.w3.org/2000/svg", + ]); + + const offMachineOrigins = (html: string): readonly string[] => { + // Scheme-qualified and protocol-relative alike, since either would load. + const found = + html.match( + /(?:[a-z][a-z0-9+.-]*:)?\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+[^"'`)\s<>]*/gi, + ) ?? []; + return found.filter( + (ref) => ![...allowedOrigins].some((origin) => ref.startsWith(origin)), + ); + }; + + for (const [outcome, page] of [ + ["success", { subject: "linear" }], + ["failure", { subject: "linear", error: "access_denied" }], + ] as const) { + test(`the ${outcome} page names no off-machine origin beyond the footer links`, () => { + const html = callbackPageHtml(page); + expect(offMachineOrigins(html)).toEqual([]); + expect(html).not.toMatch( + /\b(?:fetch|XMLHttpRequest|WebSocket|EventSource|sendBeacon|importScripts)\s*\(/, + ); + }); + } }); diff --git a/src/auth/callback-page.ts b/src/auth/callback-page.ts index 652f2953e..afb6f5c52 100644 --- a/src/auth/callback-page.ts +++ b/src/auth/callback-page.ts @@ -14,7 +14,13 @@ * authorization callback. */ -import { PRODUCT_NAME } from "../branding.js"; +import { + PRODUCT_GITHUB_LABEL, + PRODUCT_GITHUB_URL, + PRODUCT_NAME, + PRODUCT_SITE_LABEL, + PRODUCT_SITE_URL, +} from "../branding.js"; /** Corbits wordmark, background layers stripped so it inherits `currentColor`. */ const WORDMARK = ``; @@ -103,7 +109,21 @@ footer { font-size: 0.75rem; letter-spacing: 0.02em; color: var(--ink-faint); + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem 0.75rem; +} +footer a { + color: var(--ink-dim); + text-decoration: none; + border-bottom: 1px solid transparent; +} +footer a:hover { + color: var(--ink); + border-bottom-color: var(--rule); } +footer .sep { color: var(--rule); } @keyframes rise { from { opacity: 0; transform: translateY(0.5rem); } to { opacity: 1; transform: none; } @@ -227,6 +247,16 @@ export function humanizeIdentifier(raw: string): string { return words.charAt(0).toUpperCase() + words.slice(1); } +/** + * A footer link, preceded by its separator. + * + * Opens in a new tab so the operator keeps the tab telling them the + * authorization finished and this window is safe to close. + */ +function footerLink(url: string, label: string): string { + return `${label}`; +} + export type CallbackPage = { /** What was being authorized: an MCP server or provider name. */ readonly subject?: string; @@ -274,7 +304,7 @@ export function callbackPageHtml(page: CallbackPage = {}): string { `
${body}
`, "