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:
@@ -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.');
|
||||
|
||||
@@ -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'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,
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+16
-3
@@ -81,6 +81,11 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
|
||||
// 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();
|
||||
|
||||
+17
-1
@@ -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`, `<Link href>`,
|
||||
* `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');
|
||||
|
||||
Reference in New Issue
Block a user