| | @@ -0,0 +1,256 @@ |
| + | # Docket - Security Audit Report |
| + | **Date:** 2026-06-26 |
| + | **Auditor:** Adversarial review (Claude Code, authorized) |
| + | **Scope:** `/shared/projects/docket` codebase + live instance CT247 `http://localhost:3000` |
| + | **Architecture:** Next.js 14.2.15 (App Router) + better-sqlite3, read-only public data viewer, no auth, no writes |
| + | |
| + | --- |
| + | |
| + | ## Methodology |
| + | |
| + | - Full source read: `lib/db.ts`, `lib/feed.ts`, `middleware.ts`, `next.config.js`, all `app/api/**` routes, `components/source-link.tsx`, `lib/neutrality.ts` |
| + | - Live probes via `pct exec 247 -- curl ...` |
| + | - DB schema inspection (row counts, indexes) |
| + | - `npm audit --omit=dev` |
| + | |
| + | --- |
| + | |
| + | ## Confirmed Negative Findings (probed, not vulnerable) |
| + | |
| + | | Vector | Result | |
| + | |---|---| |
| + | | SQL injection (all query params) | Clean - every user value is a named bound parameter, zero interpolation of user input into SQL strings | |
| + | | DB file accessible over HTTP | 404 - `/docket.db` and `/data/docket.db` both unreachable; DB is outside `public/` | |
| + | | Stack traces / SQL in error responses | `{"error":"not found"}` only - no internal detail leaks | |
| + | | `dangerouslySetInnerHTML` with user data | Single use in `layout.tsx:67-71` - hard-coded compile-time string, no user input | |
| + | | Path traversal in route params | `/api/bills/1/../../../etc/passwd` → 404; Next.js normalizes before routing | |
| + | | Open redirect | No user-controlled `redirect()` calls; all targets are hard-coded paths | |
| + | | SSRF | Zero `fetch()` calls with user-influenced URLs anywhere in the server code | |
| + | | Client bundle secrets / env exposure | `process.env.DOCKET_DB_PATH` is server-only; not referenced in client components | |
| + | | Source maps in static files | Only server-side `.map` files in `.next/server/` - confirmed 404 over HTTP | |
| + | | `.env` / `server.log` reachable | Both return 404 - only `favicon.svg` is in `public/` | |
| + | |
| + | --- |
| + | |
| + | ## Findings |
| + | |
| + | ### F1 - MEDIUM: allMemberVotes query has no DB-level LIMIT |
| + | |
| + | **File:** `lib/feed.ts:1039-1059` |
| + | **Endpoint:** `GET /api/members/:id` |
| + | |
| + | The `getMember()` function fetches every recorded vote for a member without a LIMIT clause: |
| + | |
| + | ```sql |
| + | SELECT v.id, v.date, v.year, v.chamber, v.question, v.result, v.yea, v.nay, |
| + | v.source_url, vp.position |
| + | FROM vote_positions vp |
| + | JOIN votes v ON v.id = vp.vote_id |
| + | WHERE vp.member_id = :mid |
| + | ORDER BY v.date DESC, v.id DESC |
| + | -- NO LIMIT |
| + | ``` |
| + | |
| + | **Measured impact:** |
| + | - Member 1 (Cantwell): 9,895 votes → **2.96 MB response**, 0.5s on loopback |
| + | - Top members by vote count: 22,653 votes → estimated **~6.8 MB response** |
| + | - `vote_positions` table: **11,125,785 rows** total (indexed on `member_id`, so query is fast) |
| + | |
| + | The data is loaded entirely into JS memory for year-grouping before serialization. The `votes` (LIMIT 40) sub-query that feeds the `votes` field on the response IS limited, but `allMemberVotes` which feeds `votesByYear` is not. |
| + | |
| + | **Exploitation:** `GET /api/members/95` (or any high-vote member) repeatedly. Each request serializes ~6MB of JSON. With rate limit bypassed (F2 below), this can exhaust Node.js heap. |
| + | |
| + | **Fix:** Add `LIMIT 2000` (or a config constant) to the `allMemberVotes` query, or paginate `votesByYear` on the API. The `votes` field is already capped at 40 - apply the same discipline to `allMemberVotes`. |
| + | |
| + | --- |
| + | |
| + | ### F2 - MEDIUM: Rate limit trivially bypassed via X-Forwarded-For spoofing |
| + | |
| + | **File:** `middleware.ts:19-22` |
| + | |
| + | ```typescript |
| + | function clientIp(req: NextRequest): string { |
| + | const xff = req.headers.get("x-forwarded-for"); |
| + | if (xff) return xff.split(",")[0].trim(); // attacker-controlled |
| + | return req.headers.get("x-real-ip") ?? "unknown"; |
| + | } |
| + | ``` |
| + | |
| + | Any client can send `X-Forwarded-For: 1.2.3.4` to key themselves to any arbitrary IP. The rate-limit bucket is then keyed on the spoofed value. |
| + | |
| + | **Confirmed:** `curl -H 'X-Forwarded-For: 1.2.3.4' http://localhost:3000/api/bills` → 200, bypasses limit. |
| + | |
| + | The middleware comment says "a reverse proxy (Caddy/CF) should sit in front in production" - that is the correct fix. However, if CF is terminating, the middleware should either: |
| + | 1. Trust only CF's `CF-Connecting-IP` header, or |
| + | 2. Disable the in-process limiter and rely entirely on CF's rate limiting. |
| + | |
| + | In the current state (no CF in front), the rate limit offers zero protection. Combined with F1, this allows sustained ~6MB responses with no throttling. |
| + | |
| + | **Fix:** When CF is the termination proxy, use `CF-Connecting-IP` instead of `X-Forwarded-For`. Add a `TRUSTED_PROXY` environment variable to gate which headers to trust. Or: remove the in-process limiter entirely and use Cloudflare Rate Limiting rules. |
| + | |
| + | --- |
| + | |
| + | ### F3 - MEDIUM: Next.js 14.2.15 - unpatched CVEs |
| + | |
| + | **File:** `package.json` - `"next": "14.2.15"` |
| + | **Confirmed by:** `npm audit --omit=dev` on the live instance |
| + | |
| + | The installed version has two vulnerabilities detected: |
| + | - **Critical (per npm):** Next.js packages a vulnerable version of `postcss` internally. The PostCSS XSS (GHSA-qx2v-qp2m-jg93) affects CSS stringification - this is a build-time dev tool risk, not a runtime risk for Docket (no user-uploaded CSS). |
| + | - **Moderate:** Same PostCSS issue directly. |
| + | |
| + | Additionally, 14.2.15 predates the following patches (not all detected by the npm audit summary, but the version is in range): |
| + | - **GHSA-f82v-jwr5-mffw** (middleware auth bypass, fixed in 14.2.25): **NOT exploitable** - Docket's middleware is rate-limiting only, no auth gate to bypass. |
| + | - **GHSA-h25m-26qc-wcjf / GHSA-q4gf-8mx6-v5v3** (RSC DoS): Potentially applicable since Docket uses server components; exact exploitability depends on whether the specific RSC deserialization path is triggered. |
| + | - **GHSA-vfv6-92ff-j949 / GHSA-wfc6-r584-vfw7** (RSC cache poisoning): Relevant if a CDN is placed in front without proper Vary/cache-key configuration. |
| + | |
| + | **Fix:** Upgrade to `next@14.2.28` (or latest 14.2.x patch). Run `npm audit` again after upgrade. A breaking change to Next.js 15 is not required; a patch-level bump closes the known issues. |
| + | |
| + | --- |
| + | |
| + | ### F4 - LOW: CSP `unsafe-inline` for `script-src` and `style-src` neutralizes XSS protection |
| + | |
| + | **File:** `next.config.js:12-14` |
| + | |
| + | ```js |
| + | "script-src 'self' 'unsafe-inline'", |
| + | "style-src 'self' 'unsafe-inline'", |
| + | ``` |
| + | |
| + | `unsafe-inline` defeats the primary XSS-blocking function of Content Security Policy for both scripts and styles. Any stored or reflected XSS (none currently found) would not be stopped by the CSP. |
| + | |
| + | The comment acknowledges this: "Next.js injects a small inline hydration bootstrap; 'unsafe-inline' kept for scripts/styles (no nonce pipeline)." This is a real constraint with Next.js 14 App Router; the fix is non-trivial. |
| + | |
| + | **Current risk:** Low - no XSS injection points found. React's JSX escaping is the real defense. The CSP is essentially decorative for XSS prevention. |
| + | |
| + | **Fix options (pick one):** |
| + | 1. Implement a [nonce-based CSP](https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy#nonces) - requires middleware nonce injection and `<Script nonce={nonce}>` usage. |
| + | 2. Replace `unsafe-inline` with a `sha256-{hash}` allowlist for the one known inline script (the display-mode snippet in `layout.tsx`). |
| + | 3. Accept as a known limitation and document it - the app has no user-submitted content, so the XSS risk is tied only to DB compromise. |
| + | |
| + | --- |
| + | |
| + | ### F5 - LOW: SourceLink renders `href` without `javascript:` URL sanitization |
| + | |
| + | **File:** `components/source-link.tsx:22-23` |
| + | |
| + | ```tsx |
| + | <a href={url} target="_blank" rel="noopener noreferrer nofollow"> |
| + | ``` |
| + | |
| + | The `url` parameter comes from DB `source_url` fields. If any `source_url` row contained `javascript:alert(1)`, clicking the link would execute JS in some browsers (React 16.9+ emits a warning but does not block `javascript:` hrefs on `<a>` tags at runtime). |
| + | |
| + | `isAllowedSourceHost()` flags non-allowlisted hosts visually and in title text, but still renders the `href`. A `javascript:` URL would pass `isAllowedSourceHost` as `false` and show a "not allowlisted" state - but the link is still clickable. |
| + | |
| + | **Exploit path:** Requires the ingest pipeline to write a malicious `source_url` to the DB. This is not a user-input path. |
| + | |
| + | **Fix:** Add scheme validation before rendering: |
| + | ```typescript |
| + | const safe = /^https?:\/\//i.test(url) ? url : "#"; |
| + | <a href={safe} ...> |
| + | ``` |
| + | |
| + | --- |
| + | |
| + | ### F6 - LOW: getFeedItems, getBills, getMembers, getAwards fetch entire tables into memory |
| + | |
| + | **Files:** `lib/feed.ts:339-376, 675-732, 909-939, 471-492` |
| + | |
| + | Multiple data-access functions execute unbounded `SELECT *` queries (no `LIMIT`) and load all rows into the Node.js process before JS-level filtering/pagination: |
| + | |
| + | | Function | Query | Approx rows | |
| + | |---|---|---| |
| + | | `getFeedItems()` | All awards + votes + executive_actions | ~35k+ | |
| + | | `getBills()` | All bills | 16,915 | |
| + | | `getMembers()` | All members | 12,767 | |
| + | | `getAwards()` | All awards | unknown | |
| + | | `getVotes()` | All votes | 35,569 | |
| + | |
| + | Current data volumes are manageable. As the ingest grows (esp. USASpending awards which can be millions of records), these will become memory-exhausting. |
| + | |
| + | **Fix:** Push filters into SQL `WHERE` clauses and add `LIMIT`/`OFFSET` at the DB layer. Most of the JS filtering logic in `getBills()` and `getMembers()` can be moved to SQL with parameterized predicates. |
| + | |
| + | --- |
| + | |
| + | ### F7 - INFO: HSTS preload with 2-year max-age |
| + | |
| + | **File:** `next.config.js:25` |
| + | |
| + | ``` |
| + | Strict-Transport-Security: max-age=63072000; includeSubDomains; preload |
| + | ``` |
| + | |
| + | `preload` submits the domain to Chrome's preload list. This is appropriate for a production HTTPS site but is effectively irreversible once submitted (removal takes months). Ensure the domain will stay HTTPS before public launch. |
| + | |
| + | Not a vulnerability - noted for operational awareness. |
| + | |
| + | --- |
| + | |
| + | ### F8 - INFO: `docket.db` and `docket.db-journal` exist in the project share at `/shared/projects/docket/` |
| + | |
| + | **Observed:** `ls /shared/projects/docket/` (CT215) shows `docket.db` and `docket.db-journal` alongside the source code. |
| + | |
| + | These are not in the CT247 `/opt/docket/public/` directory and are not web-accessible. However, the share mounts suggest these files may be accessible to other containers that mount `/shared/projects`. Confirm the `.db` files in the share are not a copy of production data - or if they are, ensure the share ACLs restrict access appropriately. |
| + | |
| + | --- |
| + | |
| + | ## Probed Endpoints Summary |
| + | |
| + | | Probe | Result | |
| + | |---|---| |
| + | | `GET /api/bills/99999999` | `{"error":"not found"}` 404 - clean | |
| + | | `GET /api/search?q=' OR '1'='1` | Empty results, no SQL error - bound param confirmed | |
| + | | `GET /api/bills?limit=99999&offset=-1` | 200, limit clamped to 200, offset to 0 - parsePaging works | |
| + | | `GET /api/members/1` | 2.96 MB response (9,895 votes, no LIMIT) | |
| + | | `GET /data/docket.db` | 404 - DB not web-accessible | |
| + | | `GET /_next/static/chunks/webpack-*.js.map` | 404 - source maps not served | |
| + | | `GET /docket.db`, `GET /.env`, `GET /server.log` | All 404 | |
| + | | `GET /api/bills/1/../../../etc/passwd` | 404 - path normalized by Next.js | |
| + | | `X-Forwarded-For: 1.2.3.4` to /api/bills | 200 - rate limit bypassed (F2 confirmed) | |
| + | |
| + | --- |
| + | |
| + | ## npm audit Summary |
| + | |
| + | ``` |
| + | 2 vulnerabilities (1 moderate, 1 critical) |
| + | - critical: next depends on vulnerable postcss (PostCSS CSS stringify XSS - build-time only) |
| + | - moderate: postcss <8.5.10 directly |
| + | Fix: npm audit fix --force (upgrades to next@16, breaking) or manual next patch upgrade |
| + | ``` |
| + | |
| + | --- |
| + | |
| + | ## Ranked Findings Table |
| + | |
| + | | # | Severity | ID | Title | Exploitable Without Preconditions | Must-Fix Before Public | |
| + | |---|---|---|---|---|---| |
| + | | 1 | Medium | F1 | allMemberVotes no LIMIT - 6.8 MB per request for prolific senators | Yes | Yes | |
| + | | 2 | Medium | F2 | Rate limit bypassed via X-Forwarded-For spoofing | Yes (trivial) | Yes (or replace with CDN-side rate limiting) | |
| + | | 3 | Medium | F3 | Next.js 14.2.15 unpatched CVEs (middleware bypass N/A, RSC DoS potentially applicable) | Conditional | Recommended | |
| + | | 4 | Low | F4 | CSP `unsafe-inline` - XSS protection via CSP is nil | No (no XSS found) | No | |
| + | | 5 | Low | F5 | SourceLink renders `href` without `javascript:` guard | Requires DB compromise | No | |
| + | | 6 | Low | F6 | Full-table in-memory fetches - DoS as data grows | No (not today) | No (monitor) | |
| + | | 7 | Info | F7 | HSTS preload - operational concern | N/A | N/A | |
| + | | 8 | Info | F8 | docket.db in project share | N/A | Verify share ACLs | |
| + | |
| + | --- |
| + | |
| + | ## Must-Fix Before Public Launch |
| + | |
| + | 1. **F1 - Add LIMIT to `allMemberVotes` query** (`lib/feed.ts:1039`): Cap at 2000 rows or paginate. One-line fix; prevents multi-MB responses on busy senators. |
| + | |
| + | 2. **F2 - Fix rate limit IP extraction**: Either (a) use `CF-Connecting-IP` when behind Cloudflare and ignore `X-Forwarded-For`, or (b) remove the in-process rate limiter entirely and rely solely on CF/Caddy rules. Keeping the current middleware creates a false sense of protection. |
| + | |
| + | 3. **F3 - Upgrade Next.js**: `npm install next@14.2.28` (or latest 14.2.x) closes all known CVEs without a breaking change. Run `npm audit` to verify. |
| + | |
| + | --- |
| + | |
| + | ## Honest Verdict |
| + | |
| + | **Safe to expose publicly behind a CDN? |
| + | Yes, with two targeted code fixes (F1, F2) applied first.** |
| + | |
| + | The SQL injection surface is well-defended (every user input is a bound parameter, confirmed across all 20 API endpoints). There is no auth to bypass, no user data to steal, no write paths, and no secret material served to clients. The error responses are clean. The DB is not web-accessible. |
| + | |
| + | The two blockers are both DoS-class: the missing LIMIT on member votes (F1) means a single request for a high-vote senator returns 6.8 MB and that request is unthrottled once rate limiting is bypassed (F2). Both are one-line to a few-line fixes. Upgrade Next.js (F3) at the same time. After those three changes, the risk profile for a read-only government transparency site is acceptable. |