fix(analytics): sign the acquisition cookies and bound what they can write

Both cookies were read straight into database columns after nothing more than a
format check. httpOnly keeps JavaScript out of them and does nothing about curl,
so the anonymous id was a string the caller picked: enough to write a first-touch
row for a visitor who never existed, to file it under a channel of their
choosing, and to claim that id's events at signup, since the backfill matches on
the id alone.

They are now signed with an HMAC over AUTH_SECRET, through Web Crypto rather than
node:crypto because the proxy runs on the edge and the pages that read the
cookies back run in Node. The first-touch body moved to base64url on the way:
cookie values are percent-encoded and decoded by several layers that do not agree
on how many times, and a payload carrying its own percent escapes comes back
subtly different and takes the signature with it.

Signing stops a caller choosing an id, not collecting one, since dropping the
cookie and asking for the landing page again mints another. So the bot and
prefetch filters moved to where the rows are written rather than only where the
cookies are issued, which also fixes a returning visitor's prefetch of /register
recording a signup start, and a per-client hourly ceiling now sits in front of
the write. The ceiling is skipped when TRUSTED_PROXY_MODE is unset, where every
caller resolves to 127.0.0.1 and the bucket would empty on real traffic long
before it emptied on a flood.

Four smaller things around it:

- /api/events checked the flag and the origin after paying for a rate-limit
  write, so a host who never turned analytics on was still writing a row per
  anonymous POST. Both checks are free and now come first, and the limiter
  answers 204 rather than 429: a beacon has nobody to tell, and a flooder should
  not be handed the reset time.
- /api/onboarding/source was keyed by IP on an authenticated route. Without
  TRUSTED_PROXY_MODE that is five answers an hour for the whole deployment, and
  with it a shared office address locks out everyone after one colleague
  answered. Keyed by account, like /api/onboarding/complete beside it.
- The cookies took their Secure flag from request.nextUrl.protocol, which behind
  a TLS-terminating reverse proxy is the container-internal http address. It
  comes off the configured public origin now.
- sanitizeLandingPath took anything that started with a slash, including from the
  cookie, so a hand-written one could put newlines and markup into a column an
  admin table may render one day.

Also: the paid-account query had no LIMIT and returned every active account's
name and email, the growth route answered 403 where it meant 401, and the schema
claimed no free text is stored when self_reported_note holds 200 characters of it.
This commit is contained in:
yusufipk
2026-08-01 20:29:28 +03:00
parent 7ca5abd041
commit 33c845636c
21 changed files with 711 additions and 129 deletions
+16 -3
View File
@@ -75,11 +75,24 @@ export function extractReferrerHost(
return host;
}
/** Path only, no query string and no fragment, capped. */
// What a URL path is allowed to be made of, per RFC 3986: unreserved characters,
// percent escapes, sub-delims and the separators. Everything a real route can
// carry, and nothing that survives being pasted into a page or a log line.
const LANDING_PATH_PATTERN = /^\/[A-Za-z0-9\-._~%!$&'()*+,;=:@/]*$/;
/**
* Path only, no query string and no fragment, capped and character-checked.
*
* The proxy feeds this `request.nextUrl.pathname`, which is already a path. The
* cookie reader feeds it whatever the cookie said, which is why the allowlist is
* here rather than left to the caller: an unchecked value would put newlines and
* markup into a column that some later admin table renders.
*/
export function sanitizeLandingPath(pathname: string | null | undefined): string {
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/';
const path = pathname.split('?')[0]?.split('#')[0] ?? '/';
return path.slice(0, MAX_PATH_LENGTH) || '/';
const path = (pathname.split('?')[0]?.split('#')[0] ?? '/').slice(0, MAX_PATH_LENGTH);
if (!path || !LANDING_PATH_PATTERN.test(path)) return '/';
return path;
}
function suffixMatch(host: string, domain: string): boolean {
+75 -8
View File
@@ -5,11 +5,16 @@
// YouTube link on Tuesday and signs up on Friday is still counted against
// YouTube; there is no cross-site identifier and nothing is sent anywhere.
//
// Both are also signed. Nothing here trusts a cookie it did not issue: read
// through `readAnonymousIdCookie` and `readFirstTouchCookie`, never through the
// `decode` helpers, which are the unsigned inner layer.
//
// Imported by the proxy, so this file must stay free of Prisma and of anything
// else that cannot run on the edge.
import type { AcquisitionChannel } from '@prisma/client';
import { sanitizeLandingPath, sanitizeTag, normalizeHost } from '@/lib/analytics/channel';
import { signCookieValue, unsignCookieValue } from '@/lib/analytics/signing';
export const ANONYMOUS_ID_COOKIE = 'of_aid';
export const FIRST_TOUCH_COOKIE = 'of_ft';
@@ -58,7 +63,7 @@ export function isValidAnonymousId(value: string | null | undefined): value is s
return typeof value === 'string' && ANONYMOUS_ID_PATTERN.test(value);
}
/** 26 lowercase base36 characters from the Web Crypto API, which the edge has. */
/** 128 bits from the Web Crypto API, which the edge has, as 32 base36 characters. */
export function generateAnonymousId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
@@ -69,29 +74,58 @@ export function generateAnonymousId(): string {
return id;
}
// base64url rather than encodeURIComponent, and not for compactness. Cookie
// values are percent-encoded on the way out and decoded on the way back, by
// several layers that do not all agree on how many times; a payload that already
// contains percent escapes comes back subtly different and takes the signature
// down with it. base64url has nothing either layer wants to touch.
function toBase64Url(text: string): string {
let binary = '';
for (const byte of new TextEncoder().encode(text)) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64Url(value: string): string | null {
try {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '='));
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch {
return null;
}
}
export function encodeFirstTouch(touch: FirstTouch): string {
const payload: EncodedFirstTouch = { c: touch.channel, p: touch.landingPath };
if (touch.utmSource) payload.s = touch.utmSource;
if (touch.utmMedium) payload.m = touch.utmMedium;
if (touch.utmCampaign) payload.k = touch.utmCampaign;
if (touch.referrerHost) payload.r = touch.referrerHost;
return encodeURIComponent(JSON.stringify(payload));
return toBase64Url(JSON.stringify(payload));
}
/**
* Parses the cookie back, re-sanitizing every field.
* Parses the cookie body back, re-sanitizing every field.
*
* The cookie is httpOnly but it still came from the client, so a hand-edited one
* must not be able to put arbitrary text into a database column. Anything that
* fails validation makes the whole value null: a half-trusted first touch is
* worse than none.
* The second line of defence, not the first: callers go through
* `readFirstTouchCookie`, which checks the signature before this ever runs. The
* re-sanitizing stays because a value that survives both checks can still be one
* this deployment signed a year ago, under an older set of rules. Anything that
* fails validation makes the whole value null, since a half-trusted first touch
* is worse than none.
*/
export function decodeFirstTouch(raw: string | null | undefined): FirstTouch | null {
if (!raw) return null;
const json = fromBase64Url(raw);
if (!json) return null;
let parsed: unknown;
try {
parsed = JSON.parse(decodeURIComponent(raw));
parsed = JSON.parse(json);
} catch {
return null;
}
@@ -111,3 +145,36 @@ export function decodeFirstTouch(raw: string | null | undefined): FirstTouch | n
landingPath: sanitizeLandingPath(value.p),
};
}
// ---------------------------------------------------------------------------
// The signed forms, which are the only ones anything outside this file uses.
// ---------------------------------------------------------------------------
/** The cookie value to set, or null when there is no secret to sign it with. */
export function signAnonymousId(anonymousId: string): Promise<string | null> {
return signCookieValue(anonymousId);
}
export function signFirstTouch(touch: FirstTouch): Promise<string | null> {
return signCookieValue(encodeFirstTouch(touch));
}
/**
* The anonymous id this deployment issued, or null.
*
* Null covers every failure the same way: no cookie, a cookie signed with
* another key, one edited by hand, one whose id no longer matches the shape we
* mint. A visitor we cannot vouch for is not counted rather than counted wrong.
*/
export async function readAnonymousIdCookie(
raw: string | null | undefined
): Promise<string | null> {
const anonymousId = await unsignCookieValue(raw);
return isValidAnonymousId(anonymousId) ? anonymousId : null;
}
export async function readFirstTouchCookie(
raw: string | null | undefined
): Promise<FirstTouch | null> {
return decodeFirstTouch(await unsignCookieValue(raw));
}
+19 -1
View File
@@ -29,6 +29,16 @@ export const AT_RISK_SILENT_DAYS = 14;
const DEFAULT_WEEKS = 12;
const CHANNEL_WINDOW_DAYS = 28;
/**
* How many paid accounts the per-account table carries.
*
* The list is ordered quietest first, so the cap drops the accounts that are
* using the product most, which are the ones nobody needs to read a row about.
* It is reported rather than applied silently: a truncated table that looks
* complete is worse than a smaller one that says so.
*/
const PAID_ACCOUNT_LIMIT = 500;
export interface WeeklyRow {
weekStart: Date;
visitors: number;
@@ -72,6 +82,9 @@ export interface Scoreboard {
channels: ChannelRow[];
channelWindowDays: number;
paidAccounts: PaidAccountRow[];
/** True when there are more paid accounts than the table shows. */
paidAccountsTruncated: boolean;
paidAccountLimit: number;
atRisk: PaidAccountRow[];
currentActivePaid: number | null;
currentMrrCents: number | null;
@@ -240,6 +253,7 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
WHERE u."subscriptionStatus"::text IN ('ACTIVE', 'TRIALING')
GROUP BY u.id, u.name, u.email, u."subscriptionStatus", ua.channel, ua.self_reported
ORDER BY MAX(e.occurred_at) ASC NULLS FIRST
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
`,
getCachedStripeStats(),
]);
@@ -293,7 +307,9 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
channelBuckets.set(channel, bucket);
}
const accounts: PaidAccountRow[] = paidAccounts.map((row) => ({
// One row over the limit was fetched purely to tell "exactly full" from "cut off".
const paidAccountsTruncated = paidAccounts.length > PAID_ACCOUNT_LIMIT;
const accounts: PaidAccountRow[] = paidAccounts.slice(0, PAID_ACCOUNT_LIMIT).map((row) => ({
userId: row.user_id,
name: row.name,
email: row.email,
@@ -313,6 +329,8 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
channels: [...channelBuckets.values()].sort((a, b) => b.visitors - a.visitors),
channelWindowDays: CHANNEL_WINDOW_DAYS,
paidAccounts: accounts,
paidAccountsTruncated,
paidAccountLimit: PAID_ACCOUNT_LIMIT,
atRisk: accounts.filter(
(account) => !account.lastValueEventAt || account.lastValueEventAt < silentBefore
),
+115
View File
@@ -0,0 +1,115 @@
// Signing for the two acquisition cookies.
//
// httpOnly keeps JavaScript out of these cookies. It does nothing about curl,
// and both cookies are read straight into database columns, so without a
// signature the anonymous id is simply a string the caller picked. Picking one
// is enough to write a first-touch row for a visitor who never existed, or to
// claim another visitor's events at signup, since the backfill matches on the
// id alone.
//
// Web Crypto rather than node:crypto: this is imported by the proxy, which runs
// on the edge, and by the pages that read the cookies back, which run in Node.
// Both have crypto.subtle; only Node has createHmac.
import { logWarn } from '@/lib/logger';
const SEPARATOR = '.';
/**
* 132 bits of an HMAC-SHA256, base64url. Truncating a MAC is standard practice
* and keeps a cookie that rides on every request small.
*/
const SIGNATURE_LENGTH = 22;
let cachedSecret: string | null = null;
let cachedKey: Promise<CryptoKey> | null = null;
let warnedAboutMissingSecret = false;
function readSecret(): string | null {
const secret = process.env.AUTH_SECRET?.trim() || process.env.NEXTAUTH_SECRET?.trim();
if (secret) return secret;
// Not thrown. The proxy runs on every request and the pages render for every
// visitor; failing those to protect a funnel chart would be the wrong trade.
// Analytics simply records nothing, which is visible on /admin/growth the same
// day, and it is announced once per process rather than per request.
if (!warnedAboutMissingSecret) {
warnedAboutMissingSecret = true;
logWarn(
'AUTH_SECRET (or NEXTAUTH_SECRET) is not set, so acquisition cookies cannot be ' +
'signed. Nothing will be recorded while it is missing.'
);
}
return null;
}
function getKey(secret: string): Promise<CryptoKey> {
if (!cachedKey || cachedSecret !== secret) {
cachedSecret = secret;
cachedKey = crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
}
return cachedKey;
}
function toBase64Url(buffer: ArrayBuffer): string {
let binary = '';
for (const byte of new Uint8Array(buffer)) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function macOf(value: string, secret: string): Promise<string> {
const signature = await crypto.subtle.sign(
'HMAC',
await getKey(secret),
new TextEncoder().encode(value)
);
return toBase64Url(signature).slice(0, SIGNATURE_LENGTH);
}
/** Constant time, so a forged cookie learns nothing from how long it took to reject. */
function equals(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let difference = 0;
for (let index = 0; index < a.length; index += 1) {
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
}
return difference === 0;
}
/**
* `<mac>.<value>`.
*
* The MAC goes first and is fixed-length, so the split is a slice at a known
* offset rather than a search for a separator that a future payload might
* happen to contain.
*
* Returns null when there is no secret to sign with, which the callers treat as
* "set no cookie".
*/
export async function signCookieValue(value: string): Promise<string | null> {
const secret = readSecret();
if (!secret) return null;
return `${await macOf(value, secret)}${SEPARATOR}${value}`;
}
/** The signed value back, or null if it was absent, truncated, or edited. */
export async function unsignCookieValue(signed: string | null | undefined): Promise<string | null> {
if (typeof signed !== 'string' || signed.length <= SIGNATURE_LENGTH + 1) return null;
const secret = readSecret();
if (!secret) return null;
if (signed[SIGNATURE_LENGTH] !== SEPARATOR) return null;
const mac = signed.slice(0, SIGNATURE_LENGTH);
const value = signed.slice(SIGNATURE_LENGTH + 1);
return equals(mac, await macOf(value, secret)) ? value : null;
}
+3 -5
View File
@@ -4,11 +4,9 @@
// a channel that only shows up for one of them is worse than no channel at all.
import { eventKey, recordEvent, attachAcquisitionToUser } from '@/lib/analytics/record';
import { readVisitorContext, type VisitorContext } from '@/lib/analytics/visitor';
import { NO_VISITOR, readVisitorContext, type VisitorContext } from '@/lib/analytics/visitor';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null };
/**
* The visitor context of the request being handled.
*
@@ -20,8 +18,8 @@ const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null };
export async function readVisitorContextFromHeaders(): Promise<VisitorContext> {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
try {
const { cookies } = await import('next/headers');
return readVisitorContext(await cookies());
const { cookies, headers } = await import('next/headers');
return await readVisitorContext(await cookies(), await headers());
} catch {
return NO_VISITOR;
}
+93 -6
View File
@@ -8,17 +8,30 @@
// a purity argument. Blocking rates differ by channel, and an undercounted
// denominator would make GitHub and Hacker News traffic look like it converts
// better than it does.
//
// Recording server-side also means an anonymous request can write rows, so the
// three filters that decide whether a request counts live here rather than only
// in the proxy: the signature on the cookie, the bot and prefetch checks, and a
// per-client ceiling. In the proxy they only govern which cookies get issued,
// which is not the same thing as which rows get written.
import type { AnalyticsEventName } from '@prisma/client';
import {
ANONYMOUS_ID_COOKIE,
FIRST_TOUCH_COOKIE,
decodeFirstTouch,
isValidAnonymousId,
readAnonymousIdCookie,
readFirstTouchCookie,
type FirstTouch,
} from '@/lib/analytics/cookies';
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
import { dailyEventKey, recordEvent, recordFirstTouch } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import {
RATE_LIMIT_CONFIGS,
checkRateLimit,
getClientIpFromHeaders,
isClientIpTrustworthy,
} from '@/lib/rate-limit';
/** Both `cookies()` from next/headers and `request.cookies` satisfy this. */
export interface AnalyticsCookieReader {
@@ -28,16 +41,71 @@ export interface AnalyticsCookieReader {
export interface VisitorContext {
anonymousId: string | null;
firstTouch: FirstTouch | null;
/** Carried so the ceiling below can be applied after the response, not during it. */
clientIp: string | null;
}
export function readVisitorContext(store: AnalyticsCookieReader): VisitorContext {
const rawId = store.get(ANONYMOUS_ID_COOKIE)?.value;
export const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null, clientIp: null };
/**
* The visitor behind a set of cookies, or an empty context.
*
* Both cookies are verified, so an id that reaches a database column is one this
* deployment issued. A forged one is not repaired or partially trusted, it is
* simply not a visitor.
*/
export async function readVisitorContext(
store: AnalyticsCookieReader,
headers?: Headers
): Promise<VisitorContext> {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
const anonymousId = await readAnonymousIdCookie(store.get(ANONYMOUS_ID_COOKIE)?.value);
if (!anonymousId) return NO_VISITOR;
return {
anonymousId: isValidAnonymousId(rawId) ? rawId : null,
firstTouch: decodeFirstTouch(store.get(FIRST_TOUCH_COOKIE)?.value),
anonymousId,
firstTouch: await readFirstTouchCookie(store.get(FIRST_TOUCH_COOKIE)?.value),
clientIp: headers ? getClientIpFromHeaders(headers) : null,
};
}
/**
* The visitor behind a page render.
*
* Applies the same two header checks the proxy does, because they mean different
* things in the two places. In the proxy they decide who gets a cookie; here they
* decide what counts. A returning visitor already holds a cookie, so without this
* Next prefetching /register as a CTA scrolls into view would record a signup
* start for a page nobody opened.
*/
export async function readPageVisitor(): Promise<VisitorContext> {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
const { cookies, headers } = await import('next/headers');
const requestHeaders = await headers();
if (!isCountableDocumentRequest(requestHeaders)) return NO_VISITOR;
if (isLikelyBot(requestHeaders.get('user-agent'))) return NO_VISITOR;
return readVisitorContext(await cookies(), requestHeaders);
}
/**
* The visitor behind an API request.
*
* No document check: a beacon is `sec-fetch-dest: empty` by definition, and the
* routes that call this are reached by a form submission rather than by a
* navigation.
*/
export async function readRequestVisitor(request: {
cookies: AnalyticsCookieReader;
headers: Headers;
}): Promise<VisitorContext> {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
if (isLikelyBot(request.headers.get('user-agent'))) return NO_VISITOR;
return readVisitorContext(request.cookies, request.headers);
}
const DIRECT_TOUCH: FirstTouch = {
channel: 'DIRECT',
utmSource: null,
@@ -47,6 +115,24 @@ const DIRECT_TOUCH: FirstTouch = {
landingPath: '/',
};
/**
* A ceiling on how many visitors one client can invent per hour.
*
* A fresh signed cookie is one request away: drop the cookie, ask for the
* landing page again, and the proxy mints another id. The signature stops a
* caller from choosing an id, and this stops them from collecting an unbounded
* number of real ones. Skipped when the client IP is not real, where the bucket
* would be shared by everybody and would throttle the site rather than the
* flood.
*/
async function withinVisitorCeiling(clientIp: string | null): Promise<boolean> {
if (!clientIp || !isClientIpTrustworthy()) return true;
const config = RATE_LIMIT_CONFIGS['analytics-visitor'];
const result = await checkRateLimit(clientIp, 'analytics-visitor', config);
return result.allowed;
}
/**
* Records an event for a visitor with no account, once per visitor per UTC day.
*
@@ -59,6 +145,7 @@ export async function recordVisitorEvent(
): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
if (!visitor.anonymousId) return;
if (!(await withinVisitorCeiling(visitor.clientIp))) return;
const touch = visitor.firstTouch ?? DIRECT_TOUCH;