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:
+16
-2
@@ -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(); }
|
||||
|
||||
Reference in New Issue
Block a user