From 97dceafc93ee1c7a267349519bbf927ade3760cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 05:34:41 +0000 Subject: [PATCH 1/2] Security hardening: rate limit login, remove SESSION_SECRET, harden cookie secure flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add in-memory per-IP rate limiter to login action (5 failures in 15 min → 429) with global counter defending against distributed brute force (50 failures across all IPs in 15 min → 429); successful login clears the per-IP counter; stale entries swept every 5 minutes - Flip cookie secure flag default to true; only NODE_ENV=development disables it (fail-safe instead of fail-open) - Remove SESSION_SECRET from .env.example and README — variable was never used; sessions rely on a random 32-byte token stored server-side in SQLite - Add comment to createSession in auth.ts explaining the session token strategy https://claude.ai/code/session_01BuWpZ7GP3i3i5LHb5N4GBV --- .env.example | 4 --- README.md | 3 +- src/lib/server/auth.ts | 2 ++ src/routes/+page.server.ts | 68 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index d6f7955..c7f7a22 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,3 @@ -# Required: long random string for signing session cookies -# Generate with: openssl rand -hex 32 -SESSION_SECRET=change-me-to-a-long-random-string - # Database file path # Default in dev: ./data/tipsplit.db # Default in container: /app/data/tipsplit.db diff --git a/README.md b/README.md index 82d074c..f6c60eb 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ cp .env.example .env Edit `.env` — at minimum: ```bash -SESSION_SECRET= # openssl rand -hex 32 INITIAL_MANAGER_PIN=1234 # remove after first login ``` @@ -194,7 +193,7 @@ Each exported calculation appends one row with the full breakdown. ```bash npm install -cp .env.example .env # set SESSION_SECRET and INITIAL_MANAGER_PIN +cp .env.example .env # set INITIAL_MANAGER_PIN npm run dev # http://localhost:5173 npm test # unit tests npm run check # TypeScript diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index aaca52c..3641789 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -18,6 +18,8 @@ export async function findUserByPin(pin: string): Promise { return null; } +// Sessions use a cryptographically random 32-byte token stored server-side in SQLite. +// No cookie signing is needed because the token itself is the secret. export function createSession(userId: number): string { const id = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + SESSION_TTL_SECS * 1000).toISOString(); diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 488328d..2c9df54 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -2,21 +2,83 @@ import type { Actions, PageServerLoad } from './$types'; import { fail, redirect } from '@sveltejs/kit'; import { findUserByPin, createSession } from '$lib/server/auth'; +const WINDOW_MS = 15 * 60 * 1000; // 15 minutes +const MAX_ATTEMPTS = 5; +const GLOBAL_MAX = 50; + +// Per-IP failed attempt tracking +const failedAttempts = new Map(); + +// Global counter for distributed brute force detection +let globalFailed = { count: 0, firstAttempt: 0 }; + +// Sweep stale entries every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of failedAttempts) { + if (now - entry.firstAttempt > WINDOW_MS) failedAttempts.delete(ip); + } + if (globalFailed.count > 0 && now - globalFailed.firstAttempt > WINDOW_MS) { + globalFailed = { count: 0, firstAttempt: 0 }; + } +}, 5 * 60 * 1000); + export const load: PageServerLoad = ({ locals }) => { if (locals.user) redirect(303, '/calculate'); return {}; }; export const actions: Actions = { - default: async ({ request, cookies, locals }) => { + default: async ({ request, cookies, locals, getClientAddress }) => { if (locals.user) redirect(303, '/calculate'); const pin = String((await request.formData()).get('pin') ?? '').trim(); if (pin.length < 4) return fail(400, { error: 'PIN must be at least 4 digits' }); + const ip = getClientAddress(); + const now = Date.now(); + + // Lazy cleanup: expire this IP's window if stale + const entry = failedAttempts.get(ip); + if (entry && now - entry.firstAttempt > WINDOW_MS) { + failedAttempts.delete(ip); + } + + // Check per-IP rate limit + const current = failedAttempts.get(ip); + if (current && current.count >= MAX_ATTEMPTS) { + return fail(429, { error: 'Too many attempts. Try again in a few minutes.' }); + } + + // Check global rate limit (distributed brute force) + if (globalFailed.count > 0 && now - globalFailed.firstAttempt > WINDOW_MS) { + globalFailed = { count: 0, firstAttempt: 0 }; + } + if (globalFailed.count >= GLOBAL_MAX) { + return fail(429, { error: 'Too many attempts. Try again in a few minutes.' }); + } + const user = await findUserByPin(pin); - if (!user) return fail(401, { error: 'Incorrect PIN — try again' }); + if (!user) { + // Increment per-IP counter + const existing = failedAttempts.get(ip); + if (existing) { + existing.count++; + } else { + failedAttempts.set(ip, { count: 1, firstAttempt: now }); + } + // Increment global counter + if (globalFailed.count === 0) { + globalFailed = { count: 1, firstAttempt: now }; + } else { + globalFailed.count++; + } + return fail(401, { error: 'Incorrect PIN — try again' }); + } + + // Successful login: clear per-IP counter + failedAttempts.delete(ip); const sessionId = createSession(user.id); cookies.set('session', sessionId, { @@ -24,7 +86,7 @@ export const actions: Actions = { httpOnly: true, sameSite: 'strict', maxAge: 60 * 60 * 24, - secure: process.env.NODE_ENV === 'production', + secure: process.env.NODE_ENV !== 'development', }); redirect(303, '/calculate'); From cd4c28cf10970ff8046ed126dce2df34ea8c1cd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 11:53:05 +0000 Subject: [PATCH 2/2] Fix lunch cutoff DST bug and add settings percentage validation - Replace hardcoded UTC-7 offset with Intl.DateTimeFormat('America/Los_Angeles') so DST transitions are handled correctly; also read the full HH:MM from the lunch_cutoff setting (previously only the hour was compared) - Validate cc_fee_rate, kitchen_pct, and bar_liquor_pct in saveSettings: each must be a number in [0, 100], and kitchen_pct + bar_liquor_pct must not exceed 100% combined; invalid input returns a 400 with a descriptive error message https://claude.ai/code/session_01BuWpZ7GP3i3i5LHb5N4GBV --- src/routes/calculate/+page.server.ts | 16 ++++++++++++---- src/routes/settings/+page.server.ts | 20 +++++++++++++++++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/routes/calculate/+page.server.ts b/src/routes/calculate/+page.server.ts index d988ec0..081203f 100644 --- a/src/routes/calculate/+page.server.ts +++ b/src/routes/calculate/+page.server.ts @@ -14,10 +14,18 @@ export const load: PageServerLoad = ({ locals }) => { const settings = getSettings(); const today = new Date().toISOString().split('T')[0]; - const cutoffHour = parseInt(settings.lunch_cutoff?.split(':')[0] ?? '15', 10); - // Convert Pacific time: UTC-7 (PDT) or UTC-8 (PST) — approximate with UTC-7 - const pacificHour = (new Date().getUTCHours() - 7 + 24) % 24; - const defaultShift = pacificHour >= cutoffHour ? 'Dinner' : 'Lunch'; + const [cutoffH, cutoffM] = (settings.lunch_cutoff ?? '15:00').split(':').map(Number); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Los_Angeles', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(new Date()); + const localHour = parseInt(parts.find(p => p.type === 'hour')!.value, 10); + const localMinute = parseInt(parts.find(p => p.type === 'minute')!.value, 10); + const defaultShift = localHour > cutoffH || (localHour === cutoffH && localMinute >= cutoffM) + ? 'Dinner' + : 'Lunch'; return { staff, settings, today, defaultShift, user: locals.user }; }; diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts index 7232b69..696b99e 100644 --- a/src/routes/settings/+page.server.ts +++ b/src/routes/settings/+page.server.ts @@ -16,10 +16,24 @@ export const actions: Actions = { } const fd = await request.formData(); + + const ccFeeRate = parseFloat(String(fd.get('cc_fee_rate') ?? '')); + const kitchenPct = parseFloat(String(fd.get('kitchen_pct') ?? '')); + const barLiquorPct = parseFloat(String(fd.get('bar_liquor_pct') ?? '')); + + if (isNaN(ccFeeRate) || ccFeeRate < 0 || ccFeeRate > 100) + return fail(400, { error: 'CC fee rate must be between 0 and 100' }); + if (isNaN(kitchenPct) || kitchenPct < 0 || kitchenPct > 100) + return fail(400, { error: 'Kitchen % must be between 0 and 100' }); + if (isNaN(barLiquorPct) || barLiquorPct < 0 || barLiquorPct > 100) + return fail(400, { error: 'Bar liquor % must be between 0 and 100' }); + if (kitchenPct + barLiquorPct > 100) + return fail(400, { error: 'Kitchen % and bar liquor % cannot exceed 100% combined' }); + const updates: [string, string][] = [ - ['cc_fee_rate', String(fd.get('cc_fee_rate') ?? '')], - ['kitchen_pct', String(fd.get('kitchen_pct') ?? '')], - ['bar_liquor_pct', String(fd.get('bar_liquor_pct') ?? '')], + ['cc_fee_rate', String(ccFeeRate)], + ['kitchen_pct', String(kitchenPct)], + ['bar_liquor_pct', String(barLiquorPct)], ['lunch_cutoff', String(fd.get('lunch_cutoff') ?? '15:00')], ['restaurant_name', String(fd.get('restaurant_name') ?? '')], ['google_sheets_spreadsheet_id', String(fd.get('google_sheets_spreadsheet_id') ?? '')],