| | @@ -0,0 +1,92 @@ |
| + | # Cyclops / Flywheel Security Assessment - Docket |
| + | |
| + | **Target:** http://REDACTED-IP:3000 (pre-launch build) |
| + | **Date:** 2026-06-26 |
| + | **Scope:** Full web + `/api/*` assessment (authorized owner test) |
| + | **Method:** White-box (source at `/shared/projects/docket`) + live black-box PoC via Flywheel MCP scanner and raw HTTP. Every finding below was verified against the running target - no assertion without a request/response. |
| + | **Stack:** Next.js 14.2.28 (app router), better-sqlite3, read-only public data viewer, no auth, no writes, no uploads. |
| + | |
| + | --- |
| + | |
| + | ## 1. Findings Table |
| + | |
| + | | # | Severity | Finding | Endpoint / File | Status | |
| + | |---|----------|---------|-----------------|--------| |
| + | | 1 | **Low** | Rate-limit bypass via spoofable client-IP headers | `middleware.ts` `clientIp()` | REAL (deployment-dependent) | |
| + | | 2 | **Low** | Availability/DoS under concurrency - synchronous full-table loads on every API call, single event loop | `lib/feed.ts`, `lib/db.ts`, all `/api/*` (`force-dynamic`) | REAL (deployment-dependent) | |
| + | | 3 | **Info** | CSP allows `script-src 'unsafe-inline'` (no nonce) - weakens XSS defense-in-depth | `next.config.js` | REAL (no exploitable sink today) | |
| + | | 4 | **Info** | `q` reflected verbatim in `/api/search` JSON body | `lib/feed.ts` `globalSearch` | REAL but NOT XSS | |
| + | | 5 | **Info** | `X-XSS-Protection` header absent | `next.config.js` | Non-issue (deprecated; CSP supersedes) | |
| + | | - | - | "Reflected XSS in q" (scanner) | `/api/search` | **FALSE POSITIVE** | |
| + | | - | - | "File path in error response" (scanner) | `/api/feed/<bogus>` | **FALSE POSITIVE** | |
| + | |
| + | **Not present (verified negative):** SQL injection, IDOR/auth bypass, SSRF (params + headers), CORS misconfig, open redirect, CRLF/header injection, OS command injection, SSTI, path traversal, HTTP verb tampering, dangerous HTTP methods, debug/admin endpoints, `.env`/`.git`/DB-file/source-map exposure, stack-trace/info disclosure. |
| + | |
| + | --- |
| + | |
| + | ## 2. Real Findings - detail + PoC |
| + | |
| + | ### #1 Rate-limit bypass via spoofable client-IP headers - Low |
| + | The limiter (120 req/min/IP, fixed window) derives the client identity from request headers in this order: `CF-Connecting-IP` → `X-Real-IP` → `X-Forwarded-For` → `"unknown"`, with **no trust validation**. When the origin is reached directly (not through the CDN), an attacker rotates any of these headers to get a fresh 120-request budget per value - effectively unlimited. |
| + | |
| + | Code (`middleware.ts`): |
| + | ``` |
| + | const cf = req.headers.get("cf-connecting-ip"); if (cf) return cf.trim(); |
| + | const real = req.headers.get("x-real-ip"); if (real) return real.trim(); |
| + | const xff = req.headers.get("x-forwarded-for"); if (xff) return xff.split(",")[0].trim(); |
| + | ``` |
| + | PoC: a flat burst with a unique `X-Forwarded-For`/`X-Real-IP`/`CF-Connecting-IP` per request never hit 429, while the limiter does fire for a single un-spoofed identity (a saturating same-identity burst produced a clean `429` with `Retry-After`/`X-RateLimit-*`). Each distinct header value maps to its own bucket. |
| + | |
| + | This is **by design** for a CDN deployment (the code comments state L3/L4 protection lives at the edge). It is only a real weakness if the origin is publicly reachable without the CDN in front. |
| + | |
| + | **Fix:** Trust `CF-Connecting-IP` only when the peer socket is a known Cloudflare IP; otherwise fall back to the real socket address, not attacker-controlled headers. Firewall the origin to CDN IPs. |
| + | |
| + | ### #2 Availability under concurrency - Low |
| + | Every `/api/*` route is `export const dynamic = "force-dynamic"` (no caching) and the data layer (`getFeedItems`/`getBills`/`getMembers`/`globalSearch`) loads whole tables into memory and filters/sorts in JS via synchronous `better-sqlite3` calls, which block the single Node event loop. |
| + | |
| + | PoC: a single `/api/feed` call = `time_total 0.42s`, 25 KB. A 300-request concurrent burst (50 parallel) saturated the server: **282/300 connections failed/timed out ("000"), 18 succeeded**, and the post-burst request returned `429`. Combined with #1 (header-rotating rate-limit bypass), a distributed flood against a directly-reachable origin can degrade availability. |
| + | |
| + | **Fix:** Cache the read API at the CDN edge (these are public, slow-changing records); add `Cache-Control`/`s-maxage` to API responses; push table aggregation into SQL (`WHERE`/`LIMIT`) instead of loading full tables per request. Lock the origin behind the CDN. |
| + | |
| + | ### #3 CSP `'unsafe-inline'` scripts - Info |
| + | `script-src 'self' 'unsafe-inline'` (required by Next.js inline hydration without a nonce pipeline). No reflected/stored/DOM HTML-injection sink exists today (React auto-escapes; the only `dangerouslySetInnerHTML` is the static theme bootstrap in `app/layout.tsx`), so this is defense-in-depth only. Consider a nonce-based CSP later. |
| + | |
| + | ### #4 `q` reflected in `/api/search` - Info, NOT XSS |
| + | `/api/search?q=<script>alert(1)</script>` returns `{"q":"<script>alert(1)</script>", ...}` with `Content-Type: application/json` and `X-Content-Type-Options: nosniff`. Browsers do not execute scripts in a JSON response, and `nosniff` blocks content-type confusion. Not exploitable. The search **page** renders results through React (escaped). No action required; optionally drop the echo. |
| + | |
| + | --- |
| + | |
| + | ## 3. False Positives (scanner heuristics) |
| + | - **"Reflected XSS in q"** (`test_xss`, conf 0.8): payload reflected, but response is `application/json` + `nosniff` → no execution. See #4. |
| + | - **"File path in error response"** (`test_info_disclosure`, conf 0.7): the 404 is the standard Next.js HTML 404 page; the heuristic matched the request path text. No filesystem path, no stack trace. |
| + | |
| + | --- |
| + | |
| + | ## 4. What was tested and held (PoCs run) |
| + | - **SQLi:** `?q=' OR '1'='1`, `?state=CA' OR '1'='1`, and a time-based `from=ih' AND 1=randomblob(1e8)--` on `/api/bills/1/diff` → payloads stored/compared as literals (12ms vs 29ms baseline, no delay). All queries use named bound params (`:id`, `:from`, `:to`, `:st`, `:q`); filtering is done in JS post-fetch; `LIKE` escapes `% _`; `safeCount` whitelists table names to `[a-z_]+`. **No injection.** |
| + | - **Pagination abuse:** `limit=999999999` clamped to `MAX_LIMIT=200`; `limit=-1&offset=-5` falls back to defaults. **No DoS via limit.** |
| + | - **Path traversal / id abuse:** `/api/bills/..%2f..%2fetc%2fpasswd` → 404; oversized id → 404; ids `parseInt`-normalized before binding. |
| + | - **Static exposure:** `/docket.db`, `/opt/docket/data/docket.db`, `/.env`, `/.git/config`, `/next.config.js`, `*.js.map` → all **404**. |
| + | - **CORS:** no `Access-Control-Allow-Origin` reflection. **Open redirect / SSRF (params + headers):** no URL-typed params, no outbound fetch from user input - nothing to test. **CRLF, verb tampering, dangerous methods, debug endpoints:** none. |
| + | - **Security headers (live):** CSP, HSTS (2y, preload), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`, COOP, CORP all present; `poweredByHeader` off (no `X-Powered-By`/version leak). |
| + | - **IDOR:** N/A - no accounts, no per-user data; all data is public read-only by design. |
| + | |
| + | --- |
| + | |
| + | ## 5. Must-fix before public launch |
| + | These are **deployment** controls, not code vulnerabilities: |
| + | 1. **Put the origin behind the CDN and firewall it to CDN IPs only.** This neutralizes #1 and #2. Do not expose `:3000` publicly. |
| + | 2. **Make `CF-Connecting-IP` trust conditional** on a Cloudflare peer IP (so the header limiter isn't trivially bypassable even if the origin is ever reachable). |
| + | 3. **Cache the read API at the edge** (`s-maxage`) and/or move filtering into SQL - reduces per-request cost and removes the concurrency-saturation foothold. |
| + | |
| + | Nice-to-have (not blocking): nonce-based CSP (#3), drop the `q` echo (#4). |
| + | |
| + | --- |
| + | |
| + | ## 6. Verdict |
| + | |
| + | ### 🟢 GREEN for public launch - *behind a CDN with the origin locked to CDN IPs.* |
| + | |
| + | No critical, high, or medium code-level vulnerabilities were found. SQLi, XSS, IDOR, SSRF, traversal, CORS, redirect, CRLF, cmdi, SSTI, and info-disclosure are all absent or non-exploitable. The SQL layer is uniformly parameterized, output is React-escaped, security headers are strong, and pagination/limits are clamped. |
| + | |
| + | The only real findings (rate-limit header bypass, concurrency saturation) are **deployment-dependent** and fully mitigated by the planned CDN front + origin IP lockdown. If the origin were exposed directly to the internet without the CDN, downgrade to **NOT-GREEN** until items 1-2 in §5 are in place. |