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:
Paperclip Agent
2026-10-29 18:05:44 +00:00
parent 39d874a7e2
commit 1d9287a199
12 changed files with 842 additions and 86 deletions

View File

@@ -9,6 +9,8 @@ import {
VISITOR_ANALYTICS_INDEXES,
VISITOR_ANALYTICS_INSERT
} from "./analytics-schema";
import { truncateIpAddress } from "./request-client";
import { pruneVisitorAnalytics } from "./analytics-retention";
/**
* Idempotent schema bootstrap, mirroring `nook.ts`. The table had no DDL in
@@ -317,6 +319,10 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
});
const totalApiCalls = (totalApiCallsResult.rows[0]?.count as number) || 0;
// Since the FRE-369 privacy floor, `ip_address` holds a truncated prefix
// (/24 for v4, /56 for v6), so this counts distinct prefixes — households and
// mobile exits, not machines. It was never accurate before that either: every
// row held the proxy's loopback, so the count was 1.
const uniqueVisitorsResult = await conn.execute({
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics
WHERE ip_address IS NOT NULL
@@ -628,19 +634,15 @@ export async function getPerformanceStats(days: number = 30): Promise<{
};
}
/**
* Delete visitor analytics older than `olderThanDays`. Reachable from tRPC
* (admin) and from the daily retention cron (`/api/analytics/retention`);
* the SQL and the input rules live in `./analytics-retention`.
*/
export async function cleanupOldAnalytics(
olderThanDays: number
): Promise<number> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `DELETE FROM VisitorAnalytics
WHERE created_at < datetime('now', '-${olderThanDays} days')
RETURNING id`,
args: []
});
return result.rows.length;
return pruneVisitorAnalytics(ConnectionFactory(), olderThanDays);
}
function parseUserAgent(userAgent?: string): {
@@ -675,6 +677,16 @@ function parseUserAgent(userAgent?: string): {
return { deviceType, browser, os };
}
/**
* Fill in what the writer did not supply, and apply the privacy floor.
*
* Every analytics entry — page views, tRPC context, downloads, checkout —
* passes through here, which is why the truncation lives here and not at the
* call sites: a future writer cannot forget it, and no caller can persist a
* full client address by accident (FRE-369, finding Q4). The audit log
* (`./audit.ts`) is a separate, pre-existing surface with its own stakes and is
* deliberately untouched by this change.
*/
export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
const { deviceType, browser, os } = parseUserAgent(
entry.userAgent || undefined
@@ -682,6 +694,7 @@ export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
return {
...entry,
ipAddress: truncateIpAddress(entry.ipAddress),
deviceType: entry.deviceType || deviceType,
browser: entry.browser || browser,
os: entry.os || os