fix(invitations): throttle unauthenticated invitation lookups and harden redirects

The invitation preview surfaces (/invitations/accept and /register?invitationToken=) are the
only unauthenticated reads of invitation data, and each render costs two database queries.
They are now rate limited before the lookup can touch the database: a generous per-IP bucket
that bounds enumeration across tokens, plus a tight per-IP+token bucket that stops repeated
probing of a single invitation. Tokens are hashed before they reach the rate_limits table.

A throttled lookup says so ("we couldn't check this invitation right now") instead of claiming
the invitation is invalid, and signed-in acceptance is not gated by it.

The callback sanitizer also checked only the origin, which is not enough: an attacker can
smuggle a host into the path of an otherwise same-origin URL — new URL('https://app/​/evil.com')
keeps our origin but yields a pathname of //evil.com, which navigation sinks resolve as
protocol-relative and follow off-site. Paths are now required to be rooted at a single slash,
and the login redirect re-checks at the sink.

getClientIp is split so server components that only have `await headers()` resolve the client
IP through the same trusted-proxy logic as route handlers.
This commit is contained in:
yusufipk
2026-07-25 19:39:16 +07:00
parent 9c75ce91e1
commit b1aed03fca
8 changed files with 108 additions and 9 deletions
+30
View File
@@ -0,0 +1,30 @@
import { headers } from 'next/headers';
import { checkRateLimit, getClientIpFromHeaders } from '@/lib/rate-limit';
import { createHash } from 'crypto';
/**
* The invitation preview surfaces (`/invitations/accept` and `/register?invitationToken=`)
* are reachable without a session and each render costs two database queries, so they are
* throttled the same way the emailed verify-email link is.
*
* Two buckets: a generous per-IP one that bounds enumeration across many tokens, and a
* tight per-IP+token one that stops repeated probing of a single invitation.
*
* Returns false when the caller should skip the lookup entirely.
*/
export async function isInvitationPreviewAllowed(token: string): Promise<boolean> {
const ip = getClientIpFromHeaders(await headers());
const perIp = await checkRateLimit(`invitation-preview:${ip}`, 'invitation-preview');
if (!perIp.allowed) return false;
// Hash the token so raw invitation secrets never reach the rate_limits table (and stay
// within the 256-char key bound).
const tokenHash = createHash('sha256').update(token).digest('hex').slice(0, 32);
const perToken = await checkRateLimit(
`invitation-preview:${ip}:${tokenHash}`,
'invitation-preview-token'
);
return perToken.allowed;
}
+16 -3
View File
@@ -81,6 +81,11 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
// Member management
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
// Unauthenticated invitation preview (accept landing + invited sign-up). Two buckets,
// as with share-unlock: a generous per-IP one that bounds token enumeration, and a
// tight per-IP+token one that stops repeated probing of a single invitation.
'invitation-preview': { windowMs: 15 * 60 * 1000, maxRequests: 120 }, // 120 per 15 min per IP
'invitation-preview-token': { windowMs: 15 * 60 * 1000, maxRequests: 12 }, // 12 per 15 min per IP+token
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
// Mutations (update/delete) — moderate
@@ -188,12 +193,20 @@ function isPlausibleIp(value: string): boolean {
* do so allows clients to spoof their IP and bypass rate limits.
*/
export function getClientIp(request: Request): string {
return getClientIpFromHeaders(request.headers);
}
/**
* Same resolution as {@link getClientIp}, for callers that only have headers rather than a
* Request — server components reading `await headers()`.
*/
export function getClientIpFromHeaders(headers: Headers): string {
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
if (mode === 'cloudflare') {
// cf-connecting-ip is injected by Cloudflare and cannot be set by clients
// when origin access is restricted to Cloudflare's IP ranges.
const cfIp = request.headers.get('cf-connecting-ip');
const cfIp = headers.get('cf-connecting-ip');
if (cfIp && isPlausibleIp(cfIp)) {
return cfIp;
}
@@ -202,11 +215,11 @@ export function getClientIp(request: Request): string {
if (mode === 'nginx') {
// x-real-ip is set by Nginx's real_ip_header directive (connection-level, not spoofable
// by clients when set_real_ip_from is configured for the upstream proxy).
const realIp = request.headers.get('x-real-ip');
const realIp = headers.get('x-real-ip');
if (realIp && isPlausibleIp(realIp)) return realIp;
// x-forwarded-for last entry added by Nginx when proxy_add_x_forwarded_for is used.
const forwardedFor = request.headers.get('x-forwarded-for');
const forwardedFor = headers.get('x-forwarded-for');
if (forwardedFor) {
const entries = forwardedFor.split(',');
const last = entries[entries.length - 1].trim();
+17 -1
View File
@@ -19,12 +19,28 @@ export function getSafeCallbackUrl(
try {
const parsed = new URL(value, baseOrigin);
if (parsed.origin !== baseOrigin) return fallback;
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
// The origin check alone is not enough: an attacker can smuggle their own host into
// the path of an otherwise same-origin URL. `new URL('https://app.example.com//evil.com')`
// has our origin but a pathname of `//evil.com`, which every navigation sink below
// resolves as protocol-relative and follows off-site.
return isSafeRelativePath(path) ? path : fallback;
} catch {
return fallback;
}
}
/**
* True when a path is safe to hand to a navigation sink (`router.push`, `<Link href>`,
* `NextResponse.redirect`): rooted at a single `/`, so it can only ever stay on this origin.
*
* `//evil.com` and `/\evil.com` are protocol-relative — browsers fill in the current
* scheme and navigate to `evil.com`.
*/
export function isSafeRelativePath(value: string): boolean {
return value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\');
}
/** True when a sanitized path points at the invitation acceptance route. */
export function isInvitationCallbackUrl(path: string): boolean {
return path.startsWith('/invitations/accept');