mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Two spellings the egress guard could not see, both probed.
src/proxy.ts, src/instrumentation.ts and src/cli/ sat outside the enforced
roots (src/lib/ and src/app/), and the companion api-fetch-required guards
the client surface, so it did not reach them either. A raw fetch dropped
into instrumentation.ts linted clean. All three are egress-free today, but
instrumentation is exactly where an exporter pointed at an operator-supplied
URL would land.
The callee match knew the identifier `fetch` and the globalThis / window /
self member forms only, so an aliased import escaped it in every file:
`import { fetch as f } from "undici"` then `f(url)` linted clean under
src/lib. That spelling is what src/lib/safe-fetch.ts itself writes. Local
names bound to an imported `fetch`, and namespace-imported `ns.fetch(…)`,
are now callees; the wrapper internals and test files stay exempt as
before, and the wrapper's own aliased import is pinned as a valid case.
Adds the rule's missing unit-test file. The header records what the
binding scan still cannot see: require(), dynamic import, a fetch passed
as a parameter, and egress that never spells fetch at all.
201 lines
8.0 KiB
JavaScript
201 lines
8.0 KiB
JavaScript
/**
|
|
* @fileoverview ESLint rule — outbound `fetch` must route through the
|
|
* `safeFetch` wrapper.
|
|
*
|
|
* v1.5.6 — `src/lib/safe-fetch.ts` is the documented egress entry point:
|
|
* it pins `redirect: "manual"` + an `AbortSignal.timeout` by default and
|
|
* gates the connect-time IP pin behind `requirePublicHost`. A raw
|
|
* `fetch(...)` call under `src/lib/` or `src/app/` bypasses every one of
|
|
* those defences, so this rule flags it at authoring time and in CI.
|
|
*
|
|
* Allowed:
|
|
* - `safeFetch(...)` — the wrapper itself.
|
|
* - calls inside `src/lib/safe-fetch.ts` — the wrapper's own `fetch`.
|
|
* - calls inside `src/lib/safe-fetch-dispatcher.ts` — the pinned dispatcher.
|
|
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
|
|
* mock or assert against `fetch` directly.
|
|
*
|
|
* The check is a syntactic `CallExpression` match against a bare
|
|
* `fetch(` callee (Identifier `fetch`), the `globalThis.fetch(` /
|
|
* `window.fetch(` / `self.fetch(` member forms, and — since 2026-09-03 —
|
|
* any local name bound to a module's `fetch` export or reached through a
|
|
* namespace import. `safeFetch` is a distinct identifier and never matches.
|
|
*
|
|
* The aliased spelling is not hypothetical: `src/lib/safe-fetch.ts` opens
|
|
* with `import { fetch as undiciFetch } from "undici"`, so the codebase
|
|
* demonstrably reaches for it, and every copy of that spelling outside the
|
|
* wrapper escaped the rule in every file.
|
|
*
|
|
* Limits, so a green run is not read as more than it is: the binding scan
|
|
* walks top-level `import` declarations only. A `require("undici").fetch`,
|
|
* a dynamic `await import(...)`, a fetch handed through a parameter or
|
|
* stored on an object, and a local shadow of an imported name are all
|
|
* outside it — as is any egress that never spells `fetch` at all, such as
|
|
* `http.request` or a client library with its own transport.
|
|
*
|
|
* Same-origin client fetches are exempt: a first argument that is a
|
|
* string literal (or template head) starting with `/` is a relative,
|
|
* same-origin request (`fetch("/api/…")`) that never leaves the origin
|
|
* and has no SSRF / redirect-leak / DNS-rebinding surface. The wrapper
|
|
* is for OUTBOUND egress to external hosts — absolute URLs or
|
|
* variable-sourced targets — which is exactly what stays flagged.
|
|
*
|
|
* @see src/lib/safe-fetch.ts
|
|
* @see src/lib/safe-fetch-dispatcher.ts
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
// Files exempt from the rule — the wrapper internals and the test
|
|
// surface. Posix-style suffix matches against the absolute filename via
|
|
// `String#includes`, mirroring the queryKey-factory rule's convention.
|
|
const EXEMPT_FILES = [
|
|
"src/lib/safe-fetch.ts",
|
|
"src/lib/safe-fetch-dispatcher.ts",
|
|
// The same-origin `/api/...` envelope wrapper: its `fetch` calls are
|
|
// relative-path by contract (the api-fetch-required rule pins every
|
|
// client call site onto it), so the egress defences don't apply.
|
|
"src/lib/api/api-fetch.ts",
|
|
];
|
|
|
|
// Only enforce inside the application source. Scripts, config, and generated
|
|
// code are out of scope.
|
|
//
|
|
// The list carries three single files alongside the two directory roots.
|
|
// `src/proxy.ts`, `src/instrumentation.ts` and `src/cli/` sit outside
|
|
// `src/lib/` and `src/app/`, and the companion `healthlog/api-fetch-required`
|
|
// does not reach them either (it guards the client surface), so between them
|
|
// they were egress-unguarded. They are egress-free today; instrumentation is
|
|
// precisely where an exporter pointed at an operator-supplied URL would land,
|
|
// and nothing would have flagged it.
|
|
const ENFORCED_PATHS = [
|
|
"src/lib/",
|
|
"src/app/",
|
|
"src/proxy.ts",
|
|
"src/instrumentation.ts",
|
|
"src/cli/",
|
|
];
|
|
|
|
// Objects that carry the platform `fetch`. Treated as namespaces below so the
|
|
// member form goes through one code path with the imported-namespace case.
|
|
const GLOBAL_FETCH_OBJECTS = ["globalThis", "window", "self"];
|
|
|
|
function toPosix(filename) {
|
|
return filename.replace(/\\/g, "/");
|
|
}
|
|
|
|
function isTestFile(posix) {
|
|
return (
|
|
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
|
|
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
|
|
posix.includes("/__tests__/") ||
|
|
posix.includes("/__mocks__/")
|
|
);
|
|
}
|
|
|
|
function isEnforced(filename) {
|
|
const posix = toPosix(filename);
|
|
if (!ENFORCED_PATHS.some((path) => posix.includes(path))) return false;
|
|
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
|
|
if (isTestFile(posix)) return false;
|
|
return true;
|
|
}
|
|
|
|
/** @type {import("eslint").Rule.RuleModule} */
|
|
const safeFetchRequiredRule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Outbound fetch must route through the safeFetch wrapper (src/lib/safe-fetch.ts).",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
rawFetch:
|
|
'Raw fetch() bypasses the safeFetch wrapper\'s manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from "@/lib/safe-fetch" and call it instead.',
|
|
},
|
|
},
|
|
create(context) {
|
|
const filename = context.filename ?? context.getFilename?.();
|
|
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
if (!filename || !isEnforced(filename)) {
|
|
return {};
|
|
}
|
|
|
|
// Local names this module bound to somebody's `fetch`, collected from the
|
|
// top-level imports before any call site is visited. `import { fetch as f }
|
|
// from "undici"` makes `f(...)` a fetch call; `import * as undici from
|
|
// "undici"` makes `undici.fetch(...)` one.
|
|
const aliasedFetch = new Set();
|
|
const namespaces = new Set([...GLOBAL_FETCH_OBJECTS]);
|
|
for (const node of sourceCode.ast.body) {
|
|
if (node.type !== "ImportDeclaration") continue;
|
|
for (const specifier of node.specifiers) {
|
|
if (specifier.type === "ImportSpecifier") {
|
|
const imported =
|
|
specifier.imported.type === "Identifier"
|
|
? specifier.imported.name
|
|
: specifier.imported.value;
|
|
if (imported === "fetch") aliasedFetch.add(specifier.local.name);
|
|
} else if (specifier.type === "ImportNamespaceSpecifier") {
|
|
namespaces.add(specifier.local.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isFetchCallee(callee) {
|
|
// Bare `fetch(...)`, and any local name bound to an imported `fetch`.
|
|
if (callee.type === "Identifier") {
|
|
return callee.name === "fetch" || aliasedFetch.has(callee.name);
|
|
}
|
|
if (callee.type !== "MemberExpression") return false;
|
|
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)` and
|
|
// `<namespace>.fetch(...)`, including the static computed spelling.
|
|
if (
|
|
callee.object.type !== "Identifier" ||
|
|
!namespaces.has(callee.object.name)
|
|
) {
|
|
return false;
|
|
}
|
|
const property = !callee.computed
|
|
? callee.property.type === "Identifier"
|
|
? callee.property.name
|
|
: null
|
|
: callee.property.type === "Literal"
|
|
? callee.property.value
|
|
: null;
|
|
return property === "fetch";
|
|
}
|
|
|
|
function isSameOriginRelative(arg) {
|
|
if (!arg) return false;
|
|
// A single leading `/` not followed by `/` or `\`. `//evil.com` is a
|
|
// protocol-relative absolute URL and `/\evil.com` normalises to the
|
|
// same under WHATWG parsing — both resolve off-origin, so neither is
|
|
// a same-origin path and the wrapper must still cover them.
|
|
const isRelativeHead = (head) => /^\/(?![/\\])/.test(head);
|
|
// `fetch("/api/…")`
|
|
if (arg.type === "Literal" && typeof arg.value === "string") {
|
|
return isRelativeHead(arg.value);
|
|
}
|
|
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
|
|
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
|
|
return isRelativeHead(arg.quasis[0].value.cooked ?? "");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return {
|
|
CallExpression(node) {
|
|
if (!isFetchCallee(node.callee)) return;
|
|
// Exempt same-origin relative-path requests — they never leave
|
|
// the origin, so the wrapper's outbound defences do not apply.
|
|
if (isSameOriginRelative(node.arguments[0])) return;
|
|
context.report({ node, messageId: "rawFetch" });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
module.exports = safeFetchRequiredRule;
|