# Security scan candidates — 2026-06-20

Read-only audit of `apps/web/src/**`, `apps/web-do/**`, `functions/**`. Tests/build artifacts excluded.

## Summary

| Class | Count | Notes |
|-------|------:|-------|
| S4 SQL injection | **0** | `sql.raw` / dynamic SQL hits traced; none accept attacker-controlled fragments |
| S6 SSRF | **3** | All require admin to store `base_url` / channel config (insider or compromised admin) |
| SHAPE-B imported insecure default | **2** | Turnstile fail-open + non-prod E2E bypass defined in `turnstile.ts`, consumed by auth routes |
| S1 auth/session lifecycle | **2** | Preview/E2E-secret gated; real on `dev.multi.deal` when `E2E_SECRET` is set |
| S2 RBAC/authorization | 0 | — |
| S3 tenant isolation | 0 | — |
| S5 XSS | 0 | — |
| S7 webhook fail-open | 0 | Carrier manual adapters return `null` → 401; Stripe requires signature |
| S8 cross-tenant IDOR | 0 | — |
| S10 OAuth/auth-code replay | 0 | — |
| S12 race/idempotency | 0 | — |
| S13 business-logic amount bound | 0 | Refund paths call `assertRefundAmountWithinPaid` before provider refund |

**Total candidates: 7**

---

## S6 SSRF

### S6-001

- **Class:** S6
- **Location:** `apps/web/src/pages/api/admin/support/providers/[id]/test.ts:94`
- **Symbol:** `POST` handler → `fetch(url, …)`
- **Severity:** high
- **Attacker path:** Authenticated admin creates/updates an `openai-compat` LLM provider with `base_url` pointing at an attacker host or RFC1918/metadata target (`http://169.254.169.254/…`, `http://127.0.0.1:…`), then calls `POST /api/admin/support/providers/{id}/test`. The worker performs an outbound `POST` to `{base_url}/chat/completions` and embeds the decrypted provider API key in the `Authorization: Bearer` header.
- **Why vulnerable:** `base_url` is validated only as `z.url()` (any scheme/host). No outbound URL allow-list, private-IP block, or metadata guard before `fetch`.
- **Fix direction:** Centralize outbound LLM calls behind an SSRF guard (HTTPS-only allow-list of known provider hostnames, block private/reserved IPs, optional admin-only hostname suffix allow-list). Reject `base_url` values that fail the guard at create/update time and again immediately before `fetch`.

### S6-002

- **Class:** S6
- **Location:** `apps/web/src/server/ai/providers/listModels.ts:118`
- **Symbol:** `fetchProviderModels` → `fetchModelsJson`
- **Severity:** high
- **Attacker path:** Same poisoned `base_url` as S6-001. Admin triggers `POST /api/admin/support/providers/{id}/models/refresh` or `GET …/models` (cache miss). Server fetches `{base_url}/models` with the provider API key in `Authorization`.
- **Why vulnerable:** Identical missing SSRF policy on admin-supplied `baseUrl` passed into `fetch(url)`.
- **Fix direction:** Route `fetchProviderModels` through the same SSRF guard as S6-001; never call `fetch` with raw stored `baseUrl`.

### S6-003

- **Class:** S6
- **Location:** `apps/web/src/server/monitor/channels/botmaster.ts:49`
- **Symbol:** `botmaster.send` → `fetch(url, …)`
- **Severity:** medium
- **Attacker path:** Admin (or attacker with admin session) configures a monitor alert channel `kind: 'botmaster'` with `config.baseUrl` set to an internal/metadata URL. The next monitor alert delivery issues an outbound `POST` from the worker, exfiltrating the decrypted channel credential in a configurable auth header.
- **Why vulnerable:** `baseUrl` from `channel.config` is concatenated into the request URL with no host allow-list or SSRF filtering.
- **Fix direction:** Restrict monitor webhook `baseUrl` to an explicit HTTPS allow-list (e.g. known Botmaster host); apply the same private-IP/metadata block used elsewhere before `fetch`.

---

## SHAPE-B imported insecure default

### SHAPE-B-001

- **Class:** SHAPE-B
- **Definition:** `apps/web/src/server/security/turnstile.ts` — `verifyTurnstile` returns `{ outcome: 'unavailable' }` on network/HTTP/parse failure; `applyTurnstileFor` returns `null` (proceed) for both `'ok'` and `'unavailable'`.
- **Consumers:** `apps/web/src/pages/api/auth/login-email.ts:63`, `apps/web/src/pages/api/auth/register.ts` (via `applyTurnstileFor`), `apps/web/src/pages/api/auth/magic-link/send.ts:71-79` (inline `verifyTurnstile`, same fail-open on `'unavailable'`).
- **Severity:** medium
- **Attacker path:** Unauthenticated attacker targets login/register/magic-link-send while Cloudflare Turnstile `siteverify` is unreachable from the worker (network partition, timeout, or deliberate blocking). Captcha gate is skipped; attacker relies on per-IP rate limits only to brute-force credentials or spam magic links.
- **Why vulnerable:** Security-critical default is fail-open on infrastructure failure; consumers inherit that behavior by importing `applyTurnstileFor` / mirroring its `'unavailable'` handling.
- **Fix direction:** Fail closed on `'unavailable'` for credential endpoints, or require a secondary proof (stricter rate limit bucket, temporary lockout, or queued retry) when `siteverify` cannot be reached.

### SHAPE-B-002

- **Class:** SHAPE-B
- **Definition:** `apps/web/src/server/security/turnstile.ts:134-136` — when `env.ENVIRONMENT !== 'production'` and `env.E2E_SECRET` is set, matching `x-e2e-secret` header skips Turnstile entirely (`return null`).
- **Consumers:** Same auth routes as SHAPE-B-001 via `applyTurnstileFor`.
- **Severity:** high (on preview / non-production workers where `E2E_SECRET` is configured, e.g. `dev.multi.deal`)
- **Attacker path:** Attacker obtains `E2E_SECRET` (CI logs, mis-shared wrangler secret, leaked test harness). Sends `x-e2e-secret: <secret>` on `POST /api/auth/login-email`, `register`, or `magic-link/send` with any syntactically valid `turnstileToken`, bypassing captcha completely.
- **Why vulnerable:** Cross-file default explicitly disables captcha on non-prod; preview is the only deployed environment and may carry `E2E_SECRET` for Playwright.
- **Fix direction:** Gate bypass on a dedicated `E2E_MODE=true` flag separate from `ENVIRONMENT`, or disable bypass whenever the worker hostname is publicly reachable; never deploy `E2E_SECRET` to shared preview.

---

## S1 auth / session lifecycle

### S1-001

- **Class:** S1
- **Location:** `apps/web/src/pages/api/auth/firebase-verify.ts:85-120`
- **Symbol:** E2E branch in `POST` handler
- **Severity:** critical (preview with `E2E_SECRET`)
- **Attacker path:** Attacker with `E2E_SECRET` sends `POST /api/auth/firebase-verify` with header `x-e2e-secret` and body `idToken: "E2E:+972501234567"` (any `E2E:+digits` phone). Server skips Firebase JWT verification and rate limiting, finds or creates the user for that phone, and issues full session cookies (`multideal_at`, `multideal_rt`, CSRF).
- **Why vulnerable:** Phone OTP proof is replaced by knowledge of a static shared secret plus a trivial token format; enables account takeover for any phone already registered (or creation of new accounts).
- **Fix direction:** Remove E2E bypass from publicly reachable preview workers; restrict to local/CI via separate binding, or require one-time HMAC tied to test run id with short TTL.

### S1-002

- **Class:** S1
- **Location:** `apps/web/src/pages/api/test/mint-verification-token.ts:51-103`
- **Symbol:** `POST` handler → `createToken`
- **Severity:** high (preview with `E2E_SECRET`)
- **Attacker path:** Attacker with `x-e2e-secret` calls `POST /api/test/mint-verification-token` with `{ "email": "victim@example.com" }`. Response contains a valid email-verification `token` and `verifyUrl`. Attacker completes verification flow as the victim without mailbox access.
- **Why vulnerable:** Endpoint mints live verification tokens for arbitrary emails when only `E2E_SECRET` is required; exposed on same preview host as user traffic (404 stealth, not network isolation).
- **Fix direction:** Bind test routes to localhost-only worker routes, or require mTLS/VPN; never enable on `dev.multi.deal`.

---

## S4 SQL injection

No exploitable instances found.

Reviewed `sql.raw` in `feed.ts` (`buildHoursPredicate`), `deals-search` FTS (`plainto_tsquery` with bound parameters), `provision-gin.ts` (admin allow-list), `mock-provider.ts` (fixed table union), and admin ILIKE paths. User-controlled feed `hours` fields are Zod-bounded integers; time strings come from `formatMinutesAsTime`, not raw input.
