From f6c4a21215be9f59e0d9c1b06698c8eca18a388b Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 4 Jul 2026 18:11:44 +0200 Subject: [PATCH] feat: add rate limiting and harden auth/calendar endpoints Add an in-memory sliding-window rate limiter and wire it into signup, resend-verification, reset-password, and verify-email routes. Store password reset tokens as SHA-256 hashes, raise bcrypt cost to 12, and switch verification codes to a CSPRNG. Stop echoing calendar access/refresh tokens back to API clients, bound the weather cache size, return JSON 401s for unauthenticated API calls instead of an HTML redirect, and drop the dead mock login route. v1.113.0 --- .env.production.save | 23 ------ .gitignore | 10 ++- package.json | 2 +- src/app/api/auth/login/route.ts | 55 -------------- src/app/api/auth/resend-verification/route.ts | 7 ++ src/app/api/auth/reset-password/route.ts | 27 +++++-- src/app/api/auth/signup/route.ts | 18 +++++ src/app/api/auth/verify-email/route.ts | 18 +++++ src/app/api/calendar/connections/route.ts | 12 ++- .../api/calendar/synology/connect/route.ts | 11 ++- src/app/api/weather/route.ts | 16 ++++ src/lib/email.ts | 5 +- src/lib/rate-limit.ts | 75 +++++++++++++++++++ src/middleware.ts | 7 +- 14 files changed, 196 insertions(+), 90 deletions(-) delete mode 100644 .env.production.save delete mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/lib/rate-limit.ts diff --git a/.env.production.save b/.env.production.save deleted file mode 100644 index 00b6db2..0000000 --- a/.env.production.save +++ /dev/null @@ -1,23 +0,0 @@ -# Database (Use your Supabase details) -# Note: 6543 is for the pooled connection (PGBouncer) -DATABASE_URL="postgresql://postgres:[PASSWORD]@[LXC_IP]:6543/postgres?pgbouncer=true" -# Note: 5432 is for direct migrations -DIRECT_URL="postgresql://postgres:[PASSWORD]@[LXC_IP]:5432/postgres" - -# App Settings -# NEXTAUTH_SECRET="f6e6f96860081d4da8b04e6e6f968600" # You can use any 32-char string -# NEXTAUTH_URL="http://192.168.178.8:3000" -# NEXT_PUBLIC_BASE_URL="http://192.168.178.8:3000" - -# NextAuth -NEXTAUTH_SECRET=Ca7EoJzZSMJO2CkqwdWd -NEXTAUTH_URL=https://todo.martin-bierschenk.de -NEXT_PUBLIC_BASE_URL=https://todo.martin-bierschenk.de - - -# SMTP (Optional: For password resets/emails) -SMTP_HOST=w00ff033.kasserver.com -SMTP_PORT=587 -SMTP_USERNAME=mail@carrylight.de -SMTP_PASSWORD=QijU8e2A8p3FE8WS8esR -SMTP_SECURE=true diff --git a/.gitignore b/.gitignore index 907f892..81b7580 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,12 @@ prisma/dev.db-journal # Next.js .next/ -certificates \ No newline at end of file +certificates + +# Design reference files (not tracked) +docs/design/ + +# Dokumentationen (nicht tracken) +APP_ERKLÄRUNG.md +.env.production.save +.env*.save diff --git a/package.json b/package.json index ce04262..461dded 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.102.0", + "version": "1.113.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts deleted file mode 100644 index 5f7f8a5..0000000 --- a/src/app/api/auth/login/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -export const dynamic = 'force-dynamic'; -import { compare } from 'bcryptjs'; - -export async function POST(request: NextRequest) { - try { - const { email, password } = await request.json(); - - // Basic validation - if (!email || !password) { - return NextResponse.json( - { error: 'Email and password are required' }, - { status: 400 } - ); - } - - // In a real app, you would fetch user from database here - // For now, we'll simulate a user lookup - console.log('Attempting to login user with email:', email); - - // Simulate database lookup - const mockStoredPassword = '$2b$10$examplehashedpassword'; // This would come from DB - - // Compare passwords - const isValid = await compare(password, mockStoredPassword); - - if (!isValid) { - return NextResponse.json( - { error: 'Invalid email or password' }, - { status: 401 } - ); - } - - // Create a mock JWT token (in a real app, you would use a proper JWT library) - const token = `mock-jwt-token-${Date.now()}`; - - // Set cookie - const response = NextResponse.json({ message: 'Login successful' }); - response.cookies.set('auth_token', token, { - httpOnly: true, - maxAge: 15 * 60, // 15 minutes - path: '/', - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict' - }); - - return response; - } catch (error) { - console.error('Login error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/api/auth/resend-verification/route.ts b/src/app/api/auth/resend-verification/route.ts index fad455e..8d84a66 100644 --- a/src/app/api/auth/resend-verification/route.ts +++ b/src/app/api/auth/resend-verification/route.ts @@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; import { prisma } from '@/lib/prisma'; import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email'; +import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit'; export async function POST(request: NextRequest) { try { + const ip = clientIp(request); + const limited = rateLimit(`resend-verification:${ip}`, 5, 15 * 60 * 1000); + if (!limited.ok) { + return tooManyRequests(limited.retryAfterSeconds); + } + const { email } = await request.json(); if (!email) { diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index 435068e..f16b6df 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -3,10 +3,25 @@ export const dynamic = 'force-dynamic'; import { hash } from 'bcryptjs'; import { prisma } from '@/lib/prisma'; import { sendPasswordResetEmail } from '@/lib/email'; +import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit'; import crypto from 'crypto'; +// Reset tokens are stored as SHA-256 hashes so a leaked DB dump cannot be +// used to take over accounts. The raw token only ever exists in the email. +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +const MIN_PASSWORD_LENGTH = 8; // keep in sync with signup + export async function POST(request: NextRequest) { try { + const ip = clientIp(request); + const limited = rateLimit(`reset-password:${ip}`, 10, 15 * 60 * 1000); + if (!limited.ok) { + return tooManyRequests(limited.retryAfterSeconds); + } + const body = await request.json(); const { email, token, newPassword } = body; @@ -25,7 +40,7 @@ export async function POST(request: NextRequest) { await prisma.user.update({ where: { email }, data: { - passwordResetToken: resetToken, + passwordResetToken: hashToken(resetToken), passwordResetExpires: resetExpires, }, }); @@ -37,16 +52,16 @@ export async function POST(request: NextRequest) { // CASE 2: Reset password (token + newPassword provided) if (token && newPassword) { - if (newPassword.length < 6) { + if (newPassword.length < MIN_PASSWORD_LENGTH) { return NextResponse.json( - { error: 'Password must be at least 6 characters' }, + { error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }, { status: 400 } ); } - const user = await (prisma.user as any).findFirst({ + const user = await prisma.user.findFirst({ where: { - passwordResetToken: token, + passwordResetToken: hashToken(token), passwordResetExpires: { gt: new Date() }, }, }); @@ -58,7 +73,7 @@ export async function POST(request: NextRequest) { ); } - const hashedPassword = await hash(newPassword, 10); + const hashedPassword = await hash(newPassword, 12); await prisma.user.update({ where: { id: user.id }, diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index c1c4ccd..2636094 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -4,9 +4,20 @@ import { prisma } from '@/lib/prisma'; import { hash } from 'bcryptjs'; import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email'; import { languageFromAcceptLanguage } from '@/lib/emailTemplates'; +import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit'; + +// Intentionally loose — real validation happens via the verification email. +// This only rejects obvious garbage before it reaches the DB and SMTP. +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export async function POST(request: NextRequest) { try { + const ip = clientIp(request); + const limited = rateLimit(`signup:${ip}`, 10, 60 * 60 * 1000); + if (!limited.ok) { + return tooManyRequests(limited.retryAfterSeconds); + } + const body = await request.json(); const { name, email, password } = body; // Prefer the explicit language from the form (filled in by the signup @@ -21,6 +32,13 @@ export async function POST(request: NextRequest) { ); } + if (typeof email !== 'string' || !EMAIL_PATTERN.test(email) || email.length > 254) { + return NextResponse.json( + { error: 'Please enter a valid email address' }, + { status: 400 } + ); + } + if (password.length < 8) { return NextResponse.json( { error: 'Password must be at least 8 characters' }, diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts index 8fc5613..c6e2410 100644 --- a/src/app/api/auth/verify-email/route.ts +++ b/src/app/api/auth/verify-email/route.ts @@ -1,12 +1,24 @@ import { NextRequest, NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; import { prisma } from '@/lib/prisma'; +import { rateLimit, clientIp, tooManyRequests, RateLimitResult } from '@/lib/rate-limit'; // POST: Verify via 6-digit code export async function POST(request: NextRequest) { try { const { email, code } = await request.json(); + // A 6-digit code has only 10^6 combinations — without attempt limiting + // it is brute-forceable in minutes. Limit per email AND per IP. + const ip = clientIp(request); + const perIp = rateLimit(`verify-email:ip:${ip}`, 10, 15 * 60 * 1000); + const perEmail: RateLimitResult = email + ? rateLimit(`verify-email:email:${String(email).toLowerCase()}`, 5, 15 * 60 * 1000) + : { ok: true }; + if (!perIp.ok || !perEmail.ok) { + return tooManyRequests(perIp.retryAfterSeconds ?? perEmail.retryAfterSeconds); + } + if (!email || !code) { return NextResponse.json( { error: 'Email and code are required' }, @@ -97,6 +109,12 @@ export async function GET(request: NextRequest) { // the user actually clicks the button — survives scanner pre-fetches). export async function PATCH(request: NextRequest) { try { + const ip = clientIp(request); + const limited = rateLimit(`verify-email-token:${ip}`, 10, 15 * 60 * 1000); + if (!limited.ok) { + return tooManyRequests(limited.retryAfterSeconds); + } + const { token } = await request.json(); if (!token) { return NextResponse.json({ error: 'Token is required' }, { status: 400 }); diff --git a/src/app/api/calendar/connections/route.ts b/src/app/api/calendar/connections/route.ts index 2c71f70..d5d4fe0 100644 --- a/src/app/api/calendar/connections/route.ts +++ b/src/app/api/calendar/connections/route.ts @@ -177,7 +177,17 @@ export async function PATCH(request: NextRequest) { refreshConnectionCache(rc, tMin, tMax) .catch(e => console.error('[CACHE] Post-selection refresh failed:', e)); - return NextResponse.json({ success: true, connection: updated }); + // Never echo accessToken/refreshToken back to the client + return NextResponse.json({ + success: true, + connection: { + id: updated.id, + provider: updated.provider, + calendars: updated.calendars, + createdAt: updated.createdAt, + expiresAt: updated.expiresAt, + }, + }); } catch (error) { console.error('Error updating calendar connection:', error); return NextResponse.json( diff --git a/src/app/api/calendar/synology/connect/route.ts b/src/app/api/calendar/synology/connect/route.ts index 986a565..28173f3 100644 --- a/src/app/api/calendar/synology/connect/route.ts +++ b/src/app/api/calendar/synology/connect/route.ts @@ -76,7 +76,16 @@ export async function POST(req: Request) { }); } - return NextResponse.json({ success: true, connection }); + // Never echo accessToken (username:password) back to the client + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + calendars: connection.calendars, + createdAt: connection.createdAt, + }, + }); } catch (error: any) { console.error('Error connecting Synology Calendar:', error); return NextResponse.json( diff --git a/src/app/api/weather/route.ts b/src/app/api/weather/route.ts index 6fcadbd..1bab420 100644 --- a/src/app/api/weather/route.ts +++ b/src/app/api/weather/route.ts @@ -8,6 +8,21 @@ export const dynamic = 'force-dynamic'; // In-memory cache: { key: { data, fetchedAt } } const weatherCache = new Map(); const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes +const CACHE_MAX_ENTRIES = 500; // one entry per user/date-range — bound the Map + +function evictStaleEntries() { + const now = Date.now(); + for (const [key, entry] of weatherCache) { + if (now - entry.fetchedAt >= CACHE_TTL_MS) weatherCache.delete(key); + } + // Still over budget after TTL eviction: drop oldest entries + // (Map iterates in insertion order). + while (weatherCache.size >= CACHE_MAX_ENTRIES) { + const oldest = weatherCache.keys().next().value; + if (oldest === undefined) break; + weatherCache.delete(oldest); + } +} export async function GET(request: NextRequest) { const session = await getServerSession(authOptions); @@ -67,6 +82,7 @@ export async function GET(request: NextRequest) { } const data = { hourly, timezone: raw.timezone }; + evictStaleEntries(); weatherCache.set(cacheKey, { data, fetchedAt: Date.now() }); return NextResponse.json(data); diff --git a/src/lib/email.ts b/src/lib/email.ts index de9e995..8903497 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,4 +1,5 @@ import nodemailer from 'nodemailer'; +import { randomInt } from 'crypto'; import { getVerificationCopy, getResetCopy } from './emailTemplates'; let _transporter: nodemailer.Transporter | null = null; @@ -185,7 +186,9 @@ export async function sendPasswordResetEmail(email: string, token: string, langu } export function generateVerificationCode(): string { - return Math.floor(100000 + Math.random() * 900000).toString(); + // CSPRNG-backed; Math.random() is predictable and must never be used + // for security tokens. + return randomInt(100000, 1000000).toString(); } export function generateVerificationToken(): string { diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..a1f75bd --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,75 @@ +/** + * Minimal in-memory sliding-window rate limiter for auth-sensitive routes. + * + * Works per Node.js process. If the app ever scales to multiple instances, + * swap the backing store for Redis — the call sites won't need to change. + */ + +type WindowEntry = { timestamps: number[] }; + +const buckets = new Map(); + +// Purge stale buckets periodically so the Map can't grow without bound. +const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; +let lastCleanup = Date.now(); + +function cleanup(windowMs: number) { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + for (const [key, entry] of buckets) { + entry.timestamps = entry.timestamps.filter(t => now - t < windowMs); + if (entry.timestamps.length === 0) buckets.delete(key); + } +} + +export interface RateLimitResult { + ok: boolean; + /** Seconds until the client may retry (only set when blocked). */ + retryAfterSeconds?: number; +} + +/** + * Record a hit for `key` and report whether it exceeds `limit` hits + * within the trailing `windowMs` window. + */ +export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult { + const now = Date.now(); + cleanup(windowMs); + + const entry = buckets.get(key) ?? { timestamps: [] }; + entry.timestamps = entry.timestamps.filter(t => now - t < windowMs); + + if (entry.timestamps.length >= limit) { + const oldest = entry.timestamps[0]; + return { + ok: false, + retryAfterSeconds: Math.max(1, Math.ceil((oldest + windowMs - now) / 1000)), + }; + } + + entry.timestamps.push(now); + buckets.set(key, entry); + return { ok: true }; +} + +/** Best-effort client IP extraction behind a reverse proxy. */ +export function clientIp(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) return forwarded.split(',')[0].trim(); + return request.headers.get('x-real-ip') ?? 'unknown'; +} + +/** Standard 429 payload so all auth routes respond consistently. */ +export function tooManyRequests(retryAfterSeconds = 60) { + return new Response( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(retryAfterSeconds), + }, + } + ); +} diff --git a/src/middleware.ts b/src/middleware.ts index 9200cc0..002c897 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -20,8 +20,13 @@ export async function middleware(request: NextRequest) { secret: process.env.NEXTAUTH_SECRET, }); - // Redirect to login if not authenticated if (!token && !request.nextUrl.pathname.startsWith('/auth')) { + // API callers get a proper 401 instead of a redirect to an HTML page — + // otherwise fetch() calls receive a 200 with the login page as body. + if (request.nextUrl.pathname.startsWith('/api')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + // Browser navigation: redirect to login const url = request.nextUrl.clone(); url.pathname = '/auth/login'; url.searchParams.set('callbackUrl', request.nextUrl.pathname);