Files
yusufipk 33c845636c 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.
2026-08-01 20:29:28 +03:00

226 lines
8.0 KiB
TypeScript

// Turns whatever the browser told us about a visit into one of nine buckets.
//
// Pure and dependency-free on purpose: this runs in the proxy (edge runtime), so
// the only Prisma reference here is a type-only import, which the compiler erases.
//
// Everything that reaches this file is already reduced to a host and a couple of
// UTM tags. Nothing here ever sees a full URL, so a query string carrying a share
// token or an email address cannot be classified into a stored column by mistake.
import type { AcquisitionChannel } from '@prisma/client';
export interface ChannelInput {
utmSource?: string | null;
utmMedium?: string | null;
referrerHost?: string | null;
}
const MAX_TAG_LENGTH = 64;
const MAX_PATH_LENGTH = 128;
/** Lowercases, trims and caps a UTM tag. Returns null for anything empty. */
export function sanitizeTag(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const cleaned = value.trim().toLowerCase().slice(0, MAX_TAG_LENGTH);
if (!cleaned) return null;
// Campaign names are ours, so anything outside this set is either a typo or
// somebody probing what the column accepts. Drop it rather than store it.
if (!/^[a-z0-9._%+\- ]+$/.test(cleaned)) return null;
return cleaned;
}
/**
* Strips the `www.` prefix and the port, lowercases, and caps the length.
*
* A whole URL is not a host and comes back null. Splitting on the first colon
* would otherwise turn `https://github.com` into the host `https`, and every
* caller that passed one by mistake would file its traffic under a channel that
* does not exist.
*/
export function normalizeHost(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim().toLowerCase();
const withoutPort = trimmed.replace(/:\d+$/, '');
const host = withoutPort.replace(/^www\./, '');
if (!host || !/^[a-z0-9.\-]+$/.test(host)) return null;
return host.slice(0, MAX_TAG_LENGTH);
}
/**
* The referring host, or null when there is no usable one.
*
* A referrer pointing at our own deployment is not a referrer: it is the visitor
* clicking through the site. Treating it as one would file most of the funnel
* under whatever page they happened to start on.
*/
export function extractReferrerHost(
referrer: string | null | undefined,
selfHost?: string | null
): string | null {
if (!referrer) return null;
let host: string | null;
try {
const url = new URL(referrer);
// Browsers only ever send an http(s) referrer. Anything else is a scheme we
// have no host for, such as `android-app://com.example`, and reading its
// opaque body as a domain would invent a referring site.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
host = normalizeHost(url.hostname);
} catch {
return null;
}
if (!host) return null;
const self = normalizeHost(selfHost);
if (self && host === self) return null;
return host;
}
// 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] ?? '/').slice(0, MAX_PATH_LENGTH);
if (!path || !LANDING_PATH_PATTERN.test(path)) return '/';
return path;
}
function suffixMatch(host: string, domain: string): boolean {
return host === domain || host.endsWith(`.${domain}`);
}
const GITHUB_HOSTS = ['github.com', 'github.blog'];
const YOUTUBE_HOSTS = ['youtube.com', 'youtu.be'];
// The bucket the plan calls "google" is really organic search. Google is the
// overwhelming majority of it, and splitting Bing and DuckDuckGo into their own
// slivers would make every row in the scoreboard smaller without changing a
// single decision.
const SEARCH_HOSTS = ['google.com', 'bing.com', 'duckduckgo.com', 'ecosia.org', 'yandex.com'];
const REVIEW_HOSTS = [
'producthunt.com',
'g2.com',
'capterra.com',
'getapp.com',
'alternativeto.net',
'saashub.com',
'slant.co',
'trustpilot.com',
'sourceforge.net',
];
const COMMUNITY_HOSTS = [
'reddit.com',
'news.ycombinator.com',
'lobste.rs',
'discord.com',
'discord.gg',
'x.com',
'twitter.com',
't.co',
'linkedin.com',
'lnkd.in',
'bsky.app',
'mastodon.social',
'dev.to',
'indiehackers.com',
'facebook.com',
'instagram.com',
't.me',
];
// utm_source values we set ourselves, plus the ones other people tend to use
// when they link us. Matched exactly after sanitizing.
const SOURCE_NAMES: ReadonlyMap<string, AcquisitionChannel> = new Map([
['github', 'GITHUB'],
['youtube', 'YOUTUBE'],
['yt', 'YOUTUBE'],
['google', 'GOOGLE'],
['bing', 'GOOGLE'],
['duckduckgo', 'GOOGLE'],
['producthunt', 'REVIEW_LINK'],
['product-hunt', 'REVIEW_LINK'],
['g2', 'REVIEW_LINK'],
['capterra', 'REVIEW_LINK'],
['alternativeto', 'REVIEW_LINK'],
['reddit', 'COMMUNITY'],
['hackernews', 'COMMUNITY'],
['hn', 'COMMUNITY'],
['discord', 'COMMUNITY'],
['twitter', 'COMMUNITY'],
['x', 'COMMUNITY'],
['linkedin', 'COMMUNITY'],
['newsletter', 'OUTBOUND'],
['coldmail', 'OUTBOUND'],
['outreach', 'OUTBOUND'],
]);
// A medium that names the motion beats the source that names the place: an
// outbound campaign sent from a LinkedIn account is outbound, not community.
const MEDIUM_NAMES: ReadonlyMap<string, AcquisitionChannel> = new Map([
['outbound', 'OUTBOUND'],
['email', 'OUTBOUND'],
['cold-email', 'OUTBOUND'],
['coldemail', 'OUTBOUND'],
['dm', 'OUTBOUND'],
['referral', 'REFERRAL'],
['affiliate', 'REFERRAL'],
]);
function classifyHost(host: string): AcquisitionChannel | null {
if (GITHUB_HOSTS.some((domain) => suffixMatch(host, domain))) return 'GITHUB';
if (YOUTUBE_HOSTS.some((domain) => suffixMatch(host, domain))) return 'YOUTUBE';
// google.co.uk, google.de and the rest: the country domains all sit under a
// `google.<tld>` label, so match the label rather than listing 190 domains.
if (/(^|\.)google\.[a-z.]{2,6}$/.test(host)) return 'GOOGLE';
if (SEARCH_HOSTS.some((domain) => suffixMatch(host, domain))) return 'GOOGLE';
if (REVIEW_HOSTS.some((domain) => suffixMatch(host, domain))) return 'REVIEW_LINK';
if (COMMUNITY_HOSTS.some((domain) => suffixMatch(host, domain))) return 'COMMUNITY';
return null;
}
/**
* The bucket a visit belongs to.
*
* Precedence: an explicit medium that names the motion, then an explicit source,
* then the referring host, then direct. A tagged campaign we do not recognise is
* OTHER rather than DIRECT, because somebody deliberately tagged it.
*
* An unrecognised site that links to us counts as REFERRAL. The raw host is
* stored alongside, so a host that turns out to matter can be promoted into one
* of the lists above and re-read from history.
*/
export function classifyChannel(input: ChannelInput): AcquisitionChannel {
const source = sanitizeTag(input.utmSource);
const medium = sanitizeTag(input.utmMedium);
const host = normalizeHost(input.referrerHost);
const byMedium = medium ? MEDIUM_NAMES.get(medium) : undefined;
if (byMedium) return byMedium;
if (source) {
const bySource = SOURCE_NAMES.get(source);
if (bySource) return bySource;
// A source that looks like a domain (utm_source=github.com) is worth reading
// as one before giving up on it.
return classifyHost(source) ?? 'OTHER';
}
if (host) {
return classifyHost(host) ?? 'REFERRAL';
}
return 'DIRECT';
}