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
@@ -24,6 +24,8 @@ interface RegisterPageClientProps {
googleEnabled: boolean;
githubEnabled: boolean;
invitation?: RegisterInvitation | null;
/** Preview lookup was rate-limited, so `invitation` says nothing about its validity. */
invitationLookupThrottled?: boolean;
}
export default function RegisterPageClient({
@@ -31,6 +33,7 @@ export default function RegisterPageClient({
googleEnabled,
githubEnabled,
invitation = null,
invitationLookupThrottled = false,
}: RegisterPageClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -245,6 +248,11 @@ export default function RegisterPageClient({
Create your account below you'll be taken straight to it.
</p>
</div>
) : isInvitationFlow && invitationLookupThrottled ? (
<div className="p-3 rounded-md bg-amber-500/10 text-sm">
We couldn&apos;t check this invitation right now. Please wait a few minutes and
open the link again.
</div>
) : isInvitationFlow ? (
<div className="p-3 rounded-md bg-amber-500/10 text-sm">
This invitation link is no longer valid. Ask whoever invited you for a new one.