RoomieTab · Security
Security Report
Summary
Total
12
Critical
1
High
3
Medium
4
Low
2
Info
2
2 findings auto-fixed
Findings
Next.js Multiple CVEs — DoS, Cache Poisoning, SSRF, Info Disclosure
The installed Next.js version 15.1.x is affected by 6 known CVEs: (1) GHSA-67rr-84xm-4c7r — DoS via cache poisoning (CVSS 7.5, High); (2) GHSA-7m27-7ghc-44w9 — DoS via Server Actions (CVSS 5.3); (3) GHSA-g5qg-72qw-gw5v — Cache Key Confusion for Image Optimization (CVSS 6.2); (4) GHSA-xv57-4mr9-wg8v — Content Injection for Image Optimization (CVSS 4.3); (5) GHSA-4342-x723-ch2f — Middleware Redirect SSRF (CVSS 6.5); (6) GHSA-3h52-269p-cp9r — Dev server information exposure. The overall npm audit severity is reported as critical.
Location: package.json — next@15.1.x
Recommendation
Upgrade Next.js to 15.4.5 or later (the latest stable release as of 2026) to remediate all listed CVEs. Run: npm install next@latest
Fix Applied
Manual upgrade required: npm install next@latest
Missing Security Headers (X-Content-Type-Options, X-Frame-Options, HSTS, CSP, Referrer-Policy, Permissions-Policy)
The original next.config.ts had no HTTP security headers configured. All of the OWASP-recommended headers were absent: X-Content-Type-Options (MIME sniffing protection), X-Frame-Options (clickjacking), Strict-Transport-Security (HTTPS enforcement), Content-Security-Policy (XSS/injection defence), Referrer-Policy, and Permissions-Policy. This exposes users to clickjacking, MIME-confusion attacks, and weakens XSS defences.
Location: next.config.ts
Recommendation
Configure security headers via the Next.js headers() async function in next.config.ts. All seven required headers have been added.
Fix Applied
Added a complete securityHeaders array to next.config.ts via the headers() hook covering: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 1; mode=block, Strict-Transport-Security: max-age=31536000; includeSubDomains; preload, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=(), and a Content-Security-Policy restricting script/style/img/connect/frame sources.
Stored XSS in HTML PDF Export — Unescaped User-Controlled Data Interpolated Into HTML
The /api/export/pdf route (src/app/api/export/pdf/route.ts) generates an HTML document by directly interpolating database values (expense.description, expense.category, expense.expense_date, member display_name, household name, formatCents output) into a template literal without HTML-entity escaping. Any of these fields can contain attacker-controlled content (e.g., a description like '<script>fetch(...)' saved earlier). When a victim downloads and opens the file in a browser, arbitrary JavaScript executes in their browser context. Note: the Content-Disposition: attachment header mitigates direct inline rendering, but users routinely open attachments and many browsers render HTML files opened locally.
Location: src/app/api/export/pdf/route.ts — lines 96–175 (original)
Recommendation
HTML-escape all user-supplied values before interpolating them into the template. Replace special characters (&, <, >, ", ') with their HTML entity equivalents.
Fix Applied
Added an esc() helper function that replaces &, <, >, ", ' with HTML entities (&, <, >, ", ') and applied it to every user-controlled value: expense.description, expense.category, expense.expense_date, member display names, household name, monthLabel, formatCents output, and the date string.
Cron Endpoint Has No Authentication When CRON_SECRET Is Unset
In src/app/api/recurring/generate/route.ts the CRON_SECRET check is conditional: `if (cronSecret && authHeader !== ...)`. If the CRON_SECRET environment variable is not set (e.g., in development or a misconfigured deployment), the check is entirely bypassed and any unauthenticated caller can trigger bulk expense generation across ALL active recurring templates for ALL households, potentially creating massive fraudulent expense records. The endpoint uses createClient() with the user session (not admin client), but because cronSecret is falsy the route proceeds without any auth.
Location: src/app/api/recurring/generate/route.ts — lines 14–18
Recommendation
Make CRON_SECRET mandatory: change the guard to `if (!cronSecret || authHeader !== 'Bearer ' + cronSecret) { return 401 }`. Also verify CRON_SECRET is set in all deployment environments. Add CRON_SECRET to the .env.local.example file.
Fix Applied
Manual fix required: replace the conditional secret check with a hard-fail check that rejects the request whenever CRON_SECRET is absent or does not match.
No Rate Limiting on Auth-Sensitive Endpoints
None of the API routes implement rate limiting. The most sensitive endpoints for abuse are: POST /api/households/join-by-code (invite-code brute-force — 6-character alphanumeric codes can be brute-forced), POST /api/households/[id]/join (same), POST /api/receipts/upload (storage exhaustion), and POST /api/export/pdf (CPU/memory DoS). No middleware applies request throttling.
Location: src/app/api/households/join-by-code/route.ts, src/app/api/receipts/upload/route.ts, src/app/api/export/pdf/route.ts
Recommendation
Implement rate limiting using a middleware solution such as the Vercel KV-backed rate limiter, Upstash Ratelimit, or the next-rate-limit package. Particularly protect the invite-code lookup endpoint (e.g., max 10 requests/minute per IP).
Missing Zod / Schema Validation on Multiple API Routes — No Input Sanitization
The project defines Zod schemas in src/lib/validations.ts, but none of the API route handlers import or apply them. Routes parse raw request.json() and perform only basic truthy checks. This means: (1) string fields like description, category, splitType are passed directly to Supabase without type or length validation; (2) amountCents accepts any integer including extremely large values (no maximum); (3) category and splitType accept any string — while the DB CHECK constraint catches invalid enums, the error message leaks internal schema details; (4) the month parameter is passed to parseISO without validation — a malformed date string causes an unhandled exception that falls through to a 500 with an error message.
Location: src/app/api/expenses/route.ts, src/app/api/recurring-templates/route.ts, src/app/api/settlements/calculate/route.ts, src/app/api/settlements/archive/route.ts
Recommendation
Apply the existing Zod schemas (or new ones) at the top of each route handler using safeParse() and return 400 with validation errors on failure. Add max-length constraints to text fields (e.g., description max 500 chars), a ceiling on amountCents (e.g., max $1,000,000), and strict enum validation for category and splitType.
Settlements Archive — No Admin-Only RLS / Route Guard for Archiving
The architecture doc states 'Only admin can archive', but the POST /api/settlements/archive route (line 29–40) only verifies that the caller is an active household member — any member (not only admins) can archive a month. The RLS policy for settlements INSERT/UPDATE also allows any active member. This is an authorization inconsistency between the spec and the implementation.
Location: src/app/api/settlements/archive/route.ts — line 29 comment says 'any member can archive'
Recommendation
Add an admin role check after the membership check in the archive route: verify `member.role === 'admin'` before proceeding. Update the settlements INSERT/UPDATE RLS policy to additionally require `role = 'admin'` if admin-only archiving is the intended design.
Open Redirect in Auth Callback — Unvalidated 'next' Parameter
The auth callback handler at src/app/auth/callback/route.ts reads `const next = searchParams.get('next') ?? '/'` and redirects to `${origin}${next}` without validating that next is a relative path within the application. An attacker can craft a magic-link with `?next=//evil.com/phishing` or `?next=/login?error=...` (for UI redirection phishing). While the origin is prepended (which prevents full off-site redirect in most cases), paths starting with `//` can still cause cross-origin redirects in some browser/Node URL parsing edge cases.
Location: src/app/auth/callback/route.ts — line 10, 48
Recommendation
Validate the next parameter: ensure it starts with '/' and does not start with '//' or contain '://' before using it. Example guard: `const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/'`
No .gitignore File Detected
No .gitignore file was found in the project root. Without a .gitignore, sensitive files such as .env.local (containing SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SUPABASE_ANON_KEY), node_modules/, and .next/ build artifacts could be accidentally committed and pushed to a public or private repository, exposing credentials.
Location: / (project root) — .gitignore absent
Recommendation
Create a .gitignore that includes at minimum: .env.local, .env*.local, node_modules/, .next/, out/, .vercel/. Use the Next.js default .gitignore from create-next-app as a baseline.
Invite Code Lookup Discloses Household Existence Without Authentication
The POST /api/households/join-by-code route requires authentication (401 if no user), so unauthenticated enumeration is blocked. However, the route returns distinct error messages: 'Invalid invite code. Household not found.' vs. 'You are already a member of this household' vs. 'You already belong to a different household'. These distinct messages enable an authenticated user to confirm whether a given invite code is valid, and to infer household membership of other users.
Location: src/app/api/households/join-by-code/route.ts — lines 31–48
Recommendation
Return a generic 'Invalid invite code' message for all failure cases that could leak household existence or membership status.
Error Messages May Leak Internal Database Details
Multiple API routes return raw Supabase/PostgREST error messages directly to clients (e.g., `{ error: error.message }` or `{ error: supabaseError.message }`). These can include table names, column names, constraint names, and PostgreSQL error codes, which aid attackers in reconnaissance.
Location: src/app/api/households/route.ts line 89, src/app/api/expenses/route.ts lines 89, 112, src/app/api/expenses/[id]/route.ts lines 86, 111, and many others
Recommendation
Wrap all Supabase errors: log them server-side (with a correlation ID) and return a generic 'An internal error occurred' message with the correlation ID to clients. Only return specific user-facing messages where safe (e.g., unique constraint violations).
Receipt Upload — File Extension Taken From Client-Supplied Filename
In src/app/api/receipts/upload/route.ts the storage filename is constructed as `${user.id}/${Date.now()}.${file.name.split('.').pop()}`. The extension is derived from the client-provided filename, not from the validated MIME type. An attacker could supply a file named malicious.html with MIME type image/jpeg (bypassing the MIME check) and store it with an .html extension in the public receipts bucket.
Location: src/app/api/receipts/upload/route.ts — line 33
Recommendation
Derive the extension from the validated MIME type map rather than the client filename: e.g., `const extMap = {'image/jpeg':'jpg','image/png':'png','image/webp':'webp','image/heic':'heic'}; const ext = extMap[file.type] ?? 'jpg'`. This ensures the stored file always has a safe extension regardless of the uploaded filename.