Files
freno-dev/src/routes/api/the-nook/report.test.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

200 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, mock, beforeEach } from "bun:test";
// The route's core contracts:
// 1. A valid report is emailed to the owner with the machine snapshot
// and description, HTML-escaped.
// 2. Invalid payloads never reach the mailer.
// 3. The rate limiter halts floods per fingerprint (falling back to IP).
// 4. The landing page's iPhone beta request (email only) gets through.
// sendEmail and the limiter are mocked so the test sends no real mail.
const sentEmails: { to: string; subject: string; html: string }[] = [];
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
/** Every rate-limit key the route derived, in order. */
const rateKeys: string[] = [];
mock.module("~/server/email", () => ({
default: async (to: string, subject: string, html: string) => {
sentEmails.push({ to, subject, html });
return { success: true, messageId: "test" };
}
}));
mock.module("~/server/bug-report-rate-limit", () => ({
takeBugReportToken: (key: string) => {
rateKeys.push(key);
return tokenAnswers.shift() ?? { allowed: true };
}
}));
const { POST } = await import("./report");
const { betaRequestPayload } = await import(
"~/components/nook/betaRequest"
);
/** A minimal APIEvent double: JSON body plus optional headers. */
function request(body: unknown, headers: Record<string, string> = {}) {
return {
request: {
json: async () => body,
headers: new Headers(headers)
}
};
}
const validPayload = {
appVersion: "The Nook 0.2.0 (12)",
title: "Island flickers",
description: "It happened <twice> & stayed",
contact: "",
machine: {
macOS: "macOS 15.5",
model: "Mac15,6",
cpu: "Apple M3 Pro",
memoryGB: 36,
freeDiskGB: 100,
locale: "en_US"
},
displays: [
{
name: "Built-in",
boundsPx: "3456×2234 pt",
scale: "2.0x",
hz: "120",
builtin: true
},
{
name: "DELL U2723QE",
boundsPx: "3008×1692 pt",
scale: "1.0x",
hz: "120",
builtin: false
}
]
};
describe("POST /api/the-nook/report", () => {
beforeEach(() => {
sentEmails.length = 0;
tokenAnswers.length = 0;
rateKeys.length = 0;
});
it("emails the owner a report containing the snapshot, escaped", async () => {
const res = await POST(request(validPayload));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true });
expect(sentEmails).toHaveLength(1);
const email = sentEmails[0]!;
expect(email.subject).toBe("The Nook bug report: Island flickers");
expect(email.to).toBe("michael@freno.me");
for (const fact of ["Mac15,6", "Apple M3 Pro", "macOS 15.5", "36"]) {
expect(email.html).toContain(fact);
}
expect(email.html).toContain("Built-in");
expect(email.html).toContain("3456×2234 pt");
expect(email.html).toContain("DELL U2723QE");
expect(email.html).toContain("3008×1692 pt");
expect(email.html).toContain("<b>builtin:</b> false");
expect(email.html).toContain("It happened &lt;twice&gt; &amp; stayed");
});
it("rejects a missing, empty, or oversized title", async () => {
const empty = await POST(request({ ...validPayload, title: " " }));
expect(empty.status).toBe(400);
const missing = await POST(
request({ ...validPayload, title: undefined })
);
expect(missing.status).toBe(400);
const tooLong = await POST(
request({ ...validPayload, title: "x".repeat(201) })
);
expect(tooLong.status).toBe(400);
expect(sentEmails).toHaveLength(0);
});
it("rejects a malformed reply-to contact", async () => {
const res = await POST(
request({ ...validPayload, contact: "not-an-email" })
);
expect(res.status).toBe(400);
expect(sentEmails).toHaveLength(0);
});
it("rejects more than eight displays", async () => {
const displays = Array.from({ length: 9 }, () => validPayload.displays[0]!);
const res = await POST(request({ ...validPayload, displays }));
expect(res.status).toBe(400);
expect(sentEmails).toHaveLength(0);
});
it("returns 429 with Retry-After when the limiter denies the token", async () => {
tokenAnswers.push({ allowed: false, retryAfterSec: 3600 });
const res = await POST(request(validPayload));
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("3600");
expect(sentEmails).toHaveLength(0);
});
it("still succeeds when no fingerprint header exists", async () => {
const res = await POST(
request(validPayload, {
"cf-connecting-ip": "203.0.113.9",
"x-forwarded-for": "203.0.113.9, 10.0.0.1"
})
);
expect(res.status).toBe(200);
expect(sentEmails).toHaveLength(1);
// The quota key is the Cloudflare-recorded client, not a header hop the
// sender chose.
expect(rateKeys).toEqual(["203.0.113.9"]);
});
it("a rotating leftmost x-forwarded-for does not mint a fresh quota key", async () => {
// FRE-371: the old read keyed on the leftmost hop, which the sender
// controls outright — one scripted loop with a rotating header had
// unlimited reports. With the same Cloudflare-recorded client and a
// different spoofed prefix per request, the key must be stable.
for (const spoof of ["1.1.1.1", "2.2.2.2", "3.3.3.3"]) {
const res = await POST(
request(validPayload, {
"cf-connecting-ip": "203.0.113.9",
"x-forwarded-for": `${spoof}, 203.0.113.9, 10.0.0.1`
})
);
expect(res.status).toBe(200);
}
expect(rateKeys).toEqual([
"203.0.113.9",
"203.0.113.9",
"203.0.113.9"
]);
});
it("keys on the rightmost-minus-one hop when Cloudflare's header is absent", async () => {
const res = await POST(
request(validPayload, {
"x-forwarded-for": "6.6.6.6, 203.0.113.9, 10.0.0.1"
})
);
expect(res.status).toBe(200);
// Never the leftmost (sender-chosen) hop.
expect(rateKeys).toEqual(["203.0.113.9"]);
});
it("delivers the landing page's beta request with only the email", async () => {
const res = await POST(request(betaRequestPayload("tester@example.com")));
expect(res.status).toBe(200);
expect(sentEmails).toHaveLength(1);
const email = sentEmails[0]!;
expect(email.subject).toBe(
"The Nook bug report: iPhone app beta access request"
);
expect(email.html).toContain("<b>Reply-to:</b> tester@example.com");
expect(email.html).not.toContain("<h3>Displays</h3>");
});
});