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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user