Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"access": "public"
},
"scripts": {
"ci:local": "npm run dashboard:build && npm --prefix dashboard run test && npm test && npm run validate:copy && npm run validate:ui-hardcode && npm run validate:guardrails && npm run docs:openwiki:check && node --test test/architecture-guardrails.test.js",
"ci:local": "npm run dashboard:build && npm --prefix dashboard run test && npm test && npm run validate:copy && npm run validate:ui-hardcode && npm run validate:guardrails && npm run validate:curated-expiry && npm run docs:openwiki:check && node --test test/architecture-guardrails.test.js",
"copy:pull": "node scripts/copy-sync.cjs pull",
"copy:push": "node scripts/copy-sync.cjs push",
"dashboard:build": "npm --prefix dashboard run build",
Expand All @@ -42,6 +42,7 @@
"prepublishOnly": "node scripts/build-pricing-seed.cjs",
"test": "node --test test/*.test.js",
"validate:copy": "node scripts/validate-copy-registry.cjs",
"validate:curated-expiry": "node scripts/validate-curated-expiry.cjs",
"validate:guardrails": "node scripts/validate-architecture-guardrails.cjs",
"validate:retros": "node scripts/validate-retros.cjs",
"validate:ui-hardcode": "node scripts/ops/validate-ui-hardcode.cjs"
Expand Down
145 changes: 145 additions & 0 deletions scripts/validate-curated-expiry.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Fails when a time-boxed pricing fact in curated-overrides.json has passed its
// expiry date.
//
// Why this exists: `_meta` used to carry expiry dates as free-text prose ("update
// this file before the cutover"). Nothing read them, so deepseek-v4-pro stayed
// pinned to a 75%-off launch promo for 55 days after the promo ended — every
// DeepSeek row on the dashboard billed at 25% of its true cost, with no signal.
// A stale price is worse than a missing one: a missing price shows $0 and looks
// broken, a stale price looks fine forever. See issue #87.
//
// This turns "remember to edit a JSON file in May" into "the next PR fails".

const fs = require("fs");
const path = require("path");

const ROOT = path.resolve(__dirname, "..");
const OVERRIDES_PATH = path.join(ROOT, "src", "lib", "pricing", "curated-overrides.json");

const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// Any YYYY-MM-DD appearing anywhere inside a free-text note.
const DATE_IN_TEXT_RE = /\d{4}-\d{2}-\d{2}/;
const REQUIRED_FIELDS = ["id", "expires_at", "what", "action"];

// An expiry is due at UTC midnight on its date, so an entry dated 2026-08-31
// fails from the first moment of 2026-08-31 onward.
function parseExpiryMs(value) {
if (typeof value !== "string" || !DATE_RE.test(value)) return null;
const ms = Date.parse(`${value}T00:00:00Z`);
if (!Number.isFinite(ms)) return null;
// Reject dates that round-trip differently (e.g. 2026-02-31 → Mar 3).
if (new Date(ms).toISOString().slice(0, 10) !== value) return null;
return ms;
}

// Walks a _meta value of any shape so a date cannot hide one level down.
function collectStrings(value, out = []) {
if (typeof value === "string") out.push(value);
else if (Array.isArray(value)) value.forEach((v) => collectStrings(v, out));
else if (value && typeof value === "object") Object.values(value).forEach((v) => collectStrings(v, out));
return out;
}

function isNonEmptyString(value) {
return typeof value === "string" && value.trim() !== "";
}

// Pure: takes the parsed `_meta` object and a timestamp, returns findings.
// Exported so tests can drive it without touching the clock or the real file.
function checkExpiries(meta, nowMs) {
const errors = [];

if (meta == null || typeof meta !== "object") {
return { errors: ["_meta is missing or not an object"], checked: 0 };
}

// Guard against regressing to the pattern this check replaced. Matching on
// the key name alone was too weak — `promo_cutover: "2026-08-31 — update the
// price"` would sail straight past a `*_expiry` name check and expire in
// silence, which is the exact failure being designed out. So scan the VALUES:
// any date-looking string parked in _meta is a time-boxed fact that belongs
// in `expiries`, whatever its key is called.
for (const [key, value] of Object.entries(meta)) {
if (key === "expiries") continue;
for (const text of collectStrings(value)) {
if (DATE_IN_TEXT_RE.test(text)) {
errors.push(
`_meta.${key}: contains a date ("${text.slice(0, 60).trim()}…") but nothing enforces it. `
+ "Move the fact into the _meta.expiries array, or drop the date from the note.",
);
break;
}
}
}

const entries = meta.expiries;
if (entries === undefined) return { errors, checked: 0 };
if (!Array.isArray(entries)) {
errors.push("_meta.expiries must be an array");
return { errors, checked: 0 };
}

const seenIds = new Set();

entries.forEach((entry, index) => {
const label = `_meta.expiries[${index}]`;

if (entry == null || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`${label}: must be an object`);
return;
}

for (const field of REQUIRED_FIELDS) {
if (!isNonEmptyString(entry[field])) {
errors.push(`${label}: '${field}' is required and must be a non-empty string`);
}
}

if (isNonEmptyString(entry.id)) {
if (seenIds.has(entry.id)) errors.push(`${label}: duplicate id '${entry.id}'`);
seenIds.add(entry.id);
}

const expiresMs = parseExpiryMs(entry.expires_at);
if (expiresMs === null) {
errors.push(`${label}: 'expires_at' must be a real calendar date as YYYY-MM-DD`);
return;
}

if (nowMs >= expiresMs) {
const daysPast = Math.floor((nowMs - expiresMs) / 86400000);
errors.push(
`${label} '${entry.id}' EXPIRED ${entry.expires_at} (${daysPast} day(s) ago)\n`
+ ` what: ${entry.what}\n`
+ ` action: ${entry.action}\n`
+ " Apply the action above, then remove or advance this entry.",
);
}
});

return { errors, checked: entries.length };
}

function main() {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(OVERRIDES_PATH, "utf8"));
} catch (e) {
console.error(`Curated expiry errors:\n- cannot read ${OVERRIDES_PATH}: ${e.message}`);
process.exit(1);
}

const { errors, checked } = checkExpiries(parsed._meta, Date.now());

if (errors.length) {
console.error("Curated expiry errors:");
errors.forEach((line) => console.error(`- ${line}`));
process.exit(1);
}

console.log(`Curated expiry ok: ${checked} time-boxed entr${checked === 1 ? "y" : "ies"} still valid.`);
}

if (require.main === module) main();

module.exports = { checkExpiries, parseExpiryMs };
159 changes: 121 additions & 38 deletions src/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const path = require("node:path");
const fssync = require("node:fs");

const { resolveTrackerPaths } = require("../lib/tracker-paths");
const { createLocalApiHandler, resolveQueuePath } = require("../lib/local-api");
const { createLocalApiHandler, resolveQueuePath, isLoopbackHostname } = require("../lib/local-api");
const {
buildServeDataPreflightMessage,
summarizeQueueData,
Expand All @@ -18,6 +18,122 @@ const DEFAULT_MAX_PORT_ATTEMPTS = 20;
const NPM_PACKAGE_NAME = "@ipv9/tokentracker-cli";
const LOCAL_BIND_HOST = "127.0.0.1";

// Anti-DNS-rebinding guard. Binding the socket to loopback does not make the
// Host header trustworthy: under DNS rebinding a browser sends
// `Host: attacker.example:<port>` to 127.0.0.1 and treats the response as
// same-origin, so CORS never applies. Mutations are already gated on a loopback
// Origin; without this check every GET /functions/* endpoint — full spend
// history, model mix, project names — is readable by any page the victim
// happens to have open. Issue #88.
//
// Only the hostname matters: the port is chosen at runtime by
// listenOnAvailablePort, so pinning it here would be fragile without adding any
// protection. An absent Host (HTTP/1.0, some local probes) is allowed — the
// socket is already loopback-bound, and there is no rebinding vector without a
// browser sending a name.
function isAllowedHostHeader(hostHeader) {
if (hostHeader == null || hostHeader === "") return true;
// Userinfo has no meaning in a Host header, so anything carrying it is
// malformed. Tested on the RAW value: an EMPTY userinfo ("@localhost",
// ":@localhost") parses to a falsy url.username, so checking the parsed
// fields alone lets exactly the malformed forms through.
if (hostHeader.includes("@")) return false;
// Same reason as the request target: URL parsing strips these bytes, so they
// can smuggle a different authority past a check on the raw string.
if (/[\u0000-\u0020\u007f]/.test(hostHeader)) return false;
try {
const url = new URL(`http://${hostHeader}`);
// `localhost.` is the valid fully-qualified spelling of localhost. WHATWG
// URL canonicalises the trailing dot away for IPv4 literals but not for
// names, so strip it here or the FQDN form gets a spurious 403.
return isLoopbackHostname(url.hostname.replace(/\.$/, ""));
} catch (_e) {
return false;
}
}

// An origin server is not a proxy: a request-target must be origin-form
// ("/path") or asterisk-form ("*"). Absolute-form ("GET http://evil/x") carries
// its own authority, which WOULD win over the Host header when the URL is
// parsed for routing — so the Host allowlist and the routing would disagree
// about which site this request is for. Refuse instead of picking a winner.
function isAllowedRequestTarget(target) {
if (typeof target !== "string" || target === "") return false;
if (target === "*") return true;
if (!target.startsWith("/")) return false;
// "//evil/x" is a network-path reference, and WHATWG URL treats a backslash
// like a slash, so "/\evil/x" behaves the same way: both make the parsed URL
// adopt a foreign authority even though the Host header said loopback.
// Routing only reads url.pathname today, but leaving the parsed URL pointing
// at someone else's origin is the same guard-vs-parser disagreement that
// absolute-form creates.
if (target.startsWith("//") || target.startsWith("/\\")) return false;
// WHATWG URL strips tab, LF and CR from its input BEFORE parsing, so
// "/<tab>//evil/x" becomes "//evil/x" and adopts a foreign authority after
// passing the prefix checks above. Node's own parser rejects these bytes in a
// request-target with a 400 before the handler runs, so this is not reachable
// over the wire today — but a guard that only holds because a different layer
// is strict is the disagreement this function exists to prevent.
if (/[\u0000-\u0020\u007f]/.test(target)) return false;
return true;
}

// Extracted from cmdServe so the wiring — not just the predicate — is testable:
// a guard that exists but is never reached is the failure mode this is guarding
// against in the first place.
function createRequestHandler({ handleApi, dashboardDir }) {
return async function handleRequest(req, res) {
try {
// Reject rebound hostnames before anything reads the request. Issue #88.
if (!isAllowedHostHeader(req.headers.host)) {
res.writeHead(403, { "Content-Type": "text/plain" });
res.end("Forbidden: TokenTracker only serves loopback hosts.\n");
return;
}

if (!isAllowedRequestTarget(req.url)) {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Bad Request: absolute-form request targets are not served.\n");
return;
}

const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);

// CORS preflight
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
});
res.end();
return;
}

// API routes
if (
url.pathname.startsWith("/functions/")
|| url.pathname.startsWith("/api/")
|| url.pathname.startsWith("/proxy/")
) {
const handled = await handleApi(req, res, url);
if (handled) return;
}

// Static files
const served = await serveStaticFile(dashboardDir, url.pathname, res);
if (served) return;

// SPA fallback
await serveStaticFile(dashboardDir, "/index.html", res);
} catch (e) {
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal Server Error");
}
}
};
}

function buildPortInUseHint(port) {
return `Port ${port} is unavailable. Try: npx ${NPM_PACKAGE_NAME} serve --port ${port + 1}\n`;
}
Expand Down Expand Up @@ -110,43 +226,7 @@ async function cmdServe(argv) {
// 3. Create handler
const handleApi = createLocalApiHandler({ queuePath });

const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);

// CORS preflight
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
});
res.end();
return;
}

// API routes
if (
url.pathname.startsWith("/functions/")
|| url.pathname.startsWith("/api/")
|| url.pathname.startsWith("/proxy/")
) {
const handled = await handleApi(req, res, url);
if (handled) return;
}

// Static files
const served = await serveStaticFile(dashboardDir, url.pathname, res);
if (served) return;

// SPA fallback
await serveStaticFile(dashboardDir, "/index.html", res);
} catch (e) {
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal Server Error");
}
}
});
const server = http.createServer(createRequestHandler({ handleApi, dashboardDir }));

// 4. Listen. Default startup follows README behavior and picks the next
// available port; an explicit --port/PORT remains strict.
Expand Down Expand Up @@ -316,6 +396,9 @@ module.exports = {
NPM_PACKAGE_NAME,
LOCAL_BIND_HOST,
isPortUnavailableError,
isAllowedHostHeader,
isAllowedRequestTarget,
createRequestHandler,
listenOnAvailablePort,
getLocalServerUrl,
parseArgs,
Expand Down
Loading