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.
200 lines
6.4 KiB
TypeScript
200 lines
6.4 KiB
TypeScript
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 <twice> & 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>");
|
||
});
|
||
});
|