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(/`;
@@ -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 = "&";${tag} >
+ `)).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));