Files
freno-dev/src/routes/api/the-nook/report.ts
agent-security-engineer 2fd2a4a71a security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371)
getClientIP (the rate-limit and audit key) took the LEFTMOST x-forwarded-for
hop and read x-real-ip unconditionally. In our topology — client → Cloudflare
→ same-host origin proxy → app — both proxies APPEND, so the leftmost hop is
whatever the sender typed: a rotating X-Forwarded-For minted a fresh rate-limit
bucket per request, and unlimited bug-report quota via the same pattern in
routes/api/the-nook/report.ts. x-real-ip is client-settable too; nothing in
the chain overwrites it. It is no longer read at all.

New trust order (src/server/trusted-client-ip.ts, resolveTrustedClientIp):

  1. a non-loopback socket peer IS the client — its headers are ignored;
  2. cf-connecting-ip (Cloudflare overwrites it at the edge; parsed as an
     address or rejected);
  3. x-forwarded-for counted from the RIGHT, discounting one hop for the
     Cloudflare edge address the origin proxy appended (the topology has
     exactly one trusted proxy below Cloudflare);
  4. "unknown" — callers key stricter shared limits on it, so resolution
     fails closed toward a tighter bucket, never a looser one.

Addresses are never truncated: a /24-truncated key would pool attackers with
the innocents behind them.

Share-vs-copy, stated deliberately: this is a port of the parsing primitives
 FRE-366 reviewed in request-client.ts, NOT an import. resolveClientIp carries
a standing rule (1d9287a) that it never feeds a security decision; lifting
that rule for these callers would couple an access-control change to an
analytics branch under review, and the analytics helper only exposes
truncated prefixes while rate limiting needs the full address. If the two
drift, trusted-client-ip.ts is the one security paths follow. The deploy-side
precondition (origin reachable only via Cloudflare + the same-host proxy) is
documented in the file header; if the app is ever directly internet-reachable
no in-app read can recover the true client.

Tests updated deliberately, not to go green: rate-limit.test.ts now pins the
rightmost-minus-one order, cf-connecting-ip precedence, peer-over-headers,
x-real-ip rejection, and the spoof case (a rotating leftmost hop does not
change the resolved client); report.test.ts pins the quota key stability
under a rotating header. New trusted-client-ip.test.ts covers the helper's
own edges: APIEvent wrapper, loopback forms, unparseable hops failing closed.
2026-10-30 05:06:54 +00:00

132 lines
4.3 KiB
TypeScript

import type { APIEvent } from "@solidjs/start/server";
import { z } from "zod";
import sendEmail from "~/server/email";
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
import { json, error } from "./_lib";
/**
* POST /api/the-nook/report
* Body: { appVersion, title, description, contact?, machine, displays }
*
* Emails the developer a Nook bug report in plain prose — each field on
* its own line. The Mac client collects the machine facts itself (model,
* CPU, memory, every display); styling is irrelevant, information is the
* product here.
*/
const displaySchema = z.object({
name: z.string().max(200).default(""),
boundsPx: z.string().max(100).default(""),
scale: z.string().max(20).default(""),
hz: z.string().max(20).default(""),
builtin: z.boolean().default(false)
});
const reportSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(20_000).default(""),
contact: z.string().trim().max(254).default(""),
appVersion: z.string().trim().max(100).default(""),
machine: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
.default({}),
displays: z.array(displaySchema).max(8).default([])
});
type Report = z.infer<typeof reportSchema>;
const contactRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// One escaping contract for every interpolated string (many call sites).
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderBody(report: Report): string {
const machineLines = Object.entries(report.machine).map(
([key, value]) => `<b>${escapeHtml(key)}:</b> ${escapeHtml(String(value))}`
);
const displayLines = report.displays.map((display) =>
[
`<b>name:</b> ${escapeHtml(display.name)}`,
`<b>boundsPx:</b> ${escapeHtml(display.boundsPx)}`,
`<b>scale:</b> ${escapeHtml(display.scale)}`,
`<b>hz:</b> ${escapeHtml(display.hz)}`,
`<b>builtin:</b> ${escapeHtml(String(display.builtin))}`
].join(" · ")
);
return [
`<h2>The Nook bug report</h2>`,
`<h3>“${escapeHtml(report.title)}”</h3>`,
report.description
? `<h3>What happened</h3><div>${escapeHtml(report.description).replace(/\r\n|\n|\r/g, "<br>")}</div>`
: "",
report.contact && contactRe.test(report.contact)
? `<p><b>Reply-to:</b> ${escapeHtml(report.contact)}</p>`
: "",
`<h3>Snapshot</h3>`,
report.appVersion ? `<p><b>${escapeHtml(report.appVersion)}</b></p>` : "",
machineLines.length > 0 ? `<p>${machineLines.join("<br>")}</p>` : "",
displayLines.length > 0
? `<h3>Displays</h3><div>${displayLines.map((line) => `<div>${line}</div>`).join("")}</div>`
: ""
]
.filter((chunk) => chunk !== "")
.join("\n");
}
export async function POST(event: APIEvent): Promise<Response> {
let raw: unknown;
try {
raw = await event.request.json();
} catch {
return error("Invalid JSON", 400);
}
const parsed = reportSchema.safeParse(raw);
if (!parsed.success) {
return error("Invalid report", 400);
}
const report = parsed.data;
if (report.contact && !contactRe.test(report.contact)) {
return error("Invalid reply-to email", 400);
}
const fingerprint = (
event.request.headers.get("x-nook-fingerprint") ?? ""
)
.trim()
.toLowerCase();
// FRE-371: the IP fallback must be the spoof-resistant read — a rotating
// leftmost x-forwarded-for used to mint unlimited report quota.
const rateKey = fingerprint || resolveTrustedClientIp(event);
const token = takeBugReportToken(rateKey);
if (!token.allowed) {
return new Response(JSON.stringify({ error: "Too many reports" }), {
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(token.retryAfterSec ?? 3600)
}
});
}
const subject = `The Nook bug report: ${report.title.slice(0, 100)}`;
const result = await sendEmail(
CONTACT_RECIPIENT_EMAIL,
subject,
renderBody(report)
);
if (!result.success) {
return error("Failed to send report", 500);
}
return json({ success: true });
}