forked from Mike/freno-dev
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.
132 lines
4.3 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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 });
|
|
}
|