+ 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');