mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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.
96 lines
2.8 KiB
TypeScript
96 lines
2.8 KiB
TypeScript
import { redirect } from 'next/navigation';
|
|
import { auth } from '@/lib/auth';
|
|
import { db } from '@/lib/db';
|
|
import { acceptInvitationTokenForUser, getInvitationPreviewByToken } from '@/lib/invitations';
|
|
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
|
|
import {
|
|
InvitationAccountMismatch,
|
|
InvitationLanding,
|
|
InvitationRateLimited,
|
|
} from './invitation-landing';
|
|
|
|
interface InvitationAcceptPageProps {
|
|
searchParams: Promise<{
|
|
token?: string;
|
|
}>;
|
|
}
|
|
|
|
export default async function InvitationAcceptPage({ searchParams }: InvitationAcceptPageProps) {
|
|
const resolvedSearchParams = await searchParams;
|
|
const token = resolvedSearchParams.token?.trim();
|
|
|
|
if (!token) {
|
|
redirect('/login?error=invalid_invitation');
|
|
}
|
|
|
|
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. 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} />;
|
|
}
|
|
|
|
const invitation = await db.invitation.findUnique({
|
|
where: { token },
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
status: true,
|
|
scope: true,
|
|
workspaceId: true,
|
|
projectId: true,
|
|
},
|
|
});
|
|
|
|
function redirectToInvitationTarget(inviteStatus: string) {
|
|
if (invitation?.scope === 'WORKSPACE' && invitation.workspaceId) {
|
|
redirect(`/workspaces/${invitation.workspaceId}?invite=${inviteStatus}`);
|
|
}
|
|
if (invitation?.scope === 'PROJECT' && invitation.projectId) {
|
|
redirect(`/projects/${invitation.projectId}?invite=${inviteStatus}`);
|
|
}
|
|
}
|
|
|
|
const userEmail = session.user.email?.toLowerCase().trim();
|
|
if (!userEmail) {
|
|
redirect('/dashboard?invite=invalid_email');
|
|
}
|
|
|
|
const result = await acceptInvitationTokenForUser({
|
|
token,
|
|
userId: session.user.id,
|
|
email: userEmail,
|
|
});
|
|
|
|
if (result === 'accepted') {
|
|
redirectToInvitationTarget('accepted');
|
|
redirect('/dashboard?invite=accepted');
|
|
}
|
|
if (result === 'expired') {
|
|
redirectToInvitationTarget('expired');
|
|
redirect('/dashboard?invite=expired');
|
|
}
|
|
if (result === 'forbidden') {
|
|
// Signed in with a different address than the one invited — say so instead of
|
|
// dropping the user on the dashboard with no explanation.
|
|
return (
|
|
<InvitationAccountMismatch
|
|
invitedEmail={invitation?.email ?? 'another address'}
|
|
signedInEmail={userEmail}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (result === 'not_found' && invitation?.status === 'ACCEPTED') {
|
|
redirectToInvitationTarget('already_accepted');
|
|
}
|
|
|
|
redirect('/dashboard?invite=not_found');
|
|
}
|