mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +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.
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
// 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;
|
|
}
|