● Zion Boggan zionboggan.com ↗

Remove internal audit and performance notes from public surface

a886cec   Zion Boggan committed on Jun 27, 2026 (1 month ago)
CYCLOPS_REPORT.md +0 -92
@@ -1,92 +0,0 @@
-# Cyclops / Flywheel Security Assessment - Docket
-
-**Target:** http://localhost: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.
LATENCY_NOTES.md +0 -172
@@ -1,172 +0,0 @@
-# Docket - Navigation Latency Audit & Fix Notes
-
-Session: 2026-06-27. All timings local (`curl -w "%{time_total}"` against
-`http://localhost:3000` on the server). Cloudflare Tunnel overhead is on top of these.
-
----
-
-## Root Causes (investigated)
-
-### 1. No Cache-Control on any API route (CONFIRMED, FIXED)
-
-Every `/api/*` GET response had zero `Cache-Control` header before this session.
-Cloudflare's default for headerless JSON responses is "bypass cache" - meaning
-every client-side API call (register-hero 15-second poll, MembersBrowser
-"show historical" paginated fetches, MemberVoteYear lazy year panels) made a
-full round-trip over the CF Tunnel to the the server origin.
-
-Fix: added `Cache-Control: public, s-maxage=300, stale-while-revalidate=600`
-to all `/api/*` routes via `next.config.js` headers(). Routes that already set
-their own Cache-Control header in the response body (members, member-votes)
-override this cleanly with the same value.
-
-### 2. `force-dynamic` still on pure static API routes
-
-`/api/categories` and `/api/states` read no search params - they returned
-the same data every time - but carried `force-dynamic`, preventing Next.js
-server-side ISR. Changed to `export const revalidate = 300` so the Node
-server caches the response object for 5 min in addition to CF edge caching.
-
-### 3. No `loading.tsx` on any heavy page (CONFIRMED, FIXED)
-
-Without `loading.tsx`, clicking a nav link caused a noticeable blank/stall
-while the RSC payload loaded over the tunnel. The header stayed in place but
-the content area showed nothing.
-
-Fix: added `loading.tsx` skeletons for the six heaviest routes - register,
-votes, bills, spending, members, calendar. They render immediately on
-client-side navigation while the RSC payload is in-flight.
-
-### 4. Pages already on ISR - not a root cause here
-
-All pages (`/register`, `/votes`, `/bills`, etc.) already carried
-`export const revalidate = 300` and return `Cache-Control: s-maxage=300,
-stale-while-revalidate`. This was fixed in the previous perf session.
-The `x-nextjs-cache: STALE/HIT` headers confirm ISR is working correctly.
-
-### 5. Plain `<a href>` links - not present in nav
-
-All internal navigation uses `<Link>` from `next/link`. No regressions here;
-Next.js client-side transitions + prefetch are already wired correctly.
-
-### 6. Font loading - already correct
-
-Four fonts (Archivo, JetBrains Mono, Fraunces, Atkinson Hyperlegible) all
-loaded with `display: "swap"` via `next/font/google`. No blocking font fetch.
-
-### 7. Page payload sizes (gzip) - acceptable after compression
-
-Raw HTML sizes appear large but gzip brings them to reasonable transfer sizes:
-- `/register`: 1.45 MB raw → 92 KB gzip
-- `/votes`: 958 KB raw → 96 KB gzip
-- `/spending`: 2.85 MB raw → 206 KB gzip (320 awards + fiscal cards + apportionments)
-
----
-
-## Files Changed
-
-| File | Change |
-|------|--------|
-| `next.config.js` | Added `Cache-Control: public, s-maxage=300, stale-while-revalidate=600` header rule for `/api/:path*` |
-| `app/api/categories/route.ts` | `force-dynamic` → `revalidate = 300` |
-| `app/api/states/route.ts` | `force-dynamic` → `revalidate = 300` |
-| `app/api/feed/route.ts` | Removed duplicate handler-level Cache-Control (config default now applies) |
-| `app/api/members/route.ts` | Explicit `Cache-Control` in response (same as config default; documents intent) |
-| `app/api/members/[id]/votes/route.ts` | Explicit `Cache-Control` in response |
-| `app/register/loading.tsx` | New - hero + table skeleton |
-| `app/votes/loading.tsx` | New - filter bar + table skeleton |
-| `app/bills/loading.tsx` | New - filter bar + table skeleton |
-| `app/spending/loading.tsx` | New - fiscal cards + table skeleton |
-| `app/members/loading.tsx` | New - member card grid skeleton |
-| `app/calendar/loading.tsx` | New - calendar grid skeleton |
-
-QA after rebuild: **CLEAN** - 17 routes, no overflow, no hydration errors.
-
----
-
-## Before/After Timings (local, the server localhost)
-
-These are origin times (no Cloudflare). The "win" from Cloudflare edge caching
-shows up for end-users, not on localhost.
-
-| Route | Before (this session) | After | Note |
-|-------|-----------------------|-------|------|
-| `/register` | 0.19s | 0.19s | ISR warm; unchanged |
-| `/votes` | 0.31s | 0.31s | ISR warm; unchanged |
-| `/bills` | 0.25s | 0.25s | ISR warm; unchanged |
-| `/members` | 0.11s | 0.11s | ISR warm; unchanged |
-| `/calendar` | 0.31s | 0.06s | Second-hit ISR |
-| `/spending` | 0.66s | 0.74s | 320 awards still full-table; within reason |
-| `/api/votes` | - (no CC) | CC: s-maxage=300 | CF now caches |
-| `/api/feed` | - (no CC) | CC: s-maxage=300 | CF now caches |
-| `/api/members` | - (no CC) | CC: s-maxage=300 | CF now caches |
-| `/api/categories` | - (force-dynamic, no CC) | CC: s-maxage=300 (ISR) | CF + Node cache |
-
----
-
-## Cloudflare Cache Rule Required (Overseer action)
-
-Cloudflare by default **bypasses cache for HTML responses** even when the
-origin sends `Cache-Control: s-maxage=300`. Pages in this app return ISR HTML
-with `s-maxage=300, stale-while-revalidate` - but CF won't cache HTML without
-an explicit cache rule.
-
-Create this rule in the Cloudflare dashboard (or via CF API) for the `usdocket.org` zone:
-
-**Rule:** Cache Everything - match `usdocket.org/*`
-- Cache status: `Cache Everything`
-- Edge TTL: `Respect existing headers` (honors the `s-maxage=300` from origin)
-- Browser TTL: `Respect existing headers`
-
-This causes the CF edge to cache the full HTML responses for 5 minutes and
-serve repeat requests without hitting the tunnel. Combined with the ISR
-s-maxage already set, navigations from new users to already-cached pages will
-return from CF's edge POP in ~5ms instead of traversing the tunnel.
-
-For API routes: no separate rule needed. JSON responses with
-`Cache-Control: public, s-maxage=300, stale-while-revalidate=600` will be
-cached by CF automatically once "Cache Everything" is active (CF's default
-"Cache Only User Content" rule skips JSON by content-type, but "Cache
-Everything" overrides that).
-
-**CF API call to create the rule (requires CF Zone ID + API token):**
-
-```bash
-# Zone ID and API token are in /shared/projects/.env.secrets
-ZONE_ID="<cf_zone_id>"
-CF_TOKEN="<cf_api_token>"
-
-curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets" \
- -H "Authorization: Bearer $CF_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "name": "Cache Everything - usdocket.org",
- "kind": "zone",
- "phase": "http_request_cache_settings",
- "rules": [
- {
- "action": "set_cache_settings",
- "action_parameters": {
- "cache": true,
- "edge_ttl": {
- "mode": "respect_origin_ttl"
- },
- "browser_ttl": {
- "mode": "respect_origin"
- }
- },
- "expression": "true",
- "description": "Cache all public Docket content respecting origin TTL"
- }
- ]
- }'
-```
-
----
-
-## Summary for the Overseer
-
-Code changes are deployed on the server at `/opt/docket`. QA passes.
-The single remaining action for the overseer is the Cloudflare cache rule above,
-which unlocks edge caching of HTML pages and makes the existing `s-maxage=300`
-headers actually take effect for end-users.
PERF2_NOTES.md +0 -128
@@ -1,128 +0,0 @@
-# Docket - Performance Pass 2: Edge-Cacheable Filter Pages
-
-Session 2026-06-27. Performance-only; no visual/design changes.
-
-## Problem
-
-The interactive list pages (`/votes`, `/bills`, `/members`, `/spending`,
-`/executive`, `/search`) read URL `searchParams` inside their **server**
-components. In the Next.js App Router, touching `searchParams` opts the route
-into **dynamic rendering**, so Next emits:
-
-```
-Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
-```
-
-Cloudflare honors that and returns `cf-cache-status: BYPASS`. Every visit then
-crossed the home Cloudflare Tunnel to the the server origin (~0.5-0.8s) instead of
-serving from the CF edge (~50-150ms). The `/api/*` routes were already
-cacheable (`public, s-maxage=300`).
-
-## Fix - static shell + client-side filtering
-
-Each filter page was split into:
-
-1. A **static server shell** (`export const revalidate = 300`, no
- `searchParams` access) that renders the page header + cite block.
-2. A **client browser component** (`"use client"`) that reads the URL with
- `useSearchParams()`, fetches the matching CF-cached `/api/*` route, and
- renders the filtered/sorted results. A loading skeleton (`animate-pulse`)
- shows during the fetch.
-
-Because the shell no longer reads `searchParams`, Next renders it statically
-and CF caches the HTML. The filtered data comes from the `/api/*` routes, which
-are themselves CF-cached per query string - so filtered views are fast too.
-
-### Pages converted to static-shell + client-filter
-
-| Page | Client component | API it calls |
-|---|---|---|
-| `/votes` | `components/votes-browser.tsx` | `/api/votes?chamber=&q=&result=&sort=` |
-| `/bills` | `components/bills-browser.tsx` | `/api/bills?policyArea=&q=&congress=&sort=` |
-| `/spending` (awards section only) | `components/spending-awards-browser.tsx` | `/api/spending/awards?q=&sort=` |
-| `/search` | `components/search-browser.tsx` | `/api/search?q=` (type/sort applied client-side) |
-
-`/members` and `/executive` never read `searchParams` (they already filter
-client-side in `members-browser`/`executive-browser`), so they only needed the
-cache header (below). The `/spending` fiscal-totals and apportionments sections
-stay server-rendered (no `searchParams`).
-
-### API routes extended to accept the page filters
-
-- `app/api/votes/route.ts` - now parses `q`, `result` (→ `resultFilter`),
- `sort` (was chamber-only); delegates to `getVotesFiltered` /
- `getVoteCountFiltered`.
-- `app/api/bills/route.ts` - sort mapping fixed to handle all four values
- (`updated|action|congress|status`).
-- `app/api/spending/awards/route.ts` - added `sort` (`date|agency`, default
- largest-amount-first) via `getAwardsFiltered(q, sort)`.
-
-All keep their existing `public, s-maxage=300, stale-while-revalidate=600`
-header (from `next.config.js`).
-
-### next.config.js cache rule
-
-Added a page-route `Cache-Control` rule so CF reliably caches the HTML shells
-and the ISR detail pages, even if Next would otherwise emit a dynamic header:
-
-```js
-{
- source: "/(votes|bills|bill|members|spending|executive|search|states)(.*)?",
- headers: [{ key: "Cache-Control", value: "public, s-maxage=300, stale-while-revalidate=600" }],
-}
-```
-
-Note the **singular `bill`**: detail routes are `/bill/[id]` (singular) while
-`/votes/[id]`, `/members/[id]`, `/states/[code]` are plural. `bill` was
-initially missing, so `/bill/[id]` alone fell through to Next's dynamic
-`no-store` default and CF bypassed it. Adding `bill` fixed it.
-
-## Verification
-
-Each route hit twice through `https://usdocket.org`; 2nd-hit result reported.
-
-### Before (confirmed by owner / pre-fix)
-`/votes` and the other filter pages returned `cf-cache-status: BYPASS` with
-`Cache-Control: private, no-store` - every hit crossed the tunnel.
-
-### After
-
-| Route | cf-cache-status (2nd hit) | time_total |
-|---|---|---|
-| `/votes` | HIT | ~0.14s |
-| `/bills` | HIT | ~0.13s |
-| `/members` | HIT | ~0.25s |
-| `/spending` | HIT | ~0.20s |
-| `/executive` | HIT | ~0.29s |
-| `/search` | HIT | ~0.14s |
-| `/votes?chamber=house` | HIT | ~0.13s |
-| `/votes?chamber=senate` | HIT | - |
-| `/bills?policyArea=Taxation` | HIT | ~0.13s |
-| `/bills?sort=congress` | HIT | - |
-| `/spending?sort=date` | HIT | - |
-| `/votes/[id]` (detail) | HIT | ~0.14s |
-| `/bill/[id]` (detail) | HIT | ~0.16s |
-
-All routes serve `Cache-Control: public, s-maxage=300, stale-while-revalidate=600`.
-Filtered query-string variants each become their own CF cache key and are HIT
-on repeat. No route emits `private`/`no-store` anymore.
-
-### Filter correctness (sampled against the live API)
-
-- `?chamber=senate` → all returned votes are SENATE (total 12,916).
-- `?result=passed` → all rows have yea > nay (414>2, 52>45, 50>44).
-- bills `?sort=congress` → 119th Congress rows first.
-- awards `?sort=agency` → alphabetical by agency (Agency for Intl Development,
- Dept of Agriculture, ...).
-
-## Build / QA
-
-`bash /shared/projects/docket/_ops/rebuild-qa.sh` →
-**QA CLEAN - no overflow, no hydration errors across 17 routes.**
-
-## Net effect
-
-Repeat navigations to every list and detail page now serve from the Cloudflare
-edge (`HIT`, ~130-290ms) instead of crossing the tunnel to origin. The earlier
-the page is warmed, the more consistently sub-150ms it is. Rate-limit
-middleware (`/api/:path*` only) and the security headers are unchanged.