fix(security): client href scheme check, outbound fetch timeouts, origin-scoped CORS (closes task/resolver-security-hardening)

Source: task/resolver-security-hardening (plan/steward-linker-security) — defense-in-depth: client trusted resolver href, outbound fetches had no timeout, CORS was blanket-*.

- F1: extension/content.js makeLink re-validates primary.url scheme via a new
  pure Extract.isSafeHttpUrl helper (in extract.js, unit-tested); a non-http(s)
  url (javascript:/data:/garbage) is dropped and the plain text is kept.
- F2: each of the three resolver outbound fetches (Spotify token, Spotify
  search, ollama /api/chat) now carries signal: AbortSignal.timeout(5000) so a
  hung upstream fails fast (caught per-candidate -> null), zero-dep.
- F3: resolver CORS reflects the request Origin only for moz-extension://* or
  null (the extension's background-script origin), with Vary: Origin, instead
  of blanket access-control-allow-origin: *.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
paul
2026-06-23 02:29:42 +00:00
co-authored by Claude Opus 4.8
parent 8496e55baf
commit 7adb99231a
4 changed files with 55 additions and 5 deletions
+16 -2
View File
@@ -51,6 +51,7 @@ async function spotifyToken() {
method: "POST",
headers: { authorization: `Basic ${auth}`, "content-type": "application/x-www-form-urlencoded" },
body: "grant_type=client_credentials",
signal: AbortSignal.timeout(5000), // fail fast on a hung upstream (caught per-candidate -> null)
});
if (!r.ok) throw new Error(`spotify token http ${r.status}`);
const j = await r.json();
@@ -67,7 +68,10 @@ async function search(name) {
if (!USE_SPOTIFY) return { artist: null, album: null };
const token = await spotifyToken();
const url = `https://api.spotify.com/v1/search?type=artist,album&limit=5&q=${encodeURIComponent(name)}`;
const r = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
const r = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000), // fail fast on a hung upstream (caught per-candidate -> null)
});
if (!r.ok) throw new Error(`spotify search http ${r.status}`);
const j = await r.json();
return {
@@ -88,6 +92,7 @@ async function ollamaClassify(candidates) {
format: "json",
options: { temperature: 0 },
}),
signal: AbortSignal.timeout(5000), // fail fast on a hung upstream (caught per-candidate -> null)
});
if (!r.ok) throw new Error(`ollama http ${r.status}`);
const j = await r.json();
@@ -119,8 +124,17 @@ const json = (res, code, obj) => {
res.end(JSON.stringify(obj));
};
// CORS scoped to the extension origin: the background script sends
// `Origin: moz-extension://<uuid>`; a page-context fetch (file://, sandboxed) can send
// `Origin: null`. Reflect only those — never blanket `*` — so an arbitrary website the
// user visits can't POST to localhost:8787 and read resolver results.
const allowedOrigin = (origin) =>
typeof origin === "string" && (origin.startsWith("moz-extension://") || origin === "null");
const server = createServer(async (req, res) => {
res.setHeader("access-control-allow-origin", "*");
const origin = req.headers.origin;
if (allowedOrigin(origin)) res.setHeader("access-control-allow-origin", origin);
res.setHeader("vary", "origin"); // the allow-origin header varies by request Origin
res.setHeader("access-control-allow-headers", "content-type");
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }