diff --git a/app/(auth)/login/login-form.tsx b/app/(auth)/login/login-form.tsx index a9603f1..cc820e6 100644 --- a/app/(auth)/login/login-form.tsx +++ b/app/(auth)/login/login-form.tsx @@ -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.'); diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 402a6d4..43a012a 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -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} /> ); } diff --git a/app/(auth)/register/register-page-client.tsx b/app/(auth)/register/register-page-client.tsx index ee87564..7c9a2f4 100644 --- a/app/(auth)/register/register-page-client.tsx +++ b/app/(auth)/register/register-page-client.tsx @@ -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.

+ ) : isInvitationFlow && invitationLookupThrottled ? ( +
+ We couldn't check this invitation right now. Please wait a few minutes and + open the link again. +
) : isInvitationFlow ? (
This invitation link is no longer valid. Ask whoever invited you for a new one. diff --git a/app/invitations/accept/invitation-landing.tsx b/app/invitations/accept/invitation-landing.tsx index 71735d0..14810e3 100644 --- a/app/invitations/accept/invitation-landing.tsx +++ b/app/invitations/accept/invitation-landing.tsx @@ -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 ( + + ); +} + /** Signed in, but with an account whose address the invitation was not issued to. */ export function InvitationAccountMismatch({ invitedEmail, diff --git a/app/invitations/accept/page.tsx b/app/invitations/accept/page.tsx index 6198e47..7d97bdc 100644 --- a/app/invitations/accept/page.tsx +++ b/app/invitations/accept/page.tsx @@ -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 ; + } + const preview = await getInvitationPreviewByToken(token); return ; } diff --git a/lib/invitation-preview-limit.ts b/lib/invitation-preview-limit.ts new file mode 100644 index 0000000..fac13e8 --- /dev/null +++ b/lib/invitation-preview-limit.ts @@ -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 { + 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; +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 23dd36c..30a792a 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -81,6 +81,11 @@ export const RATE_LIMIT_CONFIGS: Record = { // 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(); diff --git a/lib/safe-redirect.ts b/lib/safe-redirect.ts index 81dfe9e..40dddae 100644 --- a/lib/safe-redirect.ts +++ b/lib/safe-redirect.ts @@ -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`, ``, + * `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');