import { isFetchAllowed } from "/js/plugins/pluginPermissions.js"; const ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]; const FORBIDDEN_HEADERS = ["authorization", "cookie"]; const MAX_BODY_CHARS = 1_000_000; export async function pluginFetch( plugin, url, init, fetchImpl = fetch.bind(globalThis), ) { if (!isFetchAllowed(url, plugin.permissions)) { throw new Error(`fetch to "${url}" not permitted`); } return sandboxedFetch(url, init, { forbiddenHeaders: FORBIDDEN_HEADERS, maxBodyChars: MAX_BODY_CHARS, fetchImpl, }); } // Shared hardening for any plugin-initiated fetch, regardless of which // permission model authorized the destination URL: method allowlist, a // caller-supplied forbidden-header set, a body-size cap, and the same // credential/redirect/referrer lockdown — only content-type is ever handed // back from the response. `forbiddenHeaders` differs by caller: ordinary // plugin fetch (pluginFetch, above) forbids Authorization (it could only // ever be forwarding an ambient credential the plugin has no business // touching) and Cookie; the user-approved single-endpoint path // (pluginConfiguredFetch.js) allows Authorization through, since there the // header value is the plugin's own credential for a URL a human explicitly // approved, not anything ambient. export async function sandboxedFetch( url, init, { forbiddenHeaders, maxBodyChars, fetchImpl = fetch.bind(globalThis) }, ) { const response = await fetchImpl(url, { ...sanitizeFetchInit(init, { forbiddenHeaders, maxBodyChars }), credentials: "omit", redirect: "error", mode: "cors", referrerPolicy: "no-referrer", }); const bodyText = await response.text(); return { status: response.status, ok: response.ok, headers: filterResponseHeaders(response.headers, ["content-type"]), body: bodyText, }; } export function sanitizeFetchInit(init, { forbiddenHeaders, maxBodyChars }) { const safeInit = {}; const method = (init?.method ?? "GET").toUpperCase(); if (!ALLOWED_METHODS.includes(method)) { throw new Error(`fetch method "${method}" not permitted`); } safeInit.method = method; const headers = {}; for (const [name, value] of Object.entries(init?.headers ?? {})) { const lowerName = String(name).toLowerCase(); if (forbiddenHeaders.includes(lowerName)) { throw new Error(`fetch header "${name}" not permitted`); } headers[name] = String(value); } safeInit.headers = headers; if (init?.body != null) { if (typeof init.body !== "string") { throw new Error("fetch body must be a string"); } if (init.body.length > maxBodyChars) { throw new Error("fetch body too large"); } safeInit.body = init.body; } return safeInit; } function filterResponseHeaders(headers, allowedNames) { const picked = {}; for (const name of allowedNames) { const value = headers.get(name); if (value != null) picked[name] = value; } return picked; }