feat(intake): sign the two actions an email may take (#224)

Signed over the item, the action and the expiry together. Signing any subset would let a link be replayed against a different item or upgraded to a different action, and leaving the expiry out of the payload would let anyone holding an expired link extend it by editing the timestamp in the URL. Each of those is a test.

Compared through a second digest rather than directly, because timingSafeEqual throws when the buffers differ in length, and a truncated link is an ordinary thing to receive rather than an exception. Same idiom as the admin gate.

actionUrl returns null rather than throwing when there is no secret or no PUBLIC_URL. An unconfigured environment still sends the notification with its review link — being told an item arrived matters far more than the shortcuts do — and a link that could not be verified must never be offered in the first place.

Uses the shared trimTrailingSlashes rather than a trailing-slash regex, which is what utils.ts exports it for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 15:20:19 -05:00
co-authored by Claude Opus 5
parent 4ccc1ffcaf
commit acc1b406d8
2 changed files with 160 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
import crypto from 'crypto';
import { trimTrailingSlashes } from '../utils';
/**
* Links in the notification email that act without a login.
*
* Only two actions are signable, and neither can publish. The worst case of a
* leaked link is a wasted API call or a hide the review queue can undo — which
* is what makes it acceptable to put them in an inbox at all.
*
* Signed over the item, the action and the expiry together. Signing any subset
* would let a link be replayed against a different item or upgraded to a
* different action, and leaving the expiry out of the payload would let anyone
* holding an expired link extend it by editing the timestamp in the URL.
*/
export type IntakeAction = 'regenerate' | 'discard';
/** Thirty days. Long enough to survive a holiday, short enough to lapse. */
export const ACTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
function secret(): string | null {
const value = process.env.INTAKE_ACTION_SECRET;
return value && value.trim() !== '' ? value : null;
}
export function signAction(itemId: number, action: IntakeAction, expiresAt: number): string {
const key = secret();
if (!key) throw new Error('INTAKE_ACTION_SECRET is not set');
return crypto
.createHmac('sha256', key)
.update(`${itemId}:${action}:${expiresAt}`)
.digest('base64url');
}
/**
* Compared through a second digest rather than directly, because
* timingSafeEqual throws when the two buffers differ in length — and a
* malformed signature from a truncated link is an ordinary thing to receive
* rather than an exception. Same idiom as middleware/adminGate.ts.
*/
function digest(value: string): Buffer {
return crypto.createHash('sha256').update(value).digest();
}
export function verifyAction(
itemId: number,
action: IntakeAction,
expiresAt: number,
signature: string,
now: number = Date.now()
): boolean {
if (!secret()) return false;
if (!Number.isFinite(expiresAt) || now > expiresAt) return false;
const expected = signAction(itemId, action, expiresAt);
return crypto.timingSafeEqual(digest(expected), digest(signature));
}
/**
* The absolute link, or null when one cannot be made.
*
* Null rather than a throw or a relative path. An unconfigured environment
* still sends the notification with its review link — being told an item
* arrived matters far more than the shortcuts — and a link that could not be
* verified must never be offered in the first place.
*/
export function actionUrl(itemId: number, action: IntakeAction): string | null {
const base = process.env.PUBLIC_URL;
if (!secret() || !base || base.trim() === '') return null;
const expiresAt = Date.now() + ACTION_TTL_MS;
const sig = signAction(itemId, action, expiresAt);
const origin = trimTrailingSlashes(base);
return `${origin}/api/intake-actions/${itemId}/${action}?expires=${expiresAt}&sig=${sig}`;
}