mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user