diff --git a/.github/extensions/og-preview/README.md b/.github/extensions/og-preview/README.md index fac3d789d..9cfc22913 100644 --- a/.github/extensions/og-preview/README.md +++ b/.github/extensions/og-preview/README.md @@ -40,6 +40,36 @@ port) that serves the static UI from `ui/` and a JSON API: The target page is fetched and parsed server-side (no external dependencies), which sidesteps browser CORS and lets it reach `localhost`. +## Network access and isolation + +Select a localhost URL in the address form or through a canvas action to authorize +that exact origin (scheme, hostname, and port). Links and resources discovered in +the page cannot authorize another local origin. Public-to-local redirects are +blocked, even if the local origin was previously selected. + +Private-network destinations beyond loopback require `OG_ALLOW_PRIVATE_NETWORK=1` +in addition to explicit selection. This setting does not grant arbitrary private +access to fetched pages. Selecting a different origin invalidates the previous +browse capability; resources on additional local ports must be previewed separately. + +Every connection validates all DNS answers and pins the validated addresses to +the request, including redirect hops. IPv4-mapped IPv6 addresses are checked +against the same policy as IPv4. The original hostname remains in use for HTTP +Host and TLS certificate verification. + +The host-provided canvas URL contains a private UI capability in its fragment. +The renderer uses it for API calls and events; do not share that URL. Sandboxed +pages receive a separate, revocable resource-only capability and cannot select +origins or invoke session/issue actions. Public errors omit exception details. +The browse frame continues to run scripts without `allow-same-origin`; module +import rewriting is a preview transform, not an HTML sanitizer. + +Run the dependency-free regression suite with Node.js 24: + +```powershell +node --test .github\extensions\og-preview\tests\*.test.mjs +``` + ## Agent actions & tools - **`open_og_preview`** `{ url?, instanceId? }` *(tool)* — open or focus the diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index 01083e909..077b10d31 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -15,7 +15,8 @@ import { extname } from "node:path"; import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; -import { fetchUrl, normalizeUrl } from "./lib/http-fetch.mjs"; +import { fetchUrl, normalizeUrl, authorizePreview } from "./lib/http-fetch.mjs"; +import { createAccess, requestAccess } from "./lib/request-access.mjs"; import { parseMetadata } from "./lib/parse-og.mjs"; import { checkAgentReadiness } from "./lib/agent-readiness.mjs"; @@ -74,9 +75,9 @@ async function serveAsset(res, name) { } /** Fetch a target URL and parse its OpenGraph metadata. */ -async function loadMetadata(rawUrl) { +async function loadMetadata(rawUrl, policy) { const target = normalizeUrl(rawUrl); - const result = await fetchUrl(target); + const result = await fetchUrl(target, policy); if (result.status >= 400) { throw new Error(`Target responded with HTTP ${result.status}.`); } @@ -90,6 +91,14 @@ async function loadMetadata(rawUrl) { return data; } +async function selectPreview(entry, url) { + const policy = await authorizePreview(url); + entry.policy = policy; + // Previously rendered content must not inherit a later local selection. + entry.browseToken = createAccess().browseToken; + return policy; +} + function sendJson(res, status, obj) { res.statusCode = status; res.setHeader("Content-Type", "application/json; charset=utf-8"); @@ -353,7 +362,7 @@ function rewriteBrowseDoc(html, finalUrl, appOrigin) { ); // Inline (no src) → rewrite import specifiers - out = out.replace(/]*)>([\s\S]*?)<\/script>/gi, (m, attrs, body) => { + out = out.replace(/])((?:[^"'<>]|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/script(?=[\t\n\f\r />])(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi, (m, attrs, body) => { if (/\ssrc\s*=/i.test(attrs)) return m; // external handled above if (!/type\s*=\s*["']?module/i.test(attrs)) return m; // classic scripts unaffected return `${rewriteJs(body, finalUrl, appOrigin)}`; @@ -439,7 +448,8 @@ function broadcast(entry, payload) { async function handleRequest(entry, req, res) { const reqUrl = new URL(req.url, "http://127.0.0.1"); - const path = reqUrl.pathname; + let path = reqUrl.pathname; + res.setHeader("Referrer-Policy", "no-referrer"); if (path === "/" || path === "/index.html") { return serveAsset(res, "index.html"); @@ -448,6 +458,17 @@ async function handleRequest(entry, req, res) { return serveAsset(res, path.slice(1)); } + const access = requestAccess(entry, req, reqUrl); + if (!access) return sendJson(res, 403, { error: "Preview access denied." }); + path = access.path; + if (path !== "/api/open-session" && path !== "/api/create-issue" && req.method !== "GET") { + return sendJson(res, 405, { error: "Use GET." }); + } + // A request keeps its authorization snapshot even when another selection + // replaces the active origin while a fetch/redirect is in flight. + let policy = entry.policy; + const proxyOrigin = new URL(entry.url).origin + `/browse/${entry.browseToken}`; + if (path === "/events") { res.writeHead(200, { "Content-Type": "text/event-stream", @@ -469,15 +490,19 @@ async function handleRequest(entry, req, res) { // user out of the Browse tab on every in-page navigation. const silent = reqUrl.searchParams.get("silent") === "1"; try { - const data = await loadMetadata(u); + if (reqUrl.searchParams.get("select") === "1") { + policy = await selectPreview(entry, u); + } + const data = await loadMetadata(u, policy); entry.currentUrl = data.requestedUrl; sendJson(res, 200, data); // Refresh the host panel title to the resolved URL (fire-and-forget; // guarded against loops by syncTitle). if (!silent) syncTitle(entry, data.requestedUrl).catch(() => {}); return; - } catch (err) { - return sendJson(res, 200, { error: err.message }); + } catch { + log("OG Viewer: metadata request failed.", "warning"); + return sendJson(res, 200, { error: "Couldn't load metadata. Check the URL and selected-origin access." }); } } @@ -486,10 +511,11 @@ async function handleRequest(entry, req, res) { if (!u) return sendJson(res, 400, { error: "Missing 'u' query parameter." }); try { const target = normalizeUrl(u); - const report = await checkAgentReadiness(target); + const report = await checkAgentReadiness(target, policy); return sendJson(res, 200, report); - } catch (err) { - return sendJson(res, 200, { error: err.message }); + } catch { + log("OG Viewer: agent-readiness request failed.", "warning"); + return sendJson(res, 200, { error: "Couldn't check agent readiness." }); } } @@ -500,7 +526,7 @@ async function handleRequest(entry, req, res) { return res.end("Missing 'u'"); } try { - const img = await fetchUrl(u, { accept: "image/*,*/*;q=0.8", timeoutMs: 12000 }); + const img = await fetchUrl(u, { ...policy, accept: "image/*,*/*;q=0.8", timeoutMs: 12000 }); res.statusCode = img.status >= 400 ? img.status : 200; res.setHeader("Content-Type", img.contentType || "application/octet-stream"); res.setHeader("Cache-Control", "public, max-age=300"); @@ -517,6 +543,7 @@ async function handleRequest(entry, req, res) { try { const target = githubBlobToRaw(u); const r = await fetchUrl(target, { + ...policy, accept: "text/plain,text/markdown,application/json,text/*;q=0.9,*/*;q=0.5", timeoutMs: 12000, maxBytes: 1024 * 1024, @@ -564,8 +591,9 @@ async function handleRequest(entry, req, res) { truncated, text, }); - } catch (err) { - return sendJson(res, 200, { error: err.message || "Couldn't load file preview." }); + } catch { + log("OG Viewer: file preview failed.", "warning"); + return sendJson(res, 200, { error: "Couldn't load file preview." }); } } @@ -575,17 +603,19 @@ async function handleRequest(entry, req, res) { // them, and rewrites JS imports / CSS urls so the dependency graph stays inside // the proxy. The path layout makes relative module imports resolve correctly. if (path.startsWith("/api/proxy/")) { - const target = proxyDecodePath(path, reqUrl.search); + const search = new URLSearchParams(reqUrl.search); + if (access.privileged) search.delete("key"); + const target = proxyDecodePath(path, search.size ? `?${search}` : ""); if (!target) { res.statusCode = 400; return res.end("Bad proxy path"); } - const appOrigin = "http://" + (req.headers.host || "127.0.0.1"); + const appOrigin = proxyOrigin; try { - const r = await fetchUrl(target, { accept: "*/*", timeoutMs: 15000 }); + const r = await fetchUrl(target, { ...policy, accept: "*/*", timeoutMs: 15000 }); const ct = r.contentType || ""; res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Cache-Control", "public, max-age=300"); + res.setHeader("Cache-Control", "no-store"); const realPath = (() => { try { return new URL(r.url).pathname; @@ -617,10 +647,11 @@ async function handleRequest(entry, req, res) { res.statusCode = r.status >= 400 ? r.status : 200; res.setHeader("Content-Type", ct || "application/octet-stream"); return res.end(r.body); - } catch (err) { + } catch { + log("OG Viewer: proxy resource request failed.", "warning"); res.statusCode = 502; res.setHeader("Access-Control-Allow-Origin", "*"); - return res.end(String(err && err.message ? err.message : err)); + return res.end("Couldn't load preview resource."); } } @@ -632,6 +663,7 @@ async function handleRequest(entry, req, res) { } try { const r = await fetchUrl(normalizeUrl(u), { + ...policy, accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", timeoutMs: 15000, }); @@ -639,7 +671,7 @@ async function handleRequest(entry, req, res) { const isHtml = !ct || /text\/html|application\/xhtml\+xml|\/xml|text\/plain/i.test(ct); res.setHeader("Cache-Control", "no-store"); res.setHeader("Access-Control-Allow-Origin", "*"); - const appOrigin = "http://" + (req.headers.host || "127.0.0.1"); + const appOrigin = proxyOrigin; if (!isHtml) { // Serve non-HTML targets (images, PDFs, …) verbatim so links to // them still render inside the browse frame. @@ -652,11 +684,12 @@ async function handleRequest(entry, req, res) { res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); return res.end(rewriteBrowseDoc(r.body.toString("utf8"), r.url, appOrigin)); - } catch (err) { + } catch { + log("OG Viewer: browse request failed.", "warning"); res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); - return res.end(browseErrorPage(u, err && err.message ? err.message : String(err))); + return res.end(browseErrorPage(u, "Check the URL and selected-origin access.")); } } @@ -667,8 +700,8 @@ async function handleRequest(entry, req, res) { let body; try { body = await readJsonBody(req); - } catch (err) { - return sendJson(res, 400, { error: err.message }); + } catch { + return sendJson(res, 400, { error: "Invalid JSON request body." }); } const repo = typeof body.repo === "string" ? body.repo.trim() : ""; const pageUrl = typeof body.url === "string" ? body.url.trim() : ""; @@ -690,13 +723,14 @@ async function handleRequest(entry, req, res) { return sendJson(res, 200, { ok: false, error: "Session bridge unavailable." }); } // Fire the request into the host chat session; the agent acts on it. - sessionRef.send(message).catch(() => {}); + await sessionRef.send(message); log(`OG Viewer: requested ${kind} for ${repo}.`); return sendJson(res, 200, { ok: true }); - } catch (err) { + } catch { + log("OG Viewer: session action failed.", "warning"); return sendJson(res, 200, { ok: false, - error: err && err.message ? err.message : String(err), + error: "Couldn't send the session action.", }); } } @@ -707,6 +741,7 @@ async function handleRequest(entry, req, res) { async function startServer(instanceId, currentUrl) { const entry = { + ...createAccess(), instanceId, server: null, url: "", @@ -714,10 +749,12 @@ async function startServer(instanceId, currentUrl) { titleKey: currentUrl ? titleKey(currentUrl) : "", clients: new Set(), }; + if (currentUrl) entry.policy = await authorizePreview(currentUrl); const server = createServer((req, res) => { - Promise.resolve(handleRequest(entry, req, res)).catch((err) => { + Promise.resolve(handleRequest(entry, req, res)).catch(() => { + log("OG Viewer: request failed.", "warning"); if (!res.headersSent) res.statusCode = 500; - res.end(String(err && err.message ? err.message : err)); + res.end("Preview request failed."); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -731,7 +768,8 @@ async function startServer(instanceId, currentUrl) { function instanceUrl(entry) { const base = entry.url; - return entry.currentUrl ? `${base}?u=${encodeURIComponent(entry.currentUrl)}` : base; + const url = entry.currentUrl ? `${base}?u=${encodeURIComponent(entry.currentUrl)}` : base; + return `${url}#key=${entry.uiToken}`; } const ogCanvas = createCanvas({ @@ -767,7 +805,8 @@ const ogCanvas = createCanvas({ } const url = ctx.input?.url; if (!url) throw new CanvasError("invalid_input", "An 'url' value is required."); - const data = await loadMetadata(url); + const policy = await selectPreview(entry, url); + const data = await loadMetadata(url, policy); entry.currentUrl = data.requestedUrl; broadcast(entry, { type: "load", url: data.requestedUrl }); syncTitle(entry, data.requestedUrl).catch(() => {}); @@ -787,7 +826,7 @@ const ogCanvas = createCanvas({ handler: async (ctx) => { const url = ctx.input?.url; if (!url) throw new CanvasError("invalid_input", "An 'url' value is required."); - const data = await loadMetadata(url); + const data = await loadMetadata(url, await authorizePreview(url)); return { requestedUrl: data.requestedUrl, resolved: data.resolved, @@ -803,6 +842,9 @@ const ogCanvas = createCanvas({ if (!entry) { entry = await startServer(ctx.instanceId, inputUrl); } else if (inputUrl) { + // Title refreshes use currentUrl and must not grant permissions to + // a redirect or a route reported by the sandboxed page. + if (inputUrl !== entry.currentUrl) await selectPreview(entry, inputUrl); entry.currentUrl = inputUrl; broadcast(entry, { type: "load", url: inputUrl }); } diff --git a/.github/extensions/og-preview/lib/agent-readiness.mjs b/.github/extensions/og-preview/lib/agent-readiness.mjs index 188a7e8e5..9f9f159ba 100644 --- a/.github/extensions/og-preview/lib/agent-readiness.mjs +++ b/.github/extensions/og-preview/lib/agent-readiness.mjs @@ -44,8 +44,8 @@ async function probe(url, opts = {}) { bytes: r.body ? r.body.length : 0, finalUrl: r.url, }; - } catch (err) { - return { ok: false, status: 0, error: String((err && err.message) || err) }; + } catch { + return { ok: false, status: 0, error: "Probe unavailable or outside the authorized preview origin." }; } } @@ -94,33 +94,34 @@ async function dnsAid(host) { return { ok: false }; } -export async function checkAgentReadiness(rawUrl) { +export async function checkAgentReadiness(rawUrl, policy = {}) { const u = new URL(rawUrl); const origin = u.origin; const host = u.hostname; const W = (p) => origin + p; + const check = (url, opts) => probe(url, { ...policy, ...opts }); const [ robots, sitemapXml, llms, llmsFull, mdNeg, page, mcp1, mcp2, a2a1, a2a2, aiPlugin, skills1, skills2, oauthPr, oauthAs, apiCatalog, aid, ] = await Promise.all([ - probe(W("/robots.txt"), { accept: "text/plain,*/*;q=0.8" }), - probe(W("/sitemap.xml"), { accept: "application/xml,text/xml,*/*;q=0.8" }), - probe(W("/llms.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), - probe(W("/llms-full.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), - probe(rawUrl, { accept: "text/markdown; q=1.0, text/x-markdown; q=0.9, text/plain; q=0.5" }), - probe(rawUrl, { accept: "text/html,application/xhtml+xml" }), - probe(W("/.well-known/mcp"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/mcp.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/agent.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/agent-card.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/ai-plugin.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/agent-skills.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/skills.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/oauth-protected-resource"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/oauth-authorization-server"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/api-catalog"), { accept: "application/linkset+json,application/json,*/*;q=0.8" }), + check(W("/robots.txt"), { accept: "text/plain,*/*;q=0.8" }), + check(W("/sitemap.xml"), { accept: "application/xml,text/xml,*/*;q=0.8" }), + check(W("/llms.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), + check(W("/llms-full.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), + check(rawUrl, { accept: "text/markdown; q=1.0, text/x-markdown; q=0.9, text/plain; q=0.5" }), + check(rawUrl, { accept: "text/html,application/xhtml+xml" }), + check(W("/.well-known/mcp"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/mcp.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/agent.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/agent-card.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/ai-plugin.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/agent-skills.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/skills.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/oauth-protected-resource"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/oauth-authorization-server"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/api-catalog"), { accept: "application/linkset+json,application/json,*/*;q=0.8" }), dnsAid(host), ]); diff --git a/.github/extensions/og-preview/lib/http-fetch.mjs b/.github/extensions/og-preview/lib/http-fetch.mjs index d24a563a1..5370349eb 100644 --- a/.github/extensions/og-preview/lib/http-fetch.mjs +++ b/.github/extensions/og-preview/lib/http-fetch.mjs @@ -13,74 +13,81 @@ const USER_AGENT = const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1|.*\.localhost)(:|\/|$)/i; -// SSRF guard. The tool intentionally supports localhost, but every other -// private / link-local / unique-local / carrier-grade-NAT range is denied by -// default so the agent-callable actions and the loopback proxy can't be turned -// into a request-forgery primitive against the developer's machine/network -// (e.g. the 169.254.169.254 cloud metadata endpoint). Set -// OG_ALLOW_PRIVATE_NETWORK=1 to opt in to private destinations beyond localhost. +// Local destinations require explicit origin authorization. The environment +// opt-in permits selecting private origins; it never authorizes discovered URLs. const ALLOW_PRIVATE_NETWORK = /^(1|true|yes|on)$/i.test( String(process.env.OG_ALLOW_PRIVATE_NETWORK || ""), ); -function ipv4Allowed(ip, allowPrivate) { - const o = ip.split(".").map((n) => Number(n)); - if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { - return false; +function addressKind(ip) { + if (net.isIP(ip) === 6) { + const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1); + const mapped = canonical.match(/^::ffff:([a-f0-9]+):([a-f0-9]+)$/); + if (mapped) { + const high = parseInt(mapped[1], 16); + const low = parseInt(mapped[2], 16); + return addressKind(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`); + } + if (canonical === "::1") return "loopback"; + // Global unicast only; exclude transition and documentation ranges. + const [first, second = "0"] = canonical.split(":"); + if (/^[23]/.test(canonical) && + !(first === "2001" && (parseInt(second || "0", 16) < 512 || second === "db8")) && + first !== "2002" && first !== "3fff") return "public"; + return "private"; } - const [a, b] = o; - if (a === 127) return true; // loopback (localhost) — always allowed - if (allowPrivate) return true; - if (a === 0) return false; // "this" network - if (a === 10) return false; // private - if (a === 172 && b >= 16 && b <= 31) return false; // private - if (a === 192 && b === 168) return false; // private - if (a === 169 && b === 254) return false; // link-local + cloud metadata - if (a === 100 && b >= 64 && b <= 127) return false; // carrier-grade NAT - return true; -} - -function ipv6Allowed(ip, allowPrivate) { - const s = ip.toLowerCase(); - const mapped = s.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mapped) return ipv4Allowed(mapped[1], allowPrivate); - if (s === "::1") return true; // loopback - if (allowPrivate) return true; - if (s === "::") return false; // unspecified - if (/^fe[89ab]/.test(s)) return false; // fe80::/10 link-local - if (/^f[cd]/.test(s)) return false; // fc00::/7 unique-local - return true; + if (net.isIP(ip) !== 4) throw new Error("Invalid resolved address."); + const o = ip.split(".").map((n) => Number(n)); + const [a, b, c] = o; + if (a === 127) return "loopback"; + if (a === 0 || a === 10 || a >= 224 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2)))) || + (a === 169 && b === 254) || + (a === 100 && b >= 64 && b <= 127) || + (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) || + (a === 203 && b === 0 && c === 113)) return "private"; + return "public"; } -function addressAllowed(ip, allowPrivate) { - const v = net.isIP(ip); - if (v === 4) return ipv4Allowed(ip, allowPrivate); - if (v === 6) return ipv6Allowed(ip, allowPrivate); - return false; +function parseTarget(rawUrl) { + const parsed = new URL(rawUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Only HTTP and HTTPS URLs are supported."); + } + if (parsed.username || parsed.password) throw new Error("URL credentials are not supported."); + return parsed; } -// Resolve a hostname to its addresses and confirm none land in a denied range. -// Literal IPs are checked directly; explicit localhost names are always allowed. -async function assertHostAllowed(hostname, allowPrivate) { +async function resolveAddresses(hostname) { const host = hostname.startsWith("[") ? hostname.slice(1, -1) : hostname; - if (LOCAL_HOST_RE.test(host)) return; // localhost / *.localhost / 127.* / ::1 if (net.isIP(host)) { - if (!addressAllowed(host, allowPrivate)) { - throw new Error(`Blocked non-public address: ${host}`); - } - return; - } - let addrs; - try { - addrs = await dns.lookup(host, { all: true }); - } catch { - throw new Error(`Could not resolve host: ${host}`); + return [{ address: host, family: net.isIP(host) }]; } - for (const a of addrs) { - if (!addressAllowed(a.address, allowPrivate)) { - throw new Error(`Blocked non-public address for ${host}: ${a.address}`); + const addresses = await dns.lookup(host, { all: true }); + if (!addresses.length) throw new Error("Host resolved to no addresses."); + for (const record of addresses) { + if (!net.isIP(record.address) || net.isIP(record.address) !== record.family) { + throw new Error("Invalid DNS result."); } } + return addresses; +} + +/** Only trusted user/agent selection may call this, never discovered content. */ +export async function authorizePreview(rawUrl, allowPrivateNetwork = ALLOW_PRIVATE_NETWORK) { + const parsed = parseTarget(normalizeUrl(rawUrl)); + const addresses = await resolveAddresses(parsed.hostname); + const kinds = new Set(addresses.map((a) => addressKind(a.address))); + if (kinds.size !== 1) throw new Error("Host resolves to mixed network scopes."); + const kind = kinds.values().next().value; + if (kind === "private" && !allowPrivateNetwork) { + throw new Error("Private-network previews require OG_ALLOW_PRIVATE_NETWORK."); + } + return { + authorizedOrigin: kind === "public" ? null : parsed.origin, + allowPrivateNetwork, + }; } /** @@ -90,9 +97,12 @@ async function assertHostAllowed(hostname, allowPrivate) { export function normalizeUrl(input) { const trimmed = String(input ?? "").trim(); if (!trimmed) throw new Error("No URL provided."); - if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (/^https?:\/\//i.test(trimmed)) return parseTarget(trimmed).href; + if (/^[a-z][a-z\d+.-]*:/i.test(trimmed) && !/^[^:/]+:\d+(?:[/?#]|$)/.test(trimmed)) { + throw new Error("Only HTTP and HTTPS URLs are supported."); + } const scheme = LOCAL_HOST_RE.test(trimmed) ? "http://" : "https://"; - return scheme + trimmed; + return parseTarget(scheme + trimmed).href; } const MAX_BYTES = 6 * 1024 * 1024; // 6 MB safety cap @@ -108,23 +118,32 @@ export function fetchUrl(rawUrl, options = {}) { accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", maxBytes = MAX_BYTES, allowPrivateNetwork = ALLOW_PRIVATE_NETWORK, + authorizedOrigin = null, } = options; return new Promise((resolve, reject) => { let redirects = 0; + let localOrigin = authorizedOrigin; const visit = async (urlStr) => { let parsed; try { - parsed = new URL(urlStr); + parsed = parseTarget(urlStr); } catch { - return reject(new Error(`Invalid URL: ${urlStr}`)); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return reject(new Error(`Unsupported protocol: ${parsed.protocol}`)); + return reject(new Error("Invalid or unsupported URL.")); } + let addresses; try { - await assertHostAllowed(parsed.hostname, allowPrivateNetwork); + if (parsed.origin !== localOrigin) localOrigin = null; + addresses = await resolveAddresses(parsed.hostname); + for (const { address } of addresses) { + const kind = addressKind(address); + if (kind !== "public" && + (parsed.origin !== localOrigin || + (kind === "private" && !allowPrivateNetwork))) { + throw new Error("Destination is outside the authorized preview origin."); + } + } } catch (err) { return reject(err); } @@ -134,6 +153,17 @@ export function fetchUrl(rawUrl, options = {}) { parsed, { method: "GET", + // Do not resolve again or reuse a connection validated for a + // different request. Keep the URL hostname for Host and TLS. + agent: false, + lookup: (_hostname, opts, callback) => { + const candidates = opts.family + ? addresses.filter((a) => a.family === opts.family) + : addresses; + if (!candidates.length) return callback(new Error("No validated address.")); + if (opts.all) return callback(null, candidates); + callback(null, candidates[0].address, candidates[0].family); + }, headers: { "User-Agent": USER_AGENT, Accept: accept, diff --git a/.github/extensions/og-preview/lib/request-access.mjs b/.github/extensions/og-preview/lib/request-access.mjs new file mode 100644 index 000000000..8df6398ad --- /dev/null +++ b/.github/extensions/og-preview/lib/request-access.mjs @@ -0,0 +1,25 @@ +import { randomBytes } from "node:crypto"; + +export function createAccess() { + return { + uiToken: randomBytes(32).toString("hex"), + browseToken: randomBytes(32).toString("hex"), + policy: {}, + }; +} + +/** Browse content can fetch resources, but cannot select origins or use tools. */ +export function requestAccess(entry, req, url) { + if (req.headers.host !== new URL(entry.url).host) return null; + const prefix = `/browse/${entry.browseToken}`; + if (url.pathname.startsWith(prefix + "/api/proxy")) { + const path = url.pathname.slice(prefix.length); + if (path === "/api/proxy" || path.startsWith("/api/proxy/")) { + return req.method === "GET" ? { path, privileged: false } : null; + } + } + if (url.searchParams.get("key") === entry.uiToken) { + return { path: url.pathname, privileged: true }; + } + return null; +} diff --git a/.github/extensions/og-preview/tests/fixtures/sdk.mjs b/.github/extensions/og-preview/tests/fixtures/sdk.mjs new file mode 100644 index 000000000..5e4fe06bf --- /dev/null +++ b/.github/extensions/og-preview/tests/fixtures/sdk.mjs @@ -0,0 +1,12 @@ +export let registration; +export const messages = []; +export const createCanvas = (options) => options; +export class CanvasError extends Error {} +export async function joinSession(options) { + registration = options; + return { + log() {}, + async send(message) { messages.push(message); }, + rpc: { canvas: { async open() {} } }, + }; +} diff --git a/.github/extensions/og-preview/tests/http-fetch.test.mjs b/.github/extensions/og-preview/tests/http-fetch.test.mjs new file mode 100644 index 000000000..3bb360b27 --- /dev/null +++ b/.github/extensions/og-preview/tests/http-fetch.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { EventEmitter } from "node:events"; +import http from "node:http"; +import https from "node:https"; +import dns from "node:dns/promises"; +import { authorizePreview, fetchUrl, normalizeUrl } from "../lib/http-fetch.mjs"; + +function transport(t, library = http, responses = [{}]) { + const requests = []; + t.mock.method(library, "request", (url, options, receive) => { + const req = new EventEmitter(); + req.setTimeout = () => {}; + req.destroy = () => {}; + req.end = () => { + const next = responses[requests.length - 1] || {}; + const res = new EventEmitter(); + res.statusCode = next.status || 200; + res.headers = next.headers || {}; + res.resume = () => {}; + receive(res); + queueMicrotask(() => { + res.emit("data", Buffer.from("ok")); + res.emit("end"); + }); + }; + requests.push({ url, options }); + return req; + }); + return requests; +} + +test("blocks private, mapped IPv6, transition, and loopback addresses without selection", async (t) => { + const requests = transport(t); + for (const host of [ + "169.254.169.254", "10.0.0.1", "127.0.0.1", "0.0.0.0", "100.64.0.1", + "[::1]", "[fc00::1]", "[fe80::1]", "[::ffff:169.254.169.254]", + "[0:0:0:0:0:ffff:a00:1]", "[::ffff:7f00:1]", "[2002:a00:1::]", + "[2001::1]", "[64:ff9b::a00:1]", + ]) { + await assert.rejects(fetchUrl(`http://${host}/`, { allowPrivateNetwork: false }), /authorized/); + } + assert.equal(requests.length, 0); +}); + +test("environment/private opt-in alone never permits discovered private destinations", async (t) => { + const requests = transport(t); + await assert.rejects(fetchUrl("http://10.0.0.1/", { allowPrivateNetwork: true }), /authorized/); + assert.equal(requests.length, 0); +}); + +test("explicit loopback authorization is exact host, scheme and port", async (t) => { + const requests = transport(t); + const policy = await authorizePreview("http://127.0.0.1:4321/", false); + assert.equal((await fetchUrl("http://127.0.0.1:4321/page", policy)).status, 200); + for (const url of ["http://127.0.0.1:4322/", "http://[::1]:4321/", "https://127.0.0.1:4321/"]) { + await assert.rejects(fetchUrl(url, policy), /authorized/); + } + assert.equal(requests.length, 1); +}); + +test("private selection needs opt-in and authorizes only that origin", async (t) => { + const requests = transport(t); + await assert.rejects(authorizePreview("http://10.0.0.1/", false), /OG_ALLOW_PRIVATE_NETWORK/); + const policy = await authorizePreview("http://10.0.0.1/", true); + await fetchUrl("http://10.0.0.1/page", policy); + await assert.rejects(fetchUrl("http://10.0.0.2/", policy), /authorized/); + assert.equal(requests.length, 1); +}); + +test("public selection does not pre-authorize later DNS rebinding to loopback", async (t) => { + const requests = transport(t); + t.mock.method(dns, "lookup", async () => [{ address: "8.8.8.8", family: 4 }]); + const policy = await authorizePreview("http://preview.example/", false); + t.mock.method(dns, "lookup", async () => [{ address: "127.0.0.1", family: 4 }]); + await assert.rejects(fetchUrl("http://preview.example/", policy), /authorized/); + assert.equal(requests.length, 0); +}); + +test("pins validated DNS addresses while preserving the hostname for Host and TLS", async (t) => { + const lookups = t.mock.method(dns, "lookup", async () => [{ address: "8.8.8.8", family: 4 }]); + const requests = transport(t, https); + await fetchUrl("https://preview.example:443/path"); + const { url, options } = requests[0]; + assert.equal(url.hostname, "preview.example"); + assert.equal(url.protocol, "https:"); + assert.equal(options.agent, false); + assert.equal(options.rejectUnauthorized, undefined); + t.mock.method(dns, "lookup", async () => { throw new Error("Must not resolve again"); }); + options.lookup("preview.example", { all: true }, (err, addresses) => { + assert.ifError(err); + assert.deepEqual(addresses, [{ address: "8.8.8.8", family: 4 }]); + }); + options.lookup("preview.example", {}, (err, address, family) => { + assert.ifError(err); + assert.equal(address, "8.8.8.8"); + assert.equal(family, 4); + }); + assert.equal(lookups.mock.callCount(), 1); +}); + +test("checks every DNS result including localhost names and rejects empty answers", async (t) => { + const requests = transport(t); + for (const answers of [ + [], + [{ address: "8.8.8.8", family: 4 }, { address: "10.0.0.1", family: 4 }], + [{ address: "10.0.0.1", family: 4 }], + [{ address: "not-an-ip", family: 4 }], + ]) { + t.mock.method(dns, "lookup", async () => answers); + await assert.rejects(fetchUrl("http://untrusted.localhost/")); + } + assert.equal(requests.length, 0); +}); + +test("public IPv4 and IPv6 mapped addresses remain usable", async (t) => { + const requests = transport(t); + for (const host of ["8.8.8.8", "[2606:4700:4700::1111]", "[::ffff:808:808]"]) { + await fetchUrl(`http://${host}/`); + } + assert.equal(requests.length, 3); +}); + +test("revalidates redirect targets and never follows public-to-local redirects", async (t) => { + const requests = transport(t, http, [{ status: 302, headers: { location: "http://127.0.0.1/" } }]); + await assert.rejects(fetchUrl("http://8.8.8.8/"), /authorized/); + assert.equal(requests.length, 1); +}); + +test("local redirects can remain on the selected origin but not switch ports", async (t) => { + const requests = transport(t, http, [ + { status: 302, headers: { location: "/page" } }, + { status: 302, headers: { location: "http://127.0.0.1:2/" } }, + ]); + await assert.rejects(fetchUrl("http://127.0.0.1:1/", await authorizePreview("http://127.0.0.1:1/")), /authorized/); + assert.equal(requests.length, 2); +}); + +test("rejects unsupported schemes and embedded credentials before transport", async (t) => { + const requests = transport(t); + for (const url of ["file:///secret", "javascript:alert(1)", "ftp://8.8.8.8", "https://user:pass@8.8.8.8/"]) { + await assert.rejects(fetchUrl(url), /Invalid or unsupported/); + } + assert.equal(requests.length, 0); + assert.equal(normalizeUrl("localhost:4321"), "http://localhost:4321/"); + assert.equal(normalizeUrl("aspire.dev"), "https://aspire.dev/"); + assert.throws(() => normalizeUrl("file:///secret"), /Only HTTP/); + assert.throws(() => normalizeUrl("javascript:alert(1)"), /Only HTTP/); +}); + +test("a public resource cannot redirect back to an otherwise authorized local origin", async (t) => { + const requests = transport(t, http, [{ status: 302, headers: { location: "http://127.0.0.1/" } }]); + await assert.rejects(fetchUrl("http://8.8.8.8/", await authorizePreview("http://127.0.0.1/")), /authorized/); + assert.equal(requests.length, 1); +}); diff --git a/.github/extensions/og-preview/tests/request-access.test.mjs b/.github/extensions/og-preview/tests/request-access.test.mjs new file mode 100644 index 000000000..3d704ab17 --- /dev/null +++ b/.github/extensions/og-preview/tests/request-access.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createAccess, requestAccess } from "../lib/request-access.mjs"; + +test("requires the UI key and literal server host for privileged routes", () => { + const entry = { ...createAccess(), url: "http://127.0.0.1:4321/" }; + const req = { method: "GET", headers: { host: "127.0.0.1:4321" } }; + const url = new URL(`/api/fetch?key=${entry.uiToken}`, entry.url); + assert.deepEqual(requestAccess(entry, req, url), { path: "/api/fetch", privileged: true }); + assert.equal(requestAccess(entry, { ...req, headers: { host: "rebound.example:4321" } }, url), null); + assert.equal(requestAccess(entry, req, new URL("/api/fetch", entry.url)), null); +}); + +test("browse keys only grant GET access to exact proxy routes", () => { + const entry = { ...createAccess(), url: "http://127.0.0.1:4321/" }; + const req = { method: "GET", headers: { host: "127.0.0.1:4321" } }; + const prefix = `/browse/${entry.browseToken}`; + for (const path of ["/api/proxy", "/api/proxy/http/example.com/path"]) { + const url = new URL(prefix + path, entry.url); + assert.deepEqual(requestAccess(entry, req, url), { path, privileged: false }); + assert.equal(requestAccess(entry, { ...req, method: "POST" }, url), null); + } + for (const path of ["/api/proxyevil", "/api/fetch", "/events", "/api/create-issue"]) { + assert.equal(requestAccess(entry, req, new URL(prefix + path, entry.url)), null); + } +}); + +test("capabilities are distinct across roles and canvas instances", () => { + const first = createAccess(); + const second = createAccess(); + assert.equal(new Set([first.uiToken, first.browseToken, second.uiToken, second.browseToken]).size, 4); + assert.equal(first.uiToken.length, 64); + const entry = { ...first, url: "http://127.0.0.1:4321/" }; + const req = { method: "GET", headers: { host: "127.0.0.1:4321" } }; + assert.equal(requestAccess(entry, req, new URL(`/api/fetch?key=${first.browseToken}`, entry.url)), null); + assert.equal(requestAccess(entry, req, new URL(`/api/fetch?key=${second.uiToken}`, entry.url)), null); +}); diff --git a/.github/extensions/og-preview/tests/server.test.mjs b/.github/extensions/og-preview/tests/server.test.mjs new file mode 100644 index 000000000..601a39175 --- /dev/null +++ b/.github/extensions/og-preview/tests/server.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { test, before, after } from "node:test"; +import { registerHooks } from "node:module"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { registration, messages } from "./fixtures/sdk.mjs"; + +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@github/copilot-sdk/extension") { + return { url: new URL("./fixtures/sdk.mjs", import.meta.url).href, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, +}); +await import("../extension.mjs"); +hooks.deregister(); + +let target; +let targetUrl; +let canvas; +let canvasUrl; +let key; +let hits = 0; +before(async () => { + target = createServer((req, res) => { + hits++; + res.setHeader("Content-Type", "text/html"); + if (req.url === "/bad-type") { + res.setHeader("Content-Type", "application/private-path-sentinel"); + } + if (req.url === "/redirect") { + res.writeHead(302, { Location: "http://127.0.0.1:1/" }); + res.end(); + return; + } + res.end(`Local preview + + + +Hello`); + }); + target.listen(0, "127.0.0.1"); + await once(target, "listening"); + targetUrl = `http://127.0.0.1:${target.address().port}`; + canvas = registration.canvases[0]; + const opened = await canvas.open({ instanceId: "test", input: { url: targetUrl } }); + canvasUrl = new URL(opened.url); + key = new URLSearchParams(canvasUrl.hash.slice(1)).get("key"); +}); +after(async () => { + await canvas.onClose({ instanceId: "test" }); + await new Promise((resolve) => target.close(resolve)); +}); +function api(path) { + const url = new URL(path, canvasUrl); + url.searchParams.set("key", key); + return url; +} +async function browse() { + const res = await fetch(api("/api/proxy?u=" + encodeURIComponent(targetUrl))); + return res.text(); +} +function resourceUrl(html) { + const value = html.match(/http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/[^" ]+\/external\.js/); + assert.ok(value, "resource URL has a browse-only capability"); + return new URL(value[0]); +} + +test("requires the private outer-UI capability on API routes and SSE", async () => { + const beforeHits = hits; + for (const path of ["/api/fetch?select=1&u=" + encodeURIComponent(targetUrl), "/events", "/api/proxy?u=" + encodeURIComponent(targetUrl)]) { + const response = await fetch(new URL(path, canvasUrl)); + assert.equal(response.status, 403); + } + assert.equal(hits, beforeHits); +}); + +test("allows explicitly selected localhost metadata and same-origin resources", async () => { + const res = await fetch(api("/api/fetch?u=" + encodeURIComponent(targetUrl))); + assert.equal(res.status, 200); + assert.equal((await res.json()).resolved.title, "Local preview"); + const html = await browse(); + const resource = resourceUrl(html); + assert.equal((await fetch(resource)).status, 200); + assert.ok(!html.includes(key), "privileged token never enters fetched HTML"); +}); + +test("rewrites mixed-case inline modules with quoted delimiters and closing whitespace", async () => { + const html = await browse(); + assert.match(html, /import "http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/127\.0\.0\.1:\d+\/module\.js"/); + assert.match(html, /const classic = "\/plain\.js"/); + assert.match(html, /import "http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/127\.0\.0\.1:\d+\/end-attributes\.js"/); + assert.match(html, /import "http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/127\.0\.0\.1:\d+\/end-slash\.js"/); +}); + +test("browse capability cannot authorize origins, send actions, or read events", async () => { + const resource = resourceUrl(await browse()); + const prefix = resource.pathname.slice(0, resource.pathname.indexOf("/api/proxy")); + const beforeMessages = messages.length; + for (const path of ["/api/fetch?select=1&u=http://127.0.0.1:1", "/api/open-session", "/events"]) { + const res = await fetch(new URL(prefix + path, canvasUrl)); + assert.equal(res.status, 403); + } + const res = await fetch(api("/api/create-issue"), { + method: "POST", + body: '{"repo":"microsoft/aspire.dev","prompt":"test"}', + }); + assert.equal(res.status, 200); + assert.equal(messages.length, beforeMessages + 1); +}); + +test("revokes old browse capabilities when the outer UI explicitly selects a target", async () => { + const oldResource = resourceUrl(await browse()); + const res = await fetch(api("/api/fetch?select=1&silent=1&u=" + encodeURIComponent(targetUrl))); + assert.equal((await res.json()).resolved.title, "Local preview"); + assert.equal((await fetch(oldResource)).status, 403); + assert.equal((await fetch(resourceUrl(await browse()))).status, 200); +}); + +for (const route of ["document", "resource"]) { + test(`in-flight ${route} responses retain their revoked browse capability`, async () => { + const html = 'Delayed preview'; + const started = Promise.withResolvers(); + let heldResponse; + let pending; + const delayedTarget = createServer((req, res) => { + res.setHeader("Content-Type", "text/html"); + if (req.url === "/delayed") { + heldResponse = res; + started.resolve(); + return; + } + res.end(html); + }); + delayedTarget.listen(0, "127.0.0.1"); + await once(delayedTarget, "listening"); + const origin = `http://127.0.0.1:${delayedTarget.address().port}`; + try { + const selection = await fetch(api("/api/fetch?select=1&silent=1&u=" + encodeURIComponent(origin))); + assert.equal((await selection.json()).resolved.title, "Delayed preview"); + const initial = await fetch(api("/api/proxy?u=" + encodeURIComponent(origin))); + const oldResource = resourceUrl(await initial.text()); + const delayedUrl = route === "document" + ? api("/api/proxy?u=" + encodeURIComponent(origin + "/delayed")) + : new URL(oldResource.href.replace("/external.js", "/delayed")); + pending = fetch(delayedUrl, { signal: AbortSignal.timeout(5000) }).then((res) => res.text()); + await Promise.race([ + started.promise, + pending.then(() => assert.fail("The upstream response must remain in flight.")), + ]); + + const reselection = await fetch(api("/api/fetch?select=1&silent=1&u=" + encodeURIComponent(targetUrl))); + assert.equal((await reselection.json()).resolved.title, "Local preview"); + const currentResource = resourceUrl(await browse()); + heldResponse.end(html); + const delayedHtml = await pending; + assert.equal(resourceUrl(delayedHtml).href, oldResource.href); + const currentPrefix = currentResource.pathname.split("/api/proxy")[0]; + assert.ok(!delayedHtml.includes(currentPrefix), "old content never receives the new capability"); + assert.equal((await fetch(oldResource)).status, 403); + assert.equal((await fetch(currentResource)).status, 200); + } finally { + heldResponse?.end(); + if (pending) await Promise.allSettled([pending]); + delayedTarget.closeAllConnections(); + await new Promise((resolve) => delayedTarget.close(resolve)); + } + }); +} + +test("returns fixed errors without paths, exception messages, or stack details", async () => { + const secret = "stack-path-sentinel"; + for (const path of ["/api/fetch", "/api/raw", "/api/agent-readiness"]) { + const res = await fetch(api(`${path}?u=${encodeURIComponent("file:///" + secret)}`)); + const body = await res.text(); + assert.ok(!body.includes(secret)); + assert.match(body, /error/); + } + const res = await fetch(api("/api/proxy?u=" + encodeURIComponent(targetUrl + "/redirect"))); + const html = await res.text(); + assert.match(html, /selected-origin access/); + assert.ok(!html.includes("Error:") && !html.includes(" at ")); + const badType = await fetch(api("/api/fetch?u=" + encodeURIComponent(targetUrl + "/bad-type"))); + const error = await badType.json(); + assert.ok(error.error); + assert.ok(!error.error.includes("private-path-sentinel")); +}); + +test("rejects unrelated loopback origins for all fetch surfaces", async () => { + const url = encodeURIComponent("http://127.0.0.1:1/"); + for (const path of ["/api/fetch", "/api/raw", "/api/img"]) { + const res = await fetch(api(`${path}?u=${url}`)); + if (path === "/api/img") assert.equal(res.status, 502); + else assert.match(await res.text(), /error/); + } + const resource = resourceUrl(await browse()); + resource.pathname = resource.pathname.replace(/\/http\/.*$/, "/http/127.0.0.1:1/"); + assert.equal((await fetch(resource)).status, 502); +}); + +test("standalone get_metadata action explicitly authorizes localhost", async () => { + const action = canvas.actions.find((action) => action.name === "get_metadata"); + const result = await action.handler({ input: { url: targetUrl } }); + assert.equal(result.resolved.title, "Local preview"); + const named = await action.handler({ input: { url: targetUrl.replace("127.0.0.1", "localhost") } }); + assert.equal(named.resolved.title, "Local preview"); +}); diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index a50e935c0..48b603d61 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -1,5 +1,13 @@ "use strict"; +const previewKey = new URLSearchParams(location.hash.slice(1)).get("key") || ""; +function apiUrl(path) { + const url = new URL(path, location.origin); + if (url.origin !== location.origin) throw new Error("Invalid preview API origin."); + url.searchParams.set("key", previewKey); + return url.pathname + url.search; +} + const TRANSPARENT = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; @@ -228,6 +236,9 @@ function withScheme(raw) { let v = (raw || "").trim(); if (!v || v === "https://" || v === "http://") return ""; if (/^https?:\/\//i.test(v)) return v; + // Preserve explicit schemes so the fetch/navigation boundary can reject + // them, rather than disguising file: or data: input as an HTTPS hostname. + if (/^[a-z][a-z\d+.-]*:/i.test(v) && !/^[^:/]+:\d+(?:[/?#]|$)/.test(v)) return v; v = v.replace(/^\/+/, ""); const isLocal = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|[^/]+\.local)(:|\/|$)/i.test(v); return (isLocal ? "http://" : "https://") + v; @@ -278,7 +289,7 @@ function makeImage(url, className) { img.addEventListener("error", () => { if (img.dataset.stage === "direct") { img.dataset.stage = "proxy"; - img.src = "/api/img?u=" + encodeURIComponent(url); + img.src = apiUrl("/api/img?u=" + encodeURIComponent(url)); } else if (img.dataset.stage === "proxy") { img.dataset.stage = "placeholder"; img.src = TRANSPARENT; @@ -330,7 +341,7 @@ function showImgTip(url, x, y) { imgTipImg.onerror = () => { if (imgTipImg.dataset.stage === "direct") { imgTipImg.dataset.stage = "proxy"; - imgTipImg.src = "/api/img?u=" + encodeURIComponent(real); + imgTipImg.src = apiUrl("/api/img?u=" + encodeURIComponent(real)); } else { imgTipMeta.textContent = "Preview unavailable"; } @@ -899,7 +910,7 @@ async function showCodeCard(url, node) { let payload = codeCache.get(raw); if (!payload) { try { - const res = await fetch("/api/raw?u=" + encodeURIComponent(raw)); + const res = await fetch(apiUrl("/api/raw?u=" + encodeURIComponent(raw))); payload = await res.json(); } catch { payload = { error: "Couldn't load file." }; @@ -1621,7 +1632,7 @@ async function postAction(path, payload, btn, busyLabel, doneLabel) { btn.classList.add("busy"); if (labelEl && busyLabel) labelEl.textContent = busyLabel; try { - const res = await fetch(path, { + const res = await fetch(apiUrl(path), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -1995,7 +2006,7 @@ async function refreshAgentReadiness(data) { return; } try { - const res = await fetch("/api/agent-readiness?u=" + encodeURIComponent(targetUrl)); + const res = await fetch(apiUrl("/api/agent-readiness?u=" + encodeURIComponent(targetUrl))); const ar = await res.json(); if (seq !== arSeq) return; // a newer load superseded this probe if (!res.ok || ar.error) throw new Error(ar.error || `Request failed (${res.status})`); @@ -2159,7 +2170,8 @@ async function load(rawUrl, opts) { renderSkeleton(); try { const res = await fetch( - "/api/fetch?u=" + encodeURIComponent(url) + (silent ? "&silent=1" : ""), + apiUrl("/api/fetch?u=" + encodeURIComponent(url) + + (silent ? "&silent=1" : "") + (opts && opts.select ? "&select=1" : "")), ); const data = await res.json(); if (!res.ok || data.error) { @@ -2170,6 +2182,7 @@ async function load(rawUrl, opts) { input.value = data.requestedUrl || url; } pendingBrowseUrl = data.requestedUrl || url; + if (opts && opts.select) browseFrameUrl = ""; if (!(opts && opts.skipBrowse)) syncBrowseFrame(pendingBrowseUrl); document.title = data.requestedUrl ? `OG · ${(data.resolved && data.resolved.hostname) || data.requestedUrl}` @@ -2195,7 +2208,7 @@ async function load(rawUrl, opts) { $("#url-form").addEventListener("submit", (e) => { e.preventDefault(); - load(input.value); + load(input.value, { select: true }); }); $("#refresh").addEventListener("click", () => { if (browseActive()) { @@ -2266,7 +2279,7 @@ const luckyGo = $("#lucky-go"); if (luckyGo) { luckyGo.addEventListener("click", () => { input.value = luckyTarget; - load(luckyTarget); + load(luckyTarget, { select: true }); }); } @@ -2287,7 +2300,7 @@ if (luckyEmpty) { luckyEmpty.addEventListener("click", () => { const url = pickLuckySite(); input.value = url; - load(url); + load(url, { select: true }); }); } @@ -2430,7 +2443,7 @@ async function navBrowseFrame(rawUrl) { browsePanel.classList.add("has-browse"); const token = ++browseNavToken; try { - const res = await fetch("/api/proxy?u=" + encodeURIComponent(u)); + const res = await fetch(apiUrl("/api/proxy?u=" + encodeURIComponent(u))); const html = await res.text(); if (token !== browseNavToken) return; // a newer navigation superseded us browseFrame.srcdoc = html; @@ -2466,9 +2479,14 @@ $("#browse-open").addEventListener("click", () => { const u = withScheme(input.value || pendingBrowseUrl); if (!u) return; try { - window.open(u, "_blank", "noopener"); + const target = new URL(u); + if (target.protocol !== "http:" && target.protocol !== "https:") { + setStatus("error", "Only HTTP and HTTPS pages can be opened."); + return; + } + window.open(target.href, "_blank", "noopener"); } catch { - /* host may block popups */ + setStatus("error", "Couldn't open this URL."); } }); @@ -2477,6 +2495,7 @@ $("#browse-open").addEventListener("click", () => { // top-level URL input, advance the embedded frame to the new page, and refresh // every preview from it. window.addEventListener("message", (e) => { + if (e.source !== browseFrame.contentWindow) return; const m = e && e.data; if (!m || m.source !== "og-browse" || m.type !== "nav" || !m.url) return; if (!/^https?:\/\//i.test(m.url)) return; // ignore non-http targets (e.g. about:srcdoc) @@ -2542,12 +2561,13 @@ try { // Server-pushed loads (agent invoking the preview_url action). try { - const es = new EventSource("/events"); + const es = new EventSource(apiUrl("/events")); es.addEventListener("message", (e) => { try { const msg = JSON.parse(e.data); if (msg && msg.type === "load" && msg.url) { input.value = msg.url; + browseFrameUrl = ""; load(msg.url); } } catch { diff --git a/.github/workflows/og-preview-tests.yml b/.github/workflows/og-preview-tests.yml new file mode 100644 index 000000000..19431e5e7 --- /dev/null +++ b/.github/workflows/og-preview-tests.yml @@ -0,0 +1,25 @@ +name: OG preview tests + +on: + pull_request: + paths: + - ".github/extensions/og-preview/**" + - ".github/workflows/og-preview-tests.yml" + push: + branches: [main] + paths: + - ".github/extensions/og-preview/**" + - ".github/workflows/og-preview-tests.yml" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.x" + - run: node --test .github/extensions/og-preview/tests/*.test.mjs diff --git a/src/frontend/src/scripts/search.ts b/src/frontend/src/scripts/search.ts index f7b0425ce..9d6f4afbe 100644 --- a/src/frontend/src/scripts/search.ts +++ b/src/frontend/src/scripts/search.ts @@ -79,10 +79,13 @@ export async function searchAspireDocs(query: string, limit: number): Promise { const data = await hit.data(); + // Template contents stay inert and detached; the WebMCP result remains JSON text. + const template = document.createElement('template'); + template.innerHTML = data.excerpt ?? ''; return { title: data.meta?.title ?? data.url, url: data.url, - excerpt: (data.excerpt ?? '').replace(/<[^>]+>/g, '').trim(), + excerpt: (template.content.textContent ?? '').trim(), }; }) ); diff --git a/src/frontend/src/utils/api-markdown-shared.ts b/src/frontend/src/utils/api-markdown-shared.ts index ac08fa6f2..f2094f7fc 100644 --- a/src/frontend/src/utils/api-markdown-shared.ts +++ b/src/frontend/src/utils/api-markdown-shared.ts @@ -101,7 +101,11 @@ export function indentMarkdown(markdown: string, prefix: string): string { } export function escapeTableCell(value: string): string { - return value.replace(/\|/g, '\\|').replace(/\n/g, '
'); + // API renderers supply formatted Markdown: leave unrelated escapes and code paths alone. + // This encodes table delimiters, not arbitrary code spans containing literal \|. + return value + .replace(/\\*\|/g, (delimiter) => delimiter.replace(/[\\|]/g, '\\$&')) + .replace(/\r\n|\r|\n/g, '
'); } export function link(label: string, href: string): string { diff --git a/src/frontend/src/utils/sample-readme-headings.ts b/src/frontend/src/utils/sample-readme-headings.ts index d8fbf4a34..08a4d3b48 100644 --- a/src/frontend/src/utils/sample-readme-headings.ts +++ b/src/frontend/src/utils/sample-readme-headings.ts @@ -9,7 +9,8 @@ export function stripReadmeTitle(readme: string): string { } /** - * Lower-cases, strips punctuation, and collapses whitespace into a stable + * Accepts plain text from `headingPlainText`, not HTML. Lower-cases, strips + * punctuation, and collapses whitespace into a stable * `kebab-case` id, mirroring Starlight's heading-slugger so anchor links * resolve. Returns `'section'` as a fallback when the input collapses to * an empty string. @@ -18,7 +19,6 @@ export function slugifyHeading(value: string): string { const slug = value .toLowerCase() .trim() - .replace(/<[^>]*>/g, '') .replace(/[^\p{Letter}\p{Number}\s-]/gu, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') diff --git a/src/frontend/tests/unit/api-markdown.vitest.test.ts b/src/frontend/tests/unit/api-markdown.vitest.test.ts index 159015258..5b32213c8 100644 --- a/src/frontend/tests/unit/api-markdown.vitest.test.ts +++ b/src/frontend/tests/unit/api-markdown.vitest.test.ts @@ -6,7 +6,13 @@ -- route module imports and test props are intentionally dynamic in this harness */ import { describe, expect, it, vi } from 'vitest'; +import { createMarkdownProcessor } from '@astrojs/markdown-remark'; +import { selectAll } from 'hast-util-select'; +import { toHtml } from 'hast-util-to-html'; +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; +import { escapeTableCell } from '@utils/api-markdown-shared'; import { renderCSharpDocMarkdown, renderCSharpMemberKindMarkdown, @@ -236,6 +242,92 @@ describe('API markdown routes', () => { }); describe('API markdown helpers', () => { + const tableProcessor = createMarkdownProcessor({ smartypants: false, syntaxHighlight: false }); + + async function renderCell(value: string) { + const processor = await tableProcessor; + const result = await processor.render( + `| Value | Sentinel |\n| --- | --- |\n| ${escapeTableCell(value)} | intact |` + ); + const tree = unified().use(rehypeParse, { fragment: true }).parse(result.code); + expect(selectAll('tbody tr', tree)).toHaveLength(1); + const cells = selectAll('td', tree); + expect(cells).toHaveLength(2); + expect(toHtml(cells[1])).toBe('intact'); + return toHtml(cells[0]); + } + + it.each([0, 1, 2, 3, 4])('preserves %i literal backslashes before a pipe in a GFM cell', async (count) => { + const value = `left${'\\'.repeat(count)}|right`; + expect(await renderCell(value)).toBe(`${value}`); + }); + + it('preserves ordinary inline code containing pipes', async () => { + expect(await renderCell('`left|middle|right`')).toBe('left|middle|right'); + }); + + it.each(['\r\n', '\r', '\n'])('keeps %j line endings within a single table row', async (newline) => { + expect(await renderCell(`first${newline}second`)).toBe('first
second'); + }); + + it('preserves links, emphasis, code spans and literal paths in rendered table cells', async () => { + expect(await renderCell( + '[API | docs](/reference/api/) **important** `C:\\src\\app` and C:\\src\\app\\' + )).toBe( + 'API | docs important ' + + 'C:\\src\\app and C:\\src\\app\\' + ); + }); + + it('encodes repeated prose backslashes and pipes once, without changing plain code spans', async () => { + const value = 'a\\|b\\\\|c | `C:\\src\\app` | `left|right`'; + expect(escapeTableCell(value)).toBe( + 'a\\\\\\|b\\\\\\\\\\|c \\| `C:\\src\\app` \\| `left\\|right`' + ); + expect(await renderCell(value)).toBe( + 'a\\|b\\\\|c | C:\\src\\app | left|right' + ); + }); + + it('preserves code backslashes when a pipe occurs elsewhere in the same span', async () => { + expect(await renderCell('before\r\n`C:\\src | D:\\data` after\\|end')).toBe( + 'before
C:\\src | D:\\data after\\|end' + ); + }); + + it('preserves existing Markdown escapes instead of treating the cell as raw text', async () => { + expect(await renderCell('\\*literal\\* and \\[label\\] | `C:\\src\\app`')).toBe( + '*literal* and [label] | C:\\src\\app' + ); + }); + + it('preserves inline-code link labels and single-line code from API renderers', async () => { + expect(await renderCell('[`left|right`](/reference/api/) and `first second`')).toBe( + 'left|right and first second' + ); + }); + + it('preserves rich Markdown from the C# documentation table renderer', async () => { + const markdown = renderCSharpDocMarkdown([{ + kind: 'list', + style: 'table', + items: [{ + term: [{ kind: 'text', text: 'path\\|name' }], + description: [ + { kind: 'href', text: 'API | docs', value: '/reference/api/' }, + { kind: 'code', text: '%LocalAppData%\\Aspire\\BrowserData' }, + ], + }], + }], { allTypes: [], base: '', packageName: 'Test.Package' }); + const result = await (await tableProcessor).render(markdown); + const tree = unified().use(rehypeParse, { fragment: true }).parse(result.code); + expect(selectAll('tbody tr', tree)).toHaveLength(1); + expect(selectAll('td', tree).map((node) => toHtml(node))).toEqual([ + 'path\\|name', + 'API | docs %LocalAppData%\\Aspire\\BrowserData', + ]); + }); + it('normalizes note blockquotes to a single level', () => { const markdown = renderCSharpDocMarkdown( [ diff --git a/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts b/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts index 5fd4016bb..f02cf811e 100644 --- a/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts +++ b/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts @@ -4,6 +4,9 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import { selectAll } from 'hast-util-select'; +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; import { readApiSearchIndex, registerApiSearch } from '@components/api-reference/search-lifecycle'; import * as searchStats from '@utils/ts-api-search-stats'; @@ -18,6 +21,32 @@ const surfaces = [ type MountSearch = (root: HTMLElement, signal: AbortSignal) => void; +function readControllerScript(source: string): string { + const tree = unified().use(rehypeParse, { fragment: true }).parse(source); + const scripts = selectAll('script', tree).filter((node) => + !('src' in node.properties) && !('is:inline' in node.properties) + && (!node.properties.type || node.properties.type === 'module')); + expect(scripts).toHaveLength(1); + return scripts[0].children.map((node) => node.type === 'text' ? node.value : '').join(''); +} + +describe('Astro controller script extraction', () => { + it.each(['script', 'ScRiPt'])('parses %s boundaries and quoted attributes, not script-like tags', (tag) => { + expect(readControllerScript(` + not a controller + + + + <${tag} data-label="a > b">const value = "&"; + `)).toBe('const value = "&";'); + }); + + it('rejects missing or ambiguous controllers', () => { + expect(() => readControllerScript('no')).toThrow(); + expect(() => readControllerScript('')).toThrow(); + }); +}); + describe('API search navigation lifecycle', () => { let events: EventTarget; let roots: Map; @@ -162,9 +191,14 @@ describe('API search navigation lifecycle', () => { if (kind === 'type' || kind === 'item') segments.push(language === 'csharp' ? '[type]' : '[item]'); const filename = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'pages', 'reference', 'api', ...segments, 'index.astro'); - const script = readFileSync(filename, 'utf8').match(/>', 'scriptnamescript'], + ['', 'section'], + ['<> & !!!', 'section'], + ])('allows only slug characters in plain text %j', (text, expected) => { + expect(slugifyHeading(text)).toBe(expected); + expect(slugifyHeading(text)).toMatch(/^[\p{Letter}\p{Number}-]+$/u); + }); + + it('preserves heading IDs when the caller extracts MDAST text before slugging', () => { + const text = headingPlainText([ + { type: 'html', value: '' }, + { type: 'text', value: 'Running ' }, + { type: 'strong', children: [{ type: 'text', value: 'the ' }] }, + { type: 'link', url: '/app/', children: [{ type: 'inlineCode', value: 'AppHost' }] }, + { type: 'html', value: '' }, + ]); + expect(text).toBe('Running the AppHost'); + expect(slugifyHeading(text)).toBe('running-the-apphost'); + }); }); describe('toSentenceCase', () => { diff --git a/src/frontend/tests/unit/search.vitest.test.ts b/src/frontend/tests/unit/search.vitest.test.ts new file mode 100644 index 000000000..b6af7b639 --- /dev/null +++ b/src/frontend/tests/unit/search.vitest.test.ts @@ -0,0 +1,108 @@ +import type { RootContent } from 'hast'; +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SearchResponse } from '@scripts/search'; + +const pagefind = vi.hoisted(() => ({ search: vi.fn() })); +vi.mock('https://aspire.dev/pagefind/pagefind.js', () => pagefind); + +function textContent(node: RootContent): string { + if (node.type === 'text') return node.value; + return 'children' in node ? node.children.map(textContent).join('') : ''; +} + +describe('WebMCP search excerpts', () => { + const parse = unified().use(rehypeParse, { fragment: true }); + const createElement = vi.fn((tag: string) => { + expect(tag).toBe('template'); + // Model detached template content with a real HTML parser, not a tag regex. + const content = { textContent: '' }; + return { + content, + set innerHTML(html: string) { + content.textContent = parse.parse(html).children.map(textContent).join(''); + }, + }; + }); + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubGlobal('window', { location: new URL('https://aspire.dev/') }); + vi.stubGlobal('document', { createElement }); + pagefind.search.mockResolvedValue({ results: [] }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + function hit(excerpt?: string, title?: string) { + return { + data: vi.fn().mockResolvedValue({ url: '/get-started/', meta: { title }, excerpt }), + }; + } + + it.each([ + [' Aspire Redis & cache ', 'Aspire Redis & cache'], + ['match <T> | \', 'match | \\'], + ['beforenested match after', 'beforenested match after'], + ['x < y and unfinished', 'x < y and unfinished'], + ['text', + 'globalThis.injected = truetext'], + ['<img src=x onerror="fail()">', ''], + [undefined, ''], + ])('extracts text from the inert HTML excerpt %j', async (excerpt, expected) => { + pagefind.search.mockResolvedValue({ results: [hit(excerpt, 'Getting started')] }); + const { searchAspireDocs } = await import('@scripts/search'); + expect(await searchAspireDocs(' redis ', 10)).toEqual({ + results: [{ title: 'Getting started', url: '/get-started/', excerpt: expected }], + }); + expect(pagefind.search).toHaveBeenCalledWith('redis'); + expect(createElement).toHaveBeenCalledExactlyOnceWith('template'); + }); + + it('preserves result limits, title fallback, and empty queries/results', async () => { + const hits = [hit('one'), hit('two'), hit('three')]; + pagefind.search.mockResolvedValue({ results: hits }); + const { searchAspireDocs } = await import('@scripts/search'); + const response = await searchAspireDocs('redis', 2); + expect(response.results).toHaveLength(2); + expect(response.results[0].title).toBe('/get-started/'); + expect(hits[2].data).not.toHaveBeenCalled(); + expect((await searchAspireDocs('redis', 0)).results).toHaveLength(1); + pagefind.search.mockClear(); + expect(await searchAspireDocs(' \n ', 10)).toEqual({ results: [] }); + expect(pagefind.search).not.toHaveBeenCalled(); + pagefind.search.mockResolvedValue({ results: [] }); + expect(await searchAspireDocs('no matches', 10)).toEqual({ results: [] }); + }); + + it('preserves the unavailable response without a browser environment', async () => { + vi.stubGlobal('window', undefined); + const { searchAspireDocs } = await import('@scripts/search'); + expect(await searchAspireDocs('redis', 10)).toEqual({ + results: [], + unavailable: true, + reason: 'Pagefind is not available in this environment.', + }); + }); + + it('keeps decoded excerpts inside the unchanged WebMCP JSON-text envelope', async () => { + const registerTool = vi.fn<(tool: { + execute: (input: unknown) => Promise; + }) => void>(); + vi.stubGlobal('navigator', { modelContext: { registerTool } }); + const excerpt = ' & "quoted"'; + pagefind.search.mockResolvedValue({ + results: [hit('<img src=x onerror="fail()"> & "quoted"')], + }); + await import('@scripts/webmcp'); + const response: SearchResponse = { + results: [{ title: '/get-started/', url: '/get-started/', excerpt }], + }; + expect(await registerTool.mock.calls[0][0].execute({ query: 'redis' })).toEqual({ + content: [{ type: 'text', text: JSON.stringify(response) }], + isError: false, + }); + }); +}); diff --git a/src/statichost/StaticHost/Live/LiveEndpoints.cs b/src/statichost/StaticHost/Live/LiveEndpoints.cs index 23f815f3e..98a0ac86c 100644 --- a/src/statichost/StaticHost/Live/LiveEndpoints.cs +++ b/src/statichost/StaticHost/Live/LiveEndpoints.cs @@ -204,7 +204,8 @@ private static async Task TwitchWebhook( // replayed indefinitely. if (!TwitchWebhookHandler.IsFresh(timestamp, time.GetUtcNow(), TimeSpan.FromMinutes(10))) { - logger.LogWarning("Twitch webhook timestamp {Timestamp} is stale or unparseable; rejecting.", timestamp); + logger.LogWarning("Twitch webhook timestamp {Timestamp} (sanitized) is stale or unparseable; rejecting.", + TwitchWebhookHandler.SanitizeDiagnosticValue(timestamp)); return Results.Unauthorized(); } @@ -227,15 +228,16 @@ private static async Task TwitchWebhook( if (acquisition.Status == TwitchMessageAcquisitionStatus.Completed) { - logger.LogDebug("Twitch webhook replay ignored for {MessageId}.", messageId); + logger.LogDebug("Twitch webhook replay ignored for message {MessageId}.", + TwitchWebhookHandler.SanitizeDiagnosticValue(messageId)); return Results.Ok(); } if (acquisition.Status == TwitchMessageAcquisitionStatus.Processing) { logger.LogDebug( - "Twitch webhook {MessageId} is already being processed; asking Twitch to retry.", - messageId); + "Twitch webhook message {MessageId} is already being processed; asking Twitch to retry.", + TwitchWebhookHandler.SanitizeDiagnosticValue(messageId)); return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); } @@ -319,7 +321,8 @@ private static async Task YouTubeVerify( verifyToken, leaseSeconds)) { - logger.LogWarning("Rejected malformed YouTube WebSub {Mode} verification for {Topic}.", mode, topic); + LogRejectedVerification( + logger, mode, topic, options.Value.YouTube.ChannelId, malformed: true); return Results.NotFound(); } @@ -345,7 +348,8 @@ private static async Task YouTubeVerify( if (!confirmed) { - logger.LogWarning("Rejected unexpected YouTube WebSub {Mode} verification for {Topic}.", mode, topic); + LogRejectedVerification( + logger, mode, topic, options.Value.YouTube.ChannelId, malformed: false); return Results.NotFound(); } @@ -355,6 +359,25 @@ private static async Task YouTubeVerify( return Results.Text(challenge, "text/plain"); } + private static void LogRejectedVerification( + ILogger logger, string mode, string topic, string channelId, bool malformed) + { + var modeClassification = mode switch + { + "subscribe" => "Subscribe", + "unsubscribe" => "Unsubscribe", + _ => "Unknown", + }; + bool? matchesConfiguredTopic = string.IsNullOrEmpty(channelId) + ? null + : string.Equals(topic, YouTubeWebSubSubscriptionTransitions.TopicFor(channelId), StringComparison.Ordinal); + logger.LogWarning( + "YouTube {Operation} rejected: {RejectionReason}; mode {ModeClassification}, " + + "topic present {TopicPresent}, matches configured topic {MatchesConfiguredTopic}.", + "WebSubVerification", malformed ? "Malformed" : "Unexpected", modeClassification, + !string.IsNullOrEmpty(topic), matchesConfiguredTopic); + } + private static async Task YouTubeWebhook( HttpContext context, IOptions options, diff --git a/src/statichost/StaticHost/Live/README.md b/src/statichost/StaticHost/Live/README.md index 2544d8127..345c60124 100644 --- a/src/statichost/StaticHost/Live/README.md +++ b/src/statichost/StaticHost/Live/README.md @@ -143,6 +143,24 @@ Subscribe verification still requires the matching `hub.verify_token`, challenge, and valid lease; repeated matching verifications retain the original renewal deadline. +Rejected verification callbacks log a fixed `RejectionReason` (`Malformed` or +`Unexpected`), a `ModeClassification` (`Subscribe`, `Unsubscribe`, or `Unknown`), +`TopicPresent`, and nullable `MatchesConfiguredTopic`. Neither submitted modes +nor topics enter the formatted message or structured fields. Topic correlation +is diagnostic only, not callback authorization. + +Twitch callback diagnostics retain rejected timestamps, unknown message types, +and replay/in-progress message IDs as sanitized fields: values are bounded to +128 characters (including an ellipsis when truncated), and characters outside +ASCII letters, digits, `_`, `.`, `:`, +`+`, and `-` are replaced with `_`. This preserves ordinary RFC3339 timestamps +and message-type names without letting line separators or control characters +forge log entries. Revocations retain the subscription ID, type, and status using +the same sanitizer, but omit the full body, transport URLs, and any other payload +fields. Invalid revocation JSON still returns the original acknowledgment and +logs that details were unavailable. Signature validation, freshness checks, +replay coordination, challenge responses, and HTTP statuses are unchanged. + Successful discovery logs `LastSuccessfulDiscoveryAt`, `LastDiscoveryLive`, and `NextDiscoveryAt`. The worker includes its last successful discovery time and result in subsequent failure logs, so operators can distinguish a diff --git a/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs b/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs index 68dd2424b..8b6b604d0 100644 --- a/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs +++ b/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; namespace StaticHost.Live.Twitch; @@ -56,6 +57,42 @@ public static bool IsFresh(string timestamp, DateTimeOffset now, TimeSpan maxAge return age <= maxAge && age >= TimeSpan.FromMinutes(-1); } + internal static string SanitizeDiagnosticValue(string value) + { + const int maxLength = 128; + var bounded = value.Length > maxLength ? value[..(maxLength - 3)] + "..." : value; + return Regex.Replace(bounded, "[^a-zA-Z0-9_.:+-]", "_"); + } + + private static void LogRevocation(string bodyJson, ILogger logger) + { + try + { + using var document = JsonDocument.Parse(bodyJson); + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("subscription", out var subscription) || + subscription.ValueKind != JsonValueKind.Object) + { + logger.LogWarning("Twitch EventSub subscription revoked; subscription details unavailable."); + return; + } + + // Retain the subscription state, not transport URLs or other payload data. + logger.LogWarning( + "Twitch EventSub subscription revoked (Id: {SubscriptionId}, Type: {SubscriptionType}, Status: {SubscriptionStatus}).", + LogField("id"), LogField("type"), LogField("status")); + + string LogField(string name) => + subscription.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? SanitizeDiagnosticValue(value.GetString()!) + : ""; + } + catch (JsonException) + { + logger.LogWarning("Twitch EventSub subscription revoked; payload is not valid JSON."); + } + } + /// /// Branches on Twitch-Eventsub-Message-Type: /// @@ -121,10 +158,11 @@ await broadcaster.UpdateAsync( return Results.Ok(); } case "revocation": - logger.LogWarning("Twitch EventSub subscription revoked: {Body}", bodyJson); + LogRevocation(bodyJson, logger); return Results.NoContent(); default: - logger.LogDebug("Twitch webhook of unknown message-type {Type}", messageType); + logger.LogDebug("Twitch webhook of unknown message type {MessageType} (sanitized).", + SanitizeDiagnosticValue(messageType)); return Results.Ok(); } } diff --git a/tests/StaticHost.Tests/Live/LiveEndpointsTests.cs b/tests/StaticHost.Tests/Live/LiveEndpointsTests.cs index e6edbed22..15006b069 100644 --- a/tests/StaticHost.Tests/Live/LiveEndpointsTests.cs +++ b/tests/StaticHost.Tests/Live/LiveEndpointsTests.cs @@ -156,6 +156,129 @@ await server.Broadcaster.UpdateAsync(new LiveStatusUpdate Assert.Equal(HttpStatusCode.OK, replayResponse.StatusCode); AssertNoStore(replayResponse); Assert.False((await server.Broadcaster.GetCurrentAsync()).Twitch.Live); + Assert.Contains(server.Logs.Entries, entry => + entry.Fields.TryGetValue("MessageId", out var id) && Equals(id, "test-message-1")); + } + + [Fact] + public async Task TwitchWebhook_SignedUnparseableTimestamp_IsRejectedWithSanitizedHeader() + { + await using var server = await LiveHttpServer.StartAsync(); + using var request = TwitchRequest(server, "notification", "{}", + timestamp: LiveTestHelpers.LogInjectionPayload); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + AssertNoStore(response); + Assert.False((await server.Broadcaster.GetCurrentAsync()).IsLive); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["Timestamp"]); + Assert.Equal("Twitch webhook timestamp payload-sentinel__forged-line___ (sanitized) is stale or unparseable; rejecting.", + entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + + [Theory] + [InlineData(-11)] + [InlineData(5)] + public async Task TwitchWebhook_RejectedTimestamp_RetainsDiagnosticValue(int minutesFromNow) + { + await using var server = await LiveHttpServer.StartAsync(); + var timestamp = server.Time.GetUtcNow().AddMinutes(minutesFromNow).ToString("O"); + using var request = TwitchRequest(server, "notification", "{}", timestamp: timestamp); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + AssertNoStore(response); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(timestamp, entry.Fields["Timestamp"]); + Assert.Contains(timestamp, entry.Message); + LiveTestHelpers.AssertSafeLogs(server.Logs); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task TwitchWebhook_SignedDuplicateId_IsLoggedSafely(bool completed) + { + await using var server = await LiveHttpServer.StartAsync(); + const string messageId = LiveTestHelpers.LogInjectionPayload; + var acquisition = await server.Coordination.AcquireTwitchMessageAsync(messageId); + Assert.Equal(TwitchMessageAcquisitionStatus.Acquired, acquisition.Status); + await using (var lease = Assert.IsAssignableFrom(acquisition.Lease)) + { + if (completed) await lease.CompleteAsync(); + using var request = TwitchRequest(server, "notification", "{}", messageId: messageId); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(completed ? HttpStatusCode.OK : HttpStatusCode.ServiceUnavailable, response.StatusCode); + AssertNoStore(response); + Assert.False((await server.Broadcaster.GetCurrentAsync()).IsLive); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(LogLevel.Debug, entry.Level); + Assert.Contains(completed ? "replay ignored" : "already being processed", entry.Message); + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["MessageId"]); + Assert.Equal(completed + ? "Twitch webhook replay ignored for message payload-sentinel__forged-line___." + : "Twitch webhook message payload-sentinel__forged-line___ is already being processed; asking Twitch to retry.", + entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + + server.Logs.Entries.Clear(); + using var retry = TwitchRequest(server, "notification", + """{"subscription":{"type":"stream.online"},"event":{"broadcaster_user_login":"aspiredotdev"}}""", + messageId: messageId); + using var retryResponse = await server.Client.SendAsync(retry); + Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode); + Assert.Equal(!completed, (await server.Broadcaster.GetCurrentAsync()).Twitch.Live); + if (completed) + { + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["MessageId"]); + Assert.Equal("Twitch webhook replay ignored for message payload-sentinel__forged-line___.", entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + else + { + LiveTestHelpers.AssertSafeLogs(server.Logs); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task TwitchWebhook_SignedRevocationAndUnknownType_LogSafely(bool revocation) + { + await using var server = await LiveHttpServer.StartAsync(); + var before = await server.Broadcaster.GetStateAsync(); + using var request = TwitchRequest( + server, revocation ? "revocation" : LiveTestHelpers.LogInjectionPayload, + LiveTestHelpers.LogInjectionPayload); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(revocation ? HttpStatusCode.NoContent : HttpStatusCode.OK, response.StatusCode); + AssertNoStore(response); + Assert.Equal(before, await server.Broadcaster.GetStateAsync()); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(revocation ? LogLevel.Warning : LogLevel.Debug, entry.Level); + Assert.Contains(revocation ? "subscription revoked" : "unknown message type", entry.Message); + if (revocation) + { + LiveTestHelpers.AssertSafeLogs(server.Logs); + } + else + { + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["MessageType"]); + Assert.Equal("Twitch webhook of unknown message type payload-sentinel__forged-line___ (sanitized).", + entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } } [Fact] @@ -176,6 +299,7 @@ public async Task YouTubeVerification_RetriesEchoNewChallengeWithoutExtendingRen AssertNoStore(retry); Assert.Equal("retry", await retry.Content.ReadAsStringAsync()); Assert.Equal(renewAt, await server.Subscriptions.GetRenewAtAsync()); + LiveTestHelpers.AssertSafeLogs(server.Logs, pending.Topic, pending.VerifyToken); } [Theory] @@ -201,6 +325,64 @@ public async Task YouTubeVerification_RejectsMalformedOrUnsolicitedConfirmation( Assert.Equal(DateTimeOffset.MinValue, await server.Subscriptions.GetRenewAtAsync()); } + [Theory] + [InlineData("mode", true)] + [InlineData("unsubscribe", true)] + [InlineData("missing-topic", true)] + [InlineData("topic", true)] + [InlineData("topic", false)] + [InlineData("token", true)] + public async Task YouTubeVerification_RejectionLogsOnlyClassificationsAndSafeFields( + string invalid, bool configured) + { + const string invalidVerifyToken = "verification-secret-sentinel\r\nforged-line\u0085\u2028\u2029"; + await using var server = await LiveHttpServer.StartAsync(youtubeConfigured: configured); + var pending = Assert.IsType( + await server.Subscriptions.TryBeginSubscriptionAsync("channel-123", server.Time.GetUtcNow())); + var mode = invalid switch + { + "mode" => LiveTestHelpers.LogInjectionPayload, + "unsubscribe" => "unsubscribe", + _ => "subscribe", + }; + var submitted = pending with + { + Topic = invalid switch + { + "missing-topic" => "", + "token" => pending.Topic, + _ => LiveTestHelpers.LogInjectionPayload, + }, + VerifyToken = invalid == "token" ? invalidVerifyToken : pending.VerifyToken, + }; + var before = server.Subscriptions.Current; + using var response = await server.Client.GetAsync( + VerificationUrl(submitted, LiveTestHelpers.LogInjectionPayload, mode: mode)); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + AssertNoStore(response); + Assert.Equal(before, server.Subscriptions.Current); + Assert.False((await server.Broadcaster.GetCurrentAsync()).IsLive); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Equal("WebSubVerification", entry.Fields["Operation"]); + Assert.Equal(invalid is "mode" or "unsubscribe" or "missing-topic" ? "Malformed" : "Unexpected", + entry.Fields["RejectionReason"]); + Assert.Equal(invalid == "mode" ? "Unknown" : invalid == "unsubscribe" ? "Unsubscribe" : "Subscribe", + entry.Fields["ModeClassification"]); + Assert.Equal(invalid != "missing-topic", entry.Fields["TopicPresent"]); + Assert.Equal(configured ? (bool?)(invalid == "token") : null, entry.Fields["MatchesConfiguredTopic"]); + LiveTestHelpers.AssertSafeLogs(server.Logs, pending.Topic, pending.VerifyToken, "verification-secret-sentinel"); + + using var confirmation = await server.Client.GetAsync( + VerificationUrl(pending, LiveTestHelpers.LogInjectionPayload)); + Assert.Equal(HttpStatusCode.OK, confirmation.StatusCode); + Assert.Equal("text/plain", confirmation.Content.Headers.ContentType?.MediaType); + Assert.Equal(LiveTestHelpers.LogInjectionPayload, await confirmation.Content.ReadAsStringAsync()); + Assert.True(await server.Subscriptions.GetRenewAtAsync() > server.Time.GetUtcNow()); + LiveTestHelpers.AssertSafeLogs(server.Logs, pending.Topic, pending.VerifyToken, "verification-secret-sentinel"); + } + [Theory] [InlineData("valid")] [InlineData("invalid")] @@ -365,17 +547,17 @@ private static HttpRequestMessage DevRequest(bool correctSecret) } private static HttpRequestMessage TwitchRequest( - LiveHttpServer server, string messageType, string body, bool stale = false) + LiveHttpServer server, string messageType, string body, bool stale = false, + string messageId = "test-message-1", string? timestamp = null) { - const string messageId = "test-message-1"; - var timestamp = server.Time.GetUtcNow().AddMinutes(stale ? -11 : 0).ToString("O"); + timestamp ??= server.Time.GetUtcNow().AddMinutes(stale ? -11 : 0).ToString("O"); var request = new HttpRequestMessage(HttpMethod.Post, "/api/live/twitch/webhook") { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; - request.Headers.Add("Twitch-Eventsub-Message-Id", messageId); - request.Headers.Add("Twitch-Eventsub-Message-Timestamp", timestamp); - request.Headers.Add("Twitch-Eventsub-Message-Type", messageType); + request.Headers.TryAddWithoutValidation("Twitch-Eventsub-Message-Id", messageId); + request.Headers.TryAddWithoutValidation("Twitch-Eventsub-Message-Timestamp", timestamp); + request.Headers.TryAddWithoutValidation("Twitch-Eventsub-Message-Type", messageType); request.Headers.Add("Twitch-Eventsub-Message-Signature", "sha256=" + Convert.ToHexStringLower(HMACSHA256.HashData( Encoding.UTF8.GetBytes(LiveHttpServer.WebhookSecret), @@ -386,8 +568,8 @@ private static HttpRequestMessage TwitchRequest( private static string VerificationUrl( YouTubeWebSubSubscriptionRequest pending, string challenge, string lease = "432000", string mode = "subscribe") => - $"/api/live/youtube/webhook?hub.mode={mode}&hub.topic={Uri.EscapeDataString(pending.Topic)}" + - $"&hub.verify_token={pending.VerifyToken}&hub.lease_seconds={lease}&hub.challenge={challenge}"; + $"/api/live/youtube/webhook?hub.mode={Uri.EscapeDataString(mode)}&hub.topic={Uri.EscapeDataString(pending.Topic)}" + + $"&hub.verify_token={Uri.EscapeDataString(pending.VerifyToken)}&hub.lease_seconds={lease}&hub.challenge={Uri.EscapeDataString(challenge)}"; private sealed class LiveHttpServer( WebApplication app, HttpClient client, FakeTimeProvider time, @@ -413,7 +595,8 @@ public static async Task StartAsync( }); builder.WebHost.UseTestServer(); var logs = new YouTubeRecordingLogger(); - builder.Logging.AddProvider(new YouTubeLoggerProvider(logs)); + builder.Logging.AddFilter(level => level >= LogLevel.Debug); + builder.Logging.AddProvider(new WebhookLoggerProvider(logs)); var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); var options = new LiveStatusOptions { @@ -458,10 +641,11 @@ public static async Task StartAsync( return new LiveHttpServer(app, app.GetTestClient(), time, streamEnded, logs); } - private sealed class YouTubeLoggerProvider(ILogger logger) : ILoggerProvider + private sealed class WebhookLoggerProvider(ILogger logger) : ILoggerProvider { public ILogger CreateLogger(string categoryName) => - categoryName.StartsWith("StaticHost.Live.YouTube.", StringComparison.Ordinal) + categoryName.StartsWith("StaticHost.Live.YouTube.", StringComparison.Ordinal) || + categoryName == "StaticHost.Live.Twitch.Webhook" ? logger : NullLogger.Instance; public void Dispose() { } } diff --git a/tests/StaticHost.Tests/Live/LiveTestHelpers.cs b/tests/StaticHost.Tests/Live/LiveTestHelpers.cs index 482057710..8d5a3ad92 100644 --- a/tests/StaticHost.Tests/Live/LiveTestHelpers.cs +++ b/tests/StaticHost.Tests/Live/LiveTestHelpers.cs @@ -2,6 +2,38 @@ namespace StaticHost.Tests.Live; internal static class LiveTestHelpers { + // Non-secret fixture for testing log injection, distinct from verification credentials. + public const string LogInjectionPayload = "payload-sentinel\r\nforged-line\u0085\u2028\u2029"; + + public static void AssertSafeLogs(YouTubeRecordingLogger logger, params string[] omittedValues) + { + Assert.NotEmpty(logger.Entries); + foreach (var entry in logger.Entries) + { + Assert.Null(entry.Exception); + AssertSafeText(entry.Message); + foreach (var field in entry.Fields) + { + AssertSafeText(field.Key); + AssertSafeText(field.Value?.ToString() ?? ""); + } + } + + void AssertSafeText(string text) + { + Assert.DoesNotContain("payload-sentinel", text); + Assert.DoesNotContain("forged-line", text); + foreach (var separator in new[] { '\r', '\n', '\u0085', '\u2028', '\u2029' }) + { + Assert.DoesNotContain(separator, text); + } + foreach (var value in omittedValues) + { + Assert.DoesNotContain(value, text); + } + } + } + public static LiveStatusBroadcaster CreateBroadcaster( int coalesceMs = 0, TimeProvider? timeProvider = null) diff --git a/tests/StaticHost.Tests/Live/TwitchWebhookHandlerTests.cs b/tests/StaticHost.Tests/Live/TwitchWebhookHandlerTests.cs index 42cba2071..cc19d6f61 100644 --- a/tests/StaticHost.Tests/Live/TwitchWebhookHandlerTests.cs +++ b/tests/StaticHost.Tests/Live/TwitchWebhookHandlerTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; + namespace StaticHost.Tests.Live; public sealed class TwitchWebhookHandlerTests @@ -154,6 +156,141 @@ public void IsFresh_ReturnsFalseForUnparseableTimestamp(string timestamp) Assert.False(TwitchWebhookHandler.IsFresh(timestamp, now, TimeSpan.FromMinutes(10))); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Handle_UntrustedRevocationAndUnknownType_LogSafely(bool revocation) + { + var broadcaster = LiveTestHelpers.CreateBroadcaster(); + var before = await broadcaster.GetStateAsync(); + var logger = new YouTubeRecordingLogger(); + var result = await TwitchWebhookHandler.HandleAsync( + revocation ? "revocation" : LiveTestHelpers.LogInjectionPayload, + LiveTestHelpers.LogInjectionPayload, + broadcaster, + new TwitchOptions(), + logger); + using var services = new ServiceCollection().AddLogging().BuildServiceProvider(); + var context = new DefaultHttpContext + { + RequestServices = services, + }; + context.Response.Body = new MemoryStream(); + + await result.ExecuteAsync(context); + + Assert.Equal(revocation ? StatusCodes.Status204NoContent : StatusCodes.Status200OK, + context.Response.StatusCode); + Assert.Equal(before, await broadcaster.GetStateAsync()); + var entry = Assert.Single(logger.Entries); + Assert.Equal(revocation ? LogLevel.Warning : LogLevel.Debug, + entry.Level); + Assert.Contains(revocation ? "subscription revoked" : "unknown message type", entry.Message); + if (revocation) + { + LiveTestHelpers.AssertSafeLogs(logger); + } + else + { + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["MessageType"]); + Assert.Equal("Twitch webhook of unknown message type payload-sentinel__forged-line___ (sanitized).", + entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + } + + [Theory] + [InlineData("new_message_type", "new_message_type")] + [InlineData("new.message-type:1", "new.message-type:1")] + [InlineData("type\r\nother\u0085\u2028\u2029\u001b[31m", "type__other_____31m")] + public async Task Handle_UnknownMessageType_RetainsSanitizedDiagnosticValue(string messageType, string expected) + { + var logger = new YouTubeRecordingLogger(); + await TwitchWebhookHandler.HandleAsync(messageType, "{}", + LiveTestHelpers.CreateBroadcaster(), new TwitchOptions(), logger); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(expected, entry.Fields["MessageType"]); + Assert.Equal($"Twitch webhook of unknown message type {expected} (sanitized).", entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + + [Fact] + public async Task Handle_UnknownMessageType_BoundsDiagnosticLength() + { + var logger = new YouTubeRecordingLogger(); + await TwitchWebhookHandler.HandleAsync(new string('a', 200), "{}", + LiveTestHelpers.CreateBroadcaster(), new TwitchOptions(), logger); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(new string('a', 125) + "...", entry.Fields["MessageType"]); + } + + [Theory] + [InlineData("authorization_revoked")] + [InlineData("user_removed")] + [InlineData("version_removed")] + public async Task Handle_Revocation_RetainsUsefulSubscriptionDetailsOnly(string status) + { + var broadcaster = LiveTestHelpers.CreateBroadcaster(); + var before = await broadcaster.GetStateAsync(); + var logger = new YouTubeRecordingLogger(); + var body = $$""" + {"subscription":{ + "id":"subscription-123", + "type":"stream.online", + "status":"{{status}}", + "transport":{"callback":"https://example.invalid/callback?key=must-not-appear"} + },"extra":"body-only-marker"} + """; + + var result = await TwitchWebhookHandler.HandleAsync( + "revocation", body, broadcaster, new TwitchOptions(), logger); + + Assert.Equal(StatusCodes.Status204NoContent, Assert.IsAssignableFrom(result).StatusCode); + Assert.Equal(before, await broadcaster.GetStateAsync()); + var entry = Assert.Single(logger.Entries); + Assert.Equal("subscription-123", entry.Fields["SubscriptionId"]); + Assert.Equal("stream.online", entry.Fields["SubscriptionType"]); + Assert.Equal(status, entry.Fields["SubscriptionStatus"]); + Assert.Equal(4, entry.Fields.Count); + LiveTestHelpers.AssertSafeLogs(logger, "must-not-appear", "example.invalid", "body-only-marker"); + } + + [Fact] + public async Task Handle_Revocation_SanitizesSubscriptionDetails() + { + var logger = new YouTubeRecordingLogger(); + const string body = """{"subscription":{"id":"id\r\nnext","type":"stream.\u001bonline","status":"status\u2028next"}}"""; + + await TwitchWebhookHandler.HandleAsync("revocation", body, + LiveTestHelpers.CreateBroadcaster(), new TwitchOptions(), logger); + + var entry = Assert.Single(logger.Entries); + Assert.Equal("id__next", entry.Fields["SubscriptionId"]); + Assert.Equal("stream._online", entry.Fields["SubscriptionType"]); + Assert.Equal("status_next", entry.Fields["SubscriptionStatus"]); + LiveTestHelpers.AssertSafeLogs(logger); + } + + [Theory] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("{\"subscription\":null}")] + [InlineData("{\"subscription\":{\"id\":42,\"type\":[],\"status\":false}}")] + public async Task Handle_Revocation_MissingDetailsStillAcknowledges(string body) + { + var logger = new YouTubeRecordingLogger(); + var result = await TwitchWebhookHandler.HandleAsync("revocation", body, + LiveTestHelpers.CreateBroadcaster(), new TwitchOptions(), logger); + + Assert.Equal(StatusCodes.Status204NoContent, Assert.IsAssignableFrom(result).StatusCode); + Assert.Single(logger.Entries); + LiveTestHelpers.AssertSafeLogs(logger); + } + private static string ComputeTwitchSignature(string secret, string messageId, string timestamp, byte[] body) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));