PingToProd

Security Report

RoomieTab

Security

Security Report

Summary

Total

12

Critical

1

High

3

Medium

4

Low

2

Info

2

2 findings auto-fixed

Findings

DEP-001criticalVulnerable Components (OWASP A06)

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

SEC-HDR-001highSecurity Misconfiguration (OWASP A05)auto-fixed

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.

XSS-001highInjection / XSS (OWASP A03)auto-fixed

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 (&amp;, &lt;, &gt;, &quot;, &#039;) 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.

AUTH-001highBroken Access Control (OWASP A01)

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.

INPUT-001mediumInsecure Design (OWASP A04)

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).

INPUT-002mediumInjection / Input Validation (OWASP A03)

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.

RLS-001mediumBroken Access Control (OWASP A01)

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-001mediumBroken Access Control (OWASP A01)

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 : '/'`

GITIGNORE-001lowSecurity Misconfiguration (OWASP A05)

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-001lowBroken Access Control (OWASP A01)

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.

INFO-001infoSecurity Misconfiguration (OWASP A05)

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).

INFO-002infoInsecure Design (OWASP A04)

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.