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
+8 -2
View File
@@ -9,7 +9,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { signIn } from 'next-auth/react';
import { getSafeCallbackUrl, isInvitationCallbackUrl } from '@/lib/safe-redirect';
import {
getSafeCallbackUrl,
isInvitationCallbackUrl,
isSafeRelativePath,
} from '@/lib/safe-redirect';
/**
* Sign-up link that carries the pending destination — and, when that destination is an
@@ -88,8 +92,10 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
return;
}
// `result.url` is whatever next-auth resolved, so it is sanitized again here — and
// re-checked at the sink, because `router.push` happily leaves the origin.
const destination = getSafeCallbackUrl(result?.url || callbackUrl);
router.push(destination);
router.push(isSafeRelativePath(destination) ? destination : '/dashboard');
router.refresh();
} catch {
setError('Something went wrong. Please try again.');