● Zion Boggan
repos/Docket/SECURITY_AUDIT.md
zionboggan.com ↗
255 lines · markdown
History for this file →
1
# Docket -  Security Audit Report
2
**Date:** 2026-06-26  
3
**Scope:** `docket` codebase + live instance `http://localhost:3000`  
4
**Architecture:** Next.js 14.2.15 (App Router) + better-sqlite3, read-only public data viewer, no auth, no writes
5
 
6
---
7
 
8
## Methodology
9
 
10
- 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`
11
- Live probes via `curl` against the running instance
12
- DB schema inspection (row counts, indexes)
13
- `npm audit --omit=dev`
14
 
15
---
16
 
17
## Confirmed Negative Findings (probed, not vulnerable)
18
 
19
| Vector | Result |
20
|---|---|
21
| SQL injection (all query params) | Clean -  every user value is a named bound parameter, zero interpolation of user input into SQL strings |
22
| DB file accessible over HTTP | 404 -  `/docket.db` and `/data/docket.db` both unreachable; DB is outside `public/` |
23
| Stack traces / SQL in error responses | `{"error":"not found"}` only -  no internal detail leaks |
24
| `dangerouslySetInnerHTML` with user data | Single use in `layout.tsx:67-71` -  hard-coded compile-time string, no user input |
25
| Path traversal in route params | `/api/bills/1/../../../etc/passwd` → 404; Next.js normalizes before routing |
26
| Open redirect | No user-controlled `redirect()` calls; all targets are hard-coded paths |
27
| SSRF | Zero `fetch()` calls with user-influenced URLs anywhere in the server code |
28
| Client bundle secrets / env exposure | `process.env.DOCKET_DB_PATH` is server-only; not referenced in client components |
29
| Source maps in static files | Only server-side `.map` files in `.next/server/` -  confirmed 404 over HTTP |
30
| `.env` / `server.log` reachable | Both return 404 -  only `favicon.svg` is in `public/` |
31
 
32
---
33
 
34
## Findings
35
 
36
### F1 -  MEDIUM: allMemberVotes query has no DB-level LIMIT
37
 
38
**File:** `lib/feed.ts:1039-1059`  
39
**Endpoint:** `GET /api/members/:id`
40
 
41
The `getMember()` function fetches every recorded vote for a member without a LIMIT clause:
42
 
43
```sql
44
SELECT v.id, v.date, v.year, v.chamber, v.question, v.result, v.yea, v.nay,
45
       v.source_url, vp.position
46
FROM vote_positions vp
47
JOIN votes v ON v.id = vp.vote_id
48
WHERE vp.member_id = :mid
49
ORDER BY v.date DESC, v.id DESC
50
-- NO LIMIT
51
```
52
 
53
**Measured impact:**  
54
- Member 1 (Cantwell): 9,895 votes → **2.96 MB response**, 0.5s on loopback  
55
- Top members by vote count: 22,653 votes → estimated **~6.8 MB response**  
56
- `vote_positions` table: **11,125,785 rows** total (indexed on `member_id`, so query is fast)
57
 
58
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.
59
 
60
**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.
61
 
62
**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`.
63
 
64
---
65
 
66
### F2 -  MEDIUM: Rate limit trivially bypassed via X-Forwarded-For spoofing
67
 
68
**File:** `middleware.ts:19-22`
69
 
70
```typescript
71
function clientIp(req: NextRequest): string {
72
  const xff = req.headers.get("x-forwarded-for");
73
  if (xff) return xff.split(",")[0].trim();  // attacker-controlled
74
  return req.headers.get("x-real-ip") ?? "unknown";
75
}
76
```
77
 
78
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.
79
 
80
**Confirmed:** `curl -H 'X-Forwarded-For: 1.2.3.4' http://localhost:3000/api/bills` → 200, bypasses limit.
81
 
82
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:
83
1. Trust only CF's `CF-Connecting-IP` header, or  
84
2. Disable the in-process limiter and rely entirely on CF's rate limiting.
85
 
86
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.
87
 
88
**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.
89
 
90
---
91
 
92
### F3 -  MEDIUM: Next.js 14.2.15 -  unpatched CVEs
93
 
94
**File:** `package.json` -  `"next": "14.2.15"`  
95
**Confirmed by:** `npm audit --omit=dev` on the live instance
96
 
97
The installed version has two vulnerabilities detected:
98
- **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).
99
- **Moderate:** Same PostCSS issue directly.
100
 
101
Version 14.2.15 predates the following patches (not all detected by the npm audit summary, but the version is in range):
102
- **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.
103
- **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.
104
- **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.
105
 
106
**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.
107
 
108
---
109
 
110
### F4 -  LOW: CSP `unsafe-inline` for `script-src` and `style-src` neutralizes XSS protection
111
 
112
**File:** `next.config.js:12-14`
113
 
114
```js
115
"script-src 'self' 'unsafe-inline'",
116
"style-src 'self' 'unsafe-inline'",
117
```
118
 
119
`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.
120
 
121
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.
122
 
123
**Current risk:** Low -  no XSS injection points found. React's JSX escaping is the real defense. The CSP is essentially decorative for XSS prevention.
124
 
125
**Fix options (pick one):**
126
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.
127
2. Replace `unsafe-inline` with a `sha256-{hash}` allowlist for the one known inline script (the display-mode snippet in `layout.tsx`).
128
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.
129
 
130
---
131
 
132
### F5 -  LOW: SourceLink renders `href` without `javascript:` URL sanitization
133
 
134
**File:** `components/source-link.tsx:22-23`
135
 
136
```tsx
137
<a href={url} target="_blank" rel="noopener noreferrer nofollow">
138
```
139
 
140
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).
141
 
142
`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.
143
 
144
**Exploit path:** Requires the ingest pipeline to write a malicious `source_url` to the DB. This is not a user-input path.
145
 
146
**Fix:** Add scheme validation before rendering:
147
```typescript
148
const safe = /^https?:\/\//i.test(url) ? url : "#";
149
<a href={safe} ...>
150
```
151
 
152
---
153
 
154
### F6 -  LOW: getFeedItems, getBills, getMembers, getAwards fetch entire tables into memory
155
 
156
**Files:** `lib/feed.ts:339-376, 675-732, 909-939, 471-492`
157
 
158
Multiple data-access functions execute unbounded `SELECT *` queries (no `LIMIT`) and load all rows into the Node.js process before JS-level filtering/pagination:
159
 
160
| Function | Query | Approx rows |
161
|---|---|---|
162
| `getFeedItems()` | All awards + votes + executive_actions | ~35k+ |
163
| `getBills()` | All bills | 16,915 |
164
| `getMembers()` | All members | 12,767 |
165
| `getAwards()` | All awards | unknown |
166
| `getVotes()` | All votes | 35,569 |
167
 
168
Current data volumes are manageable. As the ingest grows (esp. USASpending awards which can be millions of records), these will become memory-exhausting.
169
 
170
**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.
171
 
172
---
173
 
174
### F7 -  INFO: HSTS preload with 2-year max-age
175
 
176
**File:** `next.config.js:25`
177
 
178
```
179
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
180
```
181
 
182
`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.
183
 
184
Not a vulnerability -  noted for operational awareness.
185
 
186
---
187
 
188
### F8 -  INFO: `docket.db` and `docket.db-journal` may exist in the project directory
189
 
190
**Observed:** `docket.db` and `docket.db-journal` can appear alongside the source code during development.
191
 
192
These are not in the deployment web root and are not web-accessible. Confirm the `.db` files are not checked into version control and are not a copy of production data. SQLite database files are excluded via `.gitignore`.
193
 
194
---
195
 
196
## Probed Endpoints Summary
197
 
198
| Probe | Result |
199
|---|---|
200
| `GET /api/bills/99999999` | `{"error":"not found"}` 404 -  clean |
201
| `GET /api/search?q=' OR '1'='1` | Empty results, no SQL error -  bound param confirmed |
202
| `GET /api/bills?limit=99999&offset=-1` | 200, limit clamped to 200, offset to 0 -  parsePaging works |
203
| `GET /api/members/1` | 2.96 MB response (9,895 votes, no LIMIT) |
204
| `GET /data/docket.db` | 404 -  DB not web-accessible |
205
| `GET /_next/static/chunks/webpack-*.js.map` | 404 -  source maps not served |
206
| `GET /docket.db`, `GET /.env`, `GET /server.log` | All 404 |
207
| `GET /api/bills/1/../../../etc/passwd` | 404 -  path normalized by Next.js |
208
| `X-Forwarded-For: 1.2.3.4` to /api/bills | 200 -  rate limit bypassed (F2 confirmed) |
209
 
210
---
211
 
212
## npm audit Summary
213
 
214
```
215
2 vulnerabilities (1 moderate, 1 critical)
216
- critical: next depends on vulnerable postcss (PostCSS CSS stringify XSS -  build-time only)
217
- moderate: postcss <8.5.10 directly
218
Fix: npm audit fix --force (upgrades to next@16, breaking) or manual next patch upgrade
219
```
220
 
221
---
222
 
223
## Ranked Findings Table
224
 
225
| # | Severity | ID | Title | Exploitable Without Preconditions | Must-Fix Before Public |
226
|---|---|---|---|---|---|
227
| 1 | Medium | F1 | allMemberVotes no LIMIT -  6.8 MB per request for prolific senators | Yes | Yes |
228
| 2 | Medium | F2 | Rate limit bypassed via X-Forwarded-For spoofing | Yes (trivial) | Yes (or replace with CDN-side rate limiting) |
229
| 3 | Medium | F3 | Next.js 14.2.15 unpatched CVEs (middleware bypass N/A, RSC DoS potentially applicable) | Conditional | Recommended |
230
| 4 | Low | F4 | CSP `unsafe-inline` -  XSS protection via CSP is nil | No (no XSS found) | No |
231
| 5 | Low | F5 | SourceLink renders `href` without `javascript:` guard | Requires DB compromise | No |
232
| 6 | Low | F6 | Full-table in-memory fetches -  DoS as data grows | No (not today) | No (monitor) |
233
| 7 | Info | F7 | HSTS preload -  operational concern | N/A | N/A |
234
| 8 | Info | F8 | docket.db in project share | N/A | Verify share ACLs |
235
 
236
---
237
 
238
## Must-Fix Before Public Launch
239
 
240
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.
241
 
242
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.
243
 
244
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.
245
 
246
---
247
 
248
## Honest Verdict
249
 
250
**Safe to expose publicly behind a CDN?  
251
Yes, with two targeted code fixes (F1, F2) applied first.**
252
 
253
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.
254
 
255
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.