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.');
+7 -1
View File
@@ -1,5 +1,6 @@
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { getInvitationPreviewByToken } from '@/lib/invitations';
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
import RegisterPageClient from './register-page-client';
interface RegisterPageProps {
@@ -11,7 +12,11 @@ export default async function RegisterPage({ searchParams }: RegisterPageProps)
const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
const token = (await searchParams)?.invitationToken?.trim();
const preview = token ? await getInvitationPreviewByToken(token) : null;
// Unauthenticated invitation lookup — throttled per IP (and per token) before it can
// touch the database. When throttled we skip the lookup instead of guessing at a verdict.
const previewAllowed = token ? await isInvitationPreviewAllowed(token) : false;
const preview = token && previewAllowed ? await getInvitationPreviewByToken(token) : null;
const invitation =
preview && preview.status === 'PENDING' && !preview.isExpired
? {
@@ -29,6 +34,7 @@ export default async function RegisterPage({ searchParams }: RegisterPageProps)
googleEnabled={googleEnabled}
githubEnabled={githubEnabled}
invitation={invitation}
invitationLookupThrottled={Boolean(token) && !previewAllowed}
/>
);
}
@@ -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.
@@ -47,6 +47,16 @@ function UnusableInvitation({ title, message }: { title: string; message: string
);
}
/** Too many unauthenticated invitation lookups from this client — nothing was queried. */
export function InvitationRateLimited() {
return (
<UnusableInvitation
title="Too many attempts"
message="We couldn't check this invitation right now. Please wait a few minutes and open the link again."
/>
);
}
/** Signed in, but with an account whose address the invitation was not issued to. */
export function InvitationAccountMismatch({
invitedEmail,
+12 -2
View File
@@ -2,7 +2,12 @@ import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { acceptInvitationTokenForUser, getInvitationPreviewByToken } from '@/lib/invitations';
import { InvitationAccountMismatch, InvitationLanding } from './invitation-landing';
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
import {
InvitationAccountMismatch,
InvitationLanding,
InvitationRateLimited,
} from './invitation-landing';
interface InvitationAcceptPageProps {
searchParams: Promise<{
@@ -21,7 +26,12 @@ export default async function InvitationAcceptPage({ searchParams }: InvitationA
const session = await auth();
if (!session?.user?.id) {
// Signed-out visitors get the invitation itself instead of a bare login form:
// most of them have no account yet and need to be told to create one.
// most of them have no account yet and need to be told to create one. This is the
// only unauthenticated read of invitation data, so it is IP-throttled.
if (!(await isInvitationPreviewAllowed(token))) {
return <InvitationRateLimited />;
}
const preview = await getInvitationPreviewByToken(token);
return <InvitationLanding token={token} preview={preview} />;
}