Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/(auth)/api/auth/guest/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { redirect } from 'next/navigation';
import { auth, signIn } from '@/app/(auth)/auth';

export async function GET() {
const session = await auth();

if (!session?.user?.id) {
await signIn('guest', { redirect: false });
redirect('/');
}

return new Response('Unauthorized', { status: 401 });
}
28 changes: 1 addition & 27 deletions app/(auth)/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,5 @@ export const authConfig = {
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js
// while this file is also used in non-Node.js environments
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnChat = nextUrl.pathname.startsWith('/');
const isOnRegister = nextUrl.pathname.startsWith('/register');
const isOnLogin = nextUrl.pathname.startsWith('/login');

if (isLoggedIn && (isOnLogin || isOnRegister)) {
return Response.redirect(new URL('/', nextUrl as unknown as URL));
}

if (isOnRegister || isOnLogin) {
return true; // Always allow access to register and login pages
}

if (isOnChat) {
if (isLoggedIn) return true;
return false; // Redirect unauthenticated users to login page
}

if (isLoggedIn) {
return Response.redirect(new URL('/', nextUrl as unknown as URL));
}

return true;
},
},
callbacks: {},
} satisfies NextAuthConfig;
24 changes: 18 additions & 6 deletions app/(auth)/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { compare } from 'bcrypt-ts';
import NextAuth, { type User, type Session } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';

import { getUser } from '@/lib/db/queries';
import { createAnonymousUser, getUser } from '@/lib/db/queries';

import { authConfig } from './auth.config';

Expand All @@ -21,12 +21,24 @@ export const {
Credentials({
credentials: {},
async authorize({ email, password }: any) {
const users = await getUser(email);
if (users.length === 0) return null;
// biome-ignore lint: Forbidden non-null assertion.
const passwordsMatch = await compare(password, users[0].password!);
const [user] = await getUser(email);

if (!user) return null;
if (!user.password) return null;

const passwordsMatch = await compare(password, user.password);

if (!passwordsMatch) return null;
return users[0] as any;

return user;
},
}),
Credentials({
id: 'guest',
credentials: {},
async authorize() {
const [anonymousUser] = await createAnonymousUser();
return anonymousUser;
},
}),
],
Expand Down
4 changes: 4 additions & 0 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AuthForm } from '@/components/auth-form';
import { SubmitButton } from '@/components/submit-button';

import { login, type LoginActionState } from '../actions';
import { useSession } from 'next-auth/react';

export default function Page() {
const router = useRouter();
Expand All @@ -23,6 +24,8 @@ export default function Page() {
},
);

const { update: updateSession } = useSession();

useEffect(() => {
if (state.status === 'failed') {
toast({
Expand All @@ -36,6 +39,7 @@ export default function Page() {
});
} else if (state.status === 'success') {
setIsSuccessful(true);
updateSession();
router.refresh();
}
}, [state.status]);
Expand Down
4 changes: 4 additions & 0 deletions app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SubmitButton } from '@/components/submit-button';

import { register, type RegisterActionState } from '../actions';
import { toast } from '@/components/toast';
import { useSession } from 'next-auth/react';

export default function Page() {
const router = useRouter();
Expand All @@ -23,6 +24,8 @@ export default function Page() {
},
);

const { update: updateSession } = useSession();

useEffect(() => {
if (state.status === 'user_exists') {
toast({ type: 'error', description: 'Account already exists!' });
Expand All @@ -37,6 +40,7 @@ export default function Page() {
toast({ type: 'success', description: 'Account created successfully!' });

setIsSuccessful(true);
updateSession();
router.refresh();
}
}, [state]);
Expand Down
34 changes: 31 additions & 3 deletions app/(chat)/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
UIMessage,
type UIMessage,
appendResponseMessages,
createDataStreamResponse,
smoothStream,
Expand All @@ -10,6 +10,7 @@ import { systemPrompt } from '@/lib/ai/prompts';
import {
deleteChatById,
getChatById,
getMessageCountByUserId,
saveChat,
saveMessages,
} from '@/lib/db/queries';
Expand All @@ -23,8 +24,12 @@ import { createDocument } from '@/lib/ai/tools/create-document';
import { updateDocument } from '@/lib/ai/tools/update-document';
import { requestSuggestions } from '@/lib/ai/tools/request-suggestions';
import { getWeather } from '@/lib/ai/tools/get-weather';
import { isProductionEnvironment } from '@/lib/constants';
import { anonymousRegex, isProductionEnvironment } from '@/lib/constants';
import { myProvider } from '@/lib/ai/providers';
import {
entitlementsByMembershipTier,
type MembershipTier,
} from '@/lib/ai/capabilities';

export const maxDuration = 60;

Expand All @@ -42,10 +47,33 @@ export async function POST(request: Request) {

const session = await auth();

if (!session || !session.user || !session.user.id) {
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}

const membershipTier: MembershipTier = anonymousRegex.test(
session.user.email ?? '',
)
? 'guest'
: 'free';

const messageCount = await getMessageCountByUserId({
id: session.user.id,
differenceInHours: 24,
});

if (
messageCount >
entitlementsByMembershipTier[membershipTier].maxMessagesPerDay
) {
return new Response(
'You have exceeded your maximum number of messages for the day',
{
status: 429,
},
Comment on lines 47 to +73
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Insufficient Rate Limiting for Guest Users

The PR implements rate limiting based on message count per user ID, but lacks IP-based rate limiting for guest account creation. An attacker could create unlimited anonymous accounts (each with its own message quota) by repeatedly calling the guest authentication endpoint, effectively bypassing the per-user message limit and potentially causing a denial of service.

Suggested change
const session = await auth();
if (!session || !session.user || !session.user.id) {
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
const membershipTier: MembershipTier = anonymousRegex.test(
session.user.email ?? '',
)
? 'guest'
: 'free';
const messageCount = await getMessageCountByUserId({
id: session.user.id,
differenceInHours: 24,
});
if (
messageCount >
entitlementsByMembershipTier[membershipTier].maxMessagesPerDay
) {
return new Response(
'You have exceeded your maximum number of messages for the day',
{
status: 429,
},
// Add this to app/(auth)/api/auth/guest/route.ts after the imports
import { rateLimit } from '@/lib/rate-limit';
// Add this before the GET function
const limiter = rateLimit({
interval: 60 * 60 * 1000, // 1 hour
uniqueTokenPerInterval: 500, // Max 500 users per hour
});

Standard: CWE-770
Standard: OWASP Top 10 2021: A04 - Insecure Design

);
}

const userMessage = getMostRecentUserMessage(messages);

if (!userMessage) {
Expand Down
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from 'next/font/google';
import { ThemeProvider } from '@/components/theme-provider';

import './globals.css';
import { SessionProvider } from 'next-auth/react';

export const metadata: Metadata = {
metadataBase: new URL('https://chat.vercel.ai'),
Expand Down Expand Up @@ -77,7 +78,7 @@ export default async function RootLayout({
disableTransitionOnChange
>
<Toaster position="top-center" />
{children}
<SessionProvider>{children}</SessionProvider>
</ThemeProvider>
</body>
</html>
Expand Down
71 changes: 55 additions & 16 deletions components/sidebar-user-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use client';

import { ChevronUp } from 'lucide-react';
import Image from 'next/image';
import type { User } from 'next-auth';
import { signOut } from 'next-auth/react';
import { signOut, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes';

import {
Expand All @@ -17,26 +18,50 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { anonymousRegex } from '@/lib/constants';
import { useRouter } from 'next/navigation';
import { toast } from './toast';
import { LoaderIcon } from './icons';

export function SidebarUserNav({ user }: { user: User }) {
const router = useRouter();
const { data, status } = useSession();
const { setTheme, theme } = useTheme();

const isGuest = anonymousRegex.test(data?.user?.email ?? '');

return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10">
<Image
src={`https://avatar.vercel.sh/${user.email}`}
alt={user.email ?? 'User Avatar'}
width={24}
height={24}
className="rounded-full"
/>
<span className="truncate">{user?.email}</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
{status === 'loading' ? (
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10 justify-between">
<div className="flex flex-row gap-2">
<div className="size-6 bg-zinc-500/30 rounded-full animate-pulse" />
<span className="bg-zinc-500/30 text-transparent rounded-md animate-pulse">
Loading auth status
</span>
</div>
<div className="animate-spin text-zinc-500">
<LoaderIcon />
</div>
</SidebarMenuButton>
) : (
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10">
<Image
src={`https://avatar.vercel.sh/${user.email}`}
alt={user.email ?? 'User Avatar'}
width={24}
height={24}
className="rounded-full"
/>
<span className="truncate">
{isGuest ? 'Guest' : user?.email}
</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
Expand All @@ -54,12 +79,26 @@ export function SidebarUserNav({ user }: { user: User }) {
type="button"
className="w-full cursor-pointer"
onClick={() => {
signOut({
redirectTo: '/',
});
if (status === 'loading') {
toast({
type: 'error',
description:
'Checking authentication status, please try again!',
});

return;
}

if (isGuest) {
router.push('/login');
} else {
signOut({
redirectTo: '/',
});
}
}}
>
Sign out
{isGuest ? 'Login to your account' : 'Sign out'}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
Expand Down
33 changes: 33 additions & 0 deletions lib/ai/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ChatModel } from './models';

export type MembershipTier = 'guest' | 'free';

interface Entitlements {
maxMessagesPerDay: number;
chatModelsAvailable: Array<ChatModel['id']>;
}

export const entitlementsByMembershipTier: Record<
MembershipTier,
Entitlements
> = {
/*
* For users without an account
*/
guest: {
maxMessagesPerDay: 20,
chatModelsAvailable: ['chat-model', 'chat-model-reasoning'],
},

/*
* For user with an account
*/
free: {
maxMessagesPerDay: 100,
chatModelsAvailable: ['chat-model', 'chat-model-reasoning'],
},

/*
* TODO: For users with an account and a paid membership
*/
};
2 changes: 1 addition & 1 deletion lib/ai/models.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const DEFAULT_CHAT_MODEL: string = 'chat-model';

interface ChatModel {
export interface ChatModel {
id: string;
name: string;
description: string;
Expand Down
2 changes: 2 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export const isTestEnvironment = Boolean(
process.env.PLAYWRIGHT ||
process.env.CI_PLAYWRIGHT,
);

export const anonymousRegex = /^anonymous-\d+$/;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This regular expression is designed to match anonymous user emails created with Date.now(). This is coupled with the user creation logic in lib/db/queries.ts. If you update the email generation to use UUIDs for better uniqueness (as suggested in a separate comment), this regex will no longer match and will need to be updated.

A simple update could be to match any characters after anonymous-, or you could use a more specific regex for UUIDs.

Suggested change
export const anonymousRegex = /^anonymous-\d+$/;
export const anonymousRegex = /^anonymous-.+/;

Loading