mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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.
181 lines
6.3 KiB
TypeScript
181 lines
6.3 KiB
TypeScript
// The two cookies the acquisition system sets, and how to read them back.
|
|
//
|
|
// Both are first party, both stay on this deployment's own domain, and neither
|
|
// is readable from JavaScript. They exist so that a visitor who arrives from a
|
|
// 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';
|
|
|
|
export const ANONYMOUS_ID_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
|
|
|
/** cuid-ish length bound. Values outside it are treated as absent, not repaired. */
|
|
const ANONYMOUS_ID_PATTERN = /^[a-z0-9]{16,64}$/;
|
|
|
|
export interface FirstTouch {
|
|
channel: AcquisitionChannel;
|
|
utmSource: string | null;
|
|
utmMedium: string | null;
|
|
utmCampaign: string | null;
|
|
referrerHost: string | null;
|
|
landingPath: string;
|
|
}
|
|
|
|
/** Short keys: this rides on every request, so the wire form stays compact. */
|
|
interface EncodedFirstTouch {
|
|
c: string;
|
|
s?: string;
|
|
m?: string;
|
|
k?: string;
|
|
r?: string;
|
|
p: string;
|
|
}
|
|
|
|
const CHANNELS: readonly AcquisitionChannel[] = [
|
|
'DIRECT',
|
|
'GITHUB',
|
|
'YOUTUBE',
|
|
'GOOGLE',
|
|
'REVIEW_LINK',
|
|
'REFERRAL',
|
|
'OUTBOUND',
|
|
'COMMUNITY',
|
|
'OTHER',
|
|
];
|
|
|
|
export function isAcquisitionChannel(value: unknown): value is AcquisitionChannel {
|
|
return typeof value === 'string' && (CHANNELS as readonly string[]).includes(value);
|
|
}
|
|
|
|
export function isValidAnonymousId(value: string | null | undefined): value is string {
|
|
return typeof value === 'string' && ANONYMOUS_ID_PATTERN.test(value);
|
|
}
|
|
|
|
/** 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);
|
|
let id = '';
|
|
for (const byte of bytes) {
|
|
id += byte.toString(36).padStart(2, '0');
|
|
}
|
|
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 toBase64Url(JSON.stringify(payload));
|
|
}
|
|
|
|
/**
|
|
* Parses the cookie body back, re-sanitizing every field.
|
|
*
|
|
* 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(json);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
const value = parsed as Record<string, unknown>;
|
|
|
|
if (!isAcquisitionChannel(value.c)) return null;
|
|
if (typeof value.p !== 'string') return null;
|
|
|
|
return {
|
|
channel: value.c,
|
|
utmSource: sanitizeTag(typeof value.s === 'string' ? value.s : null),
|
|
utmMedium: sanitizeTag(typeof value.m === 'string' ? value.m : null),
|
|
utmCampaign: sanitizeTag(typeof value.k === 'string' ? value.k : null),
|
|
referrerHost: normalizeHost(typeof value.r === 'string' ? value.r : null),
|
|
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));
|
|
}
|