I have now read every file completely. Here is the full security audit report.

---

# Security Audit Report: RateRight v2 API Routes

## Summary

I audited all 13 API route files and 4 security library files. The codebase demonstrates generally solid security practices -- CSRF protection, rate limiting, auth checks, and input sanitization are all present. However, there are several issues ranging from critical to low severity.

---

## ISSUE 1: Missing Authentication on `/api/abn-lookup/route.ts`

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/abn-lookup/route.ts`
**Line:** 218
**Severity:** Medium
**Problem:** The ABN lookup endpoint has no authentication check. Any unauthenticated user (or bot) can call this endpoint. While it does have IP-based rate limiting (10 requests per 15 minutes), the lack of auth means anyone can probe ABNs. The endpoint also accepts an optional `email` query parameter (line 231) for director verification, which could leak information about which email addresses are associated with which companies if the ASIC integration is ever completed.
**Fix:** Add a Supabase auth check (`supabase.auth.getUser()`) before processing the request. At minimum, require authentication for the `email` parameter.

---

## ISSUE 2: Missing Authentication on `/api/company-logo/route.ts`

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/company-logo/route.ts`
**Line:** 31
**Severity:** Low
**Problem:** The company logo endpoint has no authentication. Any user can fetch logos for any domain. While this is relatively benign (logos are public data), it creates an open proxy to Clearbit and Google favicon services that could be abused. The rate limit (30 requests per 15 minutes per IP) provides some mitigation.
**Fix:** Consider adding authentication or tightening the rate limit further. For a public-facing lookup, this is acceptable but should be monitored.

---

## ISSUE 3: Missing Authentication on `/api/auth/verify-email/route.ts`

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/auth/verify-email/route.ts`
**Line:** 14
**Severity:** High
**Problem:** This endpoint accepts a POST with `userId` and `token` but does NOT verify the user is authenticated via Supabase. It has CSRF protection via `withCSRFProtection`, but no auth check against `supabase.auth.getUser()`. Currently the endpoint is a stub that always returns success (line 34-37), but when a real implementation is added, the lack of auth check combined with accepting an arbitrary `userId` in the body is dangerous -- an attacker could verify anyone's email by supplying their user ID.
**Fix:** Add `supabase.auth.getUser()` check and verify the requesting user matches the `userId` in the body, OR ensure the token alone is sufficient for verification (in which case the `userId` becomes redundant).

---

## ISSUE 4: Missing Authentication on `/api/ai/generate-profile/route.ts`

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/ai/generate-profile/route.ts`
**Line:** 15
**Severity:** High
**Problem:** This endpoint calls OpenAI's GPT-4o-mini API but has NO authentication check. There is no `supabase.auth.getUser()` call anywhere in the handler. It has CSRF protection via `withCSRFProtection` and IP-based rate limiting, but any unauthenticated user who can obtain a CSRF token (or bypass CSRF) can generate AI profiles, consuming OpenAI API credits.
**Fix:** Add a Supabase auth check (`supabase.auth.getUser()`) at the top of the handler, similar to what `transcribe-voice` and `parse-profile-voice` routes do.

---

## ISSUE 5: Missing CSRF Protection on `/api/messages/start/route.ts`

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/start/route.ts`
**Line:** 4
**Severity:** Medium
**Problem:** This POST endpoint creates conversations and sends messages but has no CSRF protection. It has authentication (checks `supabase.auth.getUser()`), but a malicious website could trick an authenticated user's browser into initiating unwanted conversations. Compare this to `/api/messages/[id]/send/route.ts` which does have CSRF protection.
**Fix:** Apply CSRF protection using either `withCSRFProtection` wrapper or inline `isSameOrigin` + `validateCSRFToken` checks.

---

## ISSUE 6: Missing Rate Limiting on Multiple Message Endpoints

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/route.ts` (line 4)
**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/start/route.ts` (line 4)
**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/[id]/route.ts` (line 4)
**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/[id]/send/route.ts` (line 5)
**Severity:** Medium
**Problem:** None of the four messaging endpoints have rate limiting. An authenticated attacker could:
- Spam the `start` endpoint to create thousands of conversations
- Spam the `send` endpoint to flood another user with messages
- Hit the GET endpoints repeatedly to cause database load
**Fix:** Add `checkRateLimit` calls to all messaging endpoints, keyed by user ID.

---

## ISSUE 7: Missing CSRF Protection on `/api/messages/[id]/route.ts` (GET)

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/[id]/route.ts`
**Line:** 4
**Severity:** Low
**Problem:** This is a GET endpoint, so CSRF is not typically required (GET should be idempotent). However, the endpoint writes to the database at line 76-80 by updating `last_read_at`. This side effect in a GET request is an anti-pattern and could be triggered by cross-site image tags or similar.
**Fix:** Move the `last_read_at` update to a separate POST/PATCH endpoint, or keep it as a GET but acknowledge the side effect is benign (updating read receipts).

---

## ISSUE 8: In-Memory Rate Limiting is Not Persistent Across Instances

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/lib/rate-limit.ts`
**Line:** 17
**Severity:** Medium
**Problem:** Rate limiting uses an in-memory `Map` (line 17). In a multi-instance deployment (e.g., multiple Vercel serverless functions), each instance has its own map. An attacker can bypass rate limits by getting routed to different instances. The file's own comments acknowledge this (lines 7-9).
**Fix:** For production, migrate to Redis or a shared rate limiting service (e.g., Upstash Redis, Vercel KV). This is already noted in the code comments.

---

## ISSUE 9: `getClientIP` Trusts `X-Forwarded-For` Header Without Validation

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/lib/rate-limit.ts`
**Line:** 68-73
**Severity:** Medium
**Problem:** The `getClientIP` function trusts the `X-Forwarded-For` header directly. An attacker can set a fake `X-Forwarded-For` header to spoof their IP and bypass IP-based rate limiting. Each request could use a different spoofed IP to get a fresh rate limit window.
**Fix:** When deployed behind a trusted proxy (like Vercel), use only the last hop added by the proxy, or use Vercel-specific headers like `x-vercel-forwarded-for`. Do not trust the first entry in `X-Forwarded-For` as it can be client-supplied.

---

## ISSUE 10: CSRF Cookie Missing `Secure` Flag in Non-HTTPS Production Environments

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/lib/csrf.ts`
**Line:** 25-27
**Severity:** Low
**Problem:** The `Secure` flag on the CSRF cookie is only set when both `NODE_ENV === 'production'` AND `NEXT_PUBLIC_APP_URL` starts with `https://`. If the app URL is misconfigured or missing the https prefix, the CSRF cookie will be sent over plain HTTP in production, making it interceptable. The cookie is also `HttpOnly` (line 22), which means the client-side code on line 52 of `csrf-client.ts` reads the cookie via `document.cookie` -- but `HttpOnly` cookies are NOT accessible via JavaScript. This is a contradiction.
**Fix:** Remove `HttpOnly` from the CSRF cookie (since the client must read it via JavaScript for the double-submit pattern), OR change the architecture so the client gets the token from the JSON response body only (not the cookie).

---

## ISSUE 11: CSRF `HttpOnly` Cookie Contradicts Client-Side Cookie Reading

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/lib/csrf.ts` (line 22)
**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/lib/csrf-client.ts` (line 14)
**Severity:** Critical
**Problem:** The CSRF cookie is set with `HttpOnly` (line 22 of csrf.ts: `HttpOnly; SameSite=Strict`). However, the client-side code in `csrf-client.ts` at line 14 reads the cookie via `document.cookie`. `HttpOnly` cookies are invisible to JavaScript -- `document.cookie` will never see them. This means:
1. `getCSRFCookie()` in the client will always return `null`
2. `withCSRFToken()` will never attach a CSRF header
3. ALL routes using CSRF protection will REJECT every legitimate request because the client cannot send the token

This effectively makes CSRF-protected routes unusable, OR the application has been working only because requests are somehow bypassing CSRF validation (which would mean CSRF protection is not actually working).

Looking closer at the flow: the `/api/auth/csrf` endpoint returns the token in the JSON body (line 215 of csrf.ts) AND sets it in a cookie. The client could use the JSON body token... but `withCSRFToken()` in csrf-client.ts reads from the cookie (which is HttpOnly and invisible). The `csrfFetch` function depends on `withCSRFToken()`. So the standard fetch helper cannot attach CSRF tokens.

**Fix:** Remove `HttpOnly` from the CSRF cookie so JavaScript can read it, since the double-submit cookie pattern explicitly requires the client to read and re-send the token. The `SameSite=Strict` attribute provides sufficient cookie protection.

---

## ISSUE 12: SSRF Risk in `/api/company-logo/route.ts`

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/company-logo/route.ts`
**Line:** 53, 68, 87
**Severity:** Medium
**Problem:** The `domain` query parameter is used to construct URLs for server-side fetches to Clearbit and Google. While the domain is cleaned of protocol and path (line 53), there is no validation that the domain is actually a valid domain name. Malicious input like `127.0.0.1` or `internal-service.local` could cause the server to make requests to internal network resources (SSRF). The `checkLogoExists` function (line 17-28) performs a HEAD request to these constructed URLs from the server.
**Fix:** Validate the domain against a domain name regex (must contain at least one dot, no IP addresses, no internal hostnames). Block private IP ranges and internal hostnames.

---

## ISSUE 13: No Input Sanitization on Message Content

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/[id]/send/route.ts`
**Line:** 28, 55
**Severity:** Medium
**Problem:** The message `content` is only checked for emptiness (line 30) and trimmed (line 55), but is not sanitized for XSS. If message content is rendered as HTML anywhere in the frontend, this could lead to stored XSS. The `sanitizeInput` function exists in the codebase but is not used here.
**Fix:** Apply `sanitizeInput()` to the message content before storing it. Also sanitize the `attachments` array. Additionally, ensure the frontend renders messages as plain text, not HTML.

---

## ISSUE 14: No Input Sanitization on `initialMessage` in Start Conversation

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/start/route.ts`
**Line:** 54, 60
**Severity:** Medium
**Problem:** The `initialMessage` is only trimmed but not sanitized. Same XSS risk as Issue 13.
**Fix:** Apply `sanitizeInput()` to `initialMessage` before inserting into the database.

---

## ISSUE 15: No Message Length Limit

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/[id]/send/route.ts`
**Line:** 28
**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/start/route.ts`
**Line:** 54
**Severity:** Low
**Problem:** Message content has no maximum length validation. An attacker could send extremely large messages (megabytes of text) to waste database storage and potentially cause rendering issues on the recipient's device.
**Fix:** Add a `maxLength` check (e.g., 10,000 characters) before inserting messages.

---

## ISSUE 16: `recipientId` Not Validated as UUID in `/api/messages/start/route.ts`

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/messages/start/route.ts`
**Line:** 15
**Severity:** Low
**Problem:** The `recipientId` from the request body is passed directly to a Supabase query without validating it is a proper UUID format. While Supabase/Postgres will reject invalid UUIDs, validating early provides clearer error messages and prevents unnecessary database calls.
**Fix:** Validate `recipientId` against a UUID regex before using it.

---

## ISSUE 17: Webhook Route Lacks Rate Limiting

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/app/api/webhooks/stripe/route.ts`
**Line:** 10
**Severity:** Low
**Problem:** The Stripe webhook endpoint has no rate limiting. While signature verification prevents unauthorized events, an attacker could DoS the endpoint by sending many requests with invalid signatures, each requiring a `constructEvent` call.
**Fix:** Add basic IP-based rate limiting to prevent DoS. However, be careful not to block legitimate Stripe webhook retries.

---

## ISSUE 18: `timingSafeEqual` Implementation Has Early Return on Length Mismatch

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/lib/csrf.ts`
**Line:** 129
**Severity:** Low
**Problem:** The custom `timingSafeEqual` function returns `false` immediately when the string lengths differ (line 129). This leaks the length of the token via timing. While CSRF tokens are hex strings of fixed length (64 chars from 32 bytes), and an attacker would need to measure sub-microsecond timing differences, using Node.js's built-in `crypto.timingSafeEqual` would be more robust.
**Fix:** Use `crypto.timingSafeEqual` from Node.js instead of a custom implementation. It handles the length check internally with constant-time behavior.

---

## ISSUE 19: `withCSRFProtection` Has `skipValidation` Option

**File:** `/home/ccuser/workdir/the-50-dollar-app-audit/src/lib/csrf.ts`
**Line:** 190
**Severity:** Low
**Problem:** The `withCSRFProtection` wrapper accepts a `skipValidation` option that bypasses CSRF token validation entirely. While no current routes use this option, its existence is a footgun -- a developer could accidentally or lazily pass `skipValidation: true` and completely disable CSRF protection.
**Fix:** Remove the `skipValidation` option, or rename it to something more explicit like `DANGEROUSLY_SKIP_CSRF_VALIDATION` to make the risk obvious.

---

## Positive Findings (What the Codebase Does Well)

1. **CSRF `isSameOrigin` correctly rejects missing Origin/Referer headers** (line 146-148 of csrf.ts). The function returns `false` when both headers are missing, which is the secure behavior. This prevents CSRF bypass via header stripping.

2. **Payment amount is hardcoded server-side.** In `/api/payments/create/route.ts` line 41: `const amount = 50.0;`. The amount is NOT taken from the client request body. The client sends `match_id` and `company_id`, but the amount is always $50 AUD. This is correct.

3. **Stripe webhook verifies signatures properly.** The webhook route uses `constructWebhookEvent()` which calls `stripe.webhooks.constructEvent(payload, signature, webhookSecret)` -- the standard Stripe signature verification.

4. **No raw SQL queries.** All database operations use the Supabase client with parameterized queries (`.from().select().eq()` etc.) and RPC calls. No SQL injection risk.

5. **No secrets in client-side code.** API keys (OpenAI, Stripe secret key, ABR GUID) are all accessed via `process.env` on the server side. The only public key exposed is `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`, which is designed to be public.

6. **AI routes sanitize input.** The `parse-profile-voice` and `generate-profile` routes use `sanitizeInput()` to strip control characters and escape template literals before sending user input to OpenAI.

7. **Auth-protected AI routes** (`transcribe-voice` and `parse-profile-voice`) properly check `supabase.auth.getUser()`.

8. **Message endpoints verify conversation participation.** Both the GET and send endpoints verify the authenticated user is a participant in the conversation before allowing access.

---

## Summary Table

| # | File | Line | Severity | Category |
|---|------|------|----------|----------|
| 1 | `src/app/api/abn-lookup/route.ts` | 218 | Medium | Missing Auth |
| 2 | `src/app/api/company-logo/route.ts` | 31 | Low | Missing Auth |
| 3 | `src/app/api/auth/verify-email/route.ts` | 14 | High | Missing Auth |
| 4 | `src/app/api/ai/generate-profile/route.ts` | 15 | High | Missing Auth |
| 5 | `src/app/api/messages/start/route.ts` | 4 | Medium | Missing CSRF |
| 6 | `src/app/api/messages/*.ts` (4 files) | various | Medium | Missing Rate Limit |
| 7 | `src/app/api/messages/[id]/route.ts` | 76-80 | Low | Side Effect in GET |
| 8 | `src/lib/rate-limit.ts` | 17 | Medium | In-Memory Only |
| 9 | `src/lib/rate-limit.ts` | 68-73 | Medium | IP Spoofing |
| 10 | `src/lib/csrf.ts` | 25-27 | Low | Cookie Security |
| 11 | `src/lib/csrf.ts` + `csrf-client.ts` | 22, 14 | **Critical** | HttpOnly vs JS Read |
| 12 | `src/app/api/company-logo/route.ts` | 53,68,87 | Medium | SSRF |
| 13 | `src/app/api/messages/[id]/send/route.ts` | 28,55 | Medium | Missing Sanitization |
| 14 | `src/app/api/messages/start/route.ts` | 54,60 | Medium | Missing Sanitization |
| 15 | `src/app/api/messages/[id]/send/route.ts` + `start` | 28,54 | Low | No Length Limit |
| 16 | `src/app/api/messages/start/route.ts` | 15 | Low | Missing UUID Validation |
| 17 | `src/app/api/webhooks/stripe/route.ts` | 10 | Low | Missing Rate Limit |
| 18 | `src/lib/csrf.ts` | 129 | Low | Timing Side Channel |
| 19 | `src/lib/csrf.ts` | 190 | Low | Dangerous Option |

**Critical:** 1 | **High:** 2 | **Medium:** 8 | **Low:** 8 | **Total: 19 issues**

The most urgent fix is **Issue 11** (Critical) -- the `HttpOnly` flag on the CSRF cookie makes the entire CSRF double-submit pattern non-functional. Either CSRF protection is silently broken (meaning routes are unprotected), or requests are being rejected silently causing UX failures. This must be resolved immediately.