forked from Mike/freno-dev
analytics: fix the two FRE-369 findings in the funnel instrumentation
The security review of 39d874a (FRE-369) found the address fix recorded
the wrong thing twice. Both are fixed here; the branch is the same one
under review, this commit does not rewrite it.
Q1 — the first x-forwarded-for hop is attacker-chosen text. In
client → Cloudflare → same-host proxy → app, both proxies *append*, so
the header arrives as `<client spoof…>, <true client>, <CF edge>` and
`split(",")[0]` took the sender's own invention — the loopback-peer gate
was sound, the hop selection defeated it. Preference is now
`cf-connecting-ip` (Cloudflare overwrites it, so a client value never
survives the edge), then x-forwarded-for counted from the right with one
trusted hop discounted, then the direct peer. Every candidate must parse
as an address. h3's `getRequestIP(…, { xForwardedFor: true })` has the
same leftmost flaw and is documented as not a fix.
Q4 — the table held full client addresses indefinitely. Every analytics
entry now passes through `truncateIpAddress` in `enrichAnalyticsEntry`
(IPv4 /24, IPv6 /56, unparseable dropped), so no writer can persist a
full address by accident. Retention got the schedule it never had:
`/api/analytics/retention` on a daily Vercel cron, Bearer-authenticated
and fail-closed, with the window parameterised (the old
`cleanupOldAnalytics` spliced the number into the SQL). Default 365 days
rather than the review's 90 so enabling the cron is not itself a
destructive event — the tighter window is an operational decision, named
on the task.
Also from the review: the module doc now forbids feeding this helper into
any security decision; `isLoopbackAddress` parses instead of
string-matching (`::ffff:127.9.9.9`, `0:0:0:0:0:0:0:1`); and `host` is
normalised (lowercased, port stripped) so `nook.freno.me:443` cannot
silently miss the `host = ?` scope. The weekly loopback caveat matches
the truncated shapes writers produce now, and says plainly that "unique
visitors" counts prefixes.
This commit is contained in:
@@ -85,7 +85,9 @@ const row = (
|
||||
method: "GET",
|
||||
referrer: null,
|
||||
user_agent: "Mozilla/5.0 (Macintosh)",
|
||||
ip_address: "203.0.113.9",
|
||||
// What the writers store since the FRE-369 privacy floor: a /24 (or /56 for
|
||||
// IPv6), never the address itself.
|
||||
ip_address: "203.0.113.0",
|
||||
country: "GB",
|
||||
device_type: "desktop",
|
||||
browser: "safari",
|
||||
@@ -227,8 +229,9 @@ describe("readFunnel", () => {
|
||||
|
||||
describe("readTraffic", () => {
|
||||
it("separates legacy rows, live loopback and automation from clean traffic", async () => {
|
||||
// Post-instrumentation rows: host set. One of them still loopback — the
|
||||
// shape the report must flag as "the fix is not live".
|
||||
// Post-instrumentation rows: host set. Two of them still loopback — in the
|
||||
// truncated shapes the privacy floor writes — which is the shape the report
|
||||
// must flag as "the fix is not live".
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
stage: "click",
|
||||
@@ -237,10 +240,15 @@ describe("readTraffic", () => {
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
stage: "served",
|
||||
ip_address: "127.0.0.1",
|
||||
ip_address: "127.0.0.0",
|
||||
user_agent: "The Nook/0.13.4 Sparkle/2.9.6",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
ip_address: "0:0:0:0:0:0:0:0",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||
|
||||
// Pre-instrumentation rows: no host, proxy's address — legacy, never
|
||||
@@ -263,10 +271,10 @@ describe("readTraffic", () => {
|
||||
const traffic = await readTraffic(conns, windowsFor(NOW, 7).current);
|
||||
|
||||
expect(traffic).toEqual({
|
||||
analyticsRows: 5,
|
||||
hostRows: 3,
|
||||
analyticsRows: 6,
|
||||
hostRows: 4,
|
||||
legacyRows: 2,
|
||||
hostLoopbackRows: 1,
|
||||
hostLoopbackRows: 2,
|
||||
downloadRows: 3,
|
||||
downloadClickRows: 1,
|
||||
downloadServedRows: 1,
|
||||
@@ -482,13 +490,16 @@ describe("getWeeklyReport / renderWeeklyMarkdown", () => {
|
||||
});
|
||||
|
||||
it("flags loopback only when rows recorded since the instrumentation carry it", async () => {
|
||||
// Both spellings a live writer can produce: the raw proxy address, and the
|
||||
// /24 the privacy floor stores instead of it.
|
||||
insertAnalytics({ path: "/", ip_address: "127.0.0.1", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/", ip_address: "127.0.53.0", created_at: THIS_WEEK });
|
||||
|
||||
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
expect(markdown).toContain(
|
||||
"1 rows recorded since the instrumentation still carry a loopback address"
|
||||
"2 rows recorded since the instrumentation still carry a loopback address"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user