● Zion Boggan zionboggan.com ↗

Fix register time bug: date-only items show date not bogus 20:00 ET; mobile QA crawl harness

d0e7930   Zion Boggan committed on Jun 26, 2026 (1 month ago)
CYCLOPS_REPORT.md +92 -0
@@ -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.
_ops/qa-crawl.js +74 -0
@@ -0,0 +1,74 @@
+// Docket mobile QA crawl - drives chromium at iPhone viewport across every
+// route, screenshots, and auto-detects horizontal overflow + console errors.
+const { chromium } = require("playwright");
+const fs = require("fs");
+
+const BASE = process.env.QA_BASE || "http://localhost:3000";
+const OUT = "/opt/docket/_ops/qa";
+fs.mkdirSync(OUT, { recursive: true });
+
+const routes = [
+ "/", "/register", "/votes", "/members", "/bills", "/spending",
+ "/executive", "/states", "/schedule", "/calendar", "/search",
+ "/how-we-pick", "/who-pays",
+ "/votes/6017", "/members/A000055", "/bill/2136", "/states/CA",
+];
+
+(async () => {
+ const browser = await chromium.launch();
+ const ctx = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ userAgent:
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
+ });
+ const report = [];
+ for (const r of routes) {
+ const page = await ctx.newPage();
+ const errors = [];
+ page.on("console", (m) => { if (m.type() === "error") errors.push(m.text().slice(0, 160)); });
+ page.on("pageerror", (e) => errors.push("PAGEERR " + e.message.slice(0, 160)));
+ let status = 0;
+ try {
+ const resp = await page.goto(BASE + r, { waitUntil: "networkidle", timeout: 35000 });
+ status = resp ? resp.status() : 0;
+ } catch (e) { errors.push("GOTO " + e.message.slice(0, 120)); }
+ await page.waitForTimeout(700);
+ const m = await page.evaluate(() => {
+ const vw = window.innerWidth;
+ const sw = document.documentElement.scrollWidth;
+ const over = [];
+ document.querySelectorAll("body *").forEach((el) => {
+ const rb = el.getBoundingClientRect();
+ if (rb.right > vw + 2 && rb.width > 40 && el.children.length < 8) {
+ const cls = (typeof el.className === "string" ? el.className : "").slice(0, 34);
+ over.push(el.tagName + (cls ? "." + cls : "") + " →" + Math.round(rb.right));
+ }
+ });
+ // tap-target check: interactive els < 40px tall
+ const small = [];
+ document.querySelectorAll("a,button,[role=button],summary").forEach((el) => {
+ const rb = el.getBoundingClientRect();
+ if (rb.height > 0 && rb.height < 40 && rb.width > 0) small.push((el.textContent || "").trim().slice(0, 18));
+ });
+ return { vw, sw, overflow: sw > vw + 2, offenders: [...new Set(over)].slice(0, 8), smallTargets: [...new Set(small)].slice(0, 8) };
+ });
+ const name = (r === "/" ? "home" : r.replace(/^\//, "").replace(/\//g, "_"));
+ await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: false });
+ report.push({ route: r, status, ...m, errors: errors.slice(0, 5) });
+ await page.close();
+ }
+ await browser.close();
+ fs.writeFileSync(`${OUT}/report.json`, JSON.stringify(report, null, 2));
+ console.log("==== DOCKET MOBILE QA (390px) ====");
+ for (const x of report) {
+ console.log(
+ `[${x.status}] ovf=${x.overflow ? "YES(" + x.sw + ")" : "no"} ${x.route}` +
+ (x.offenders.length ? `\n OVERFLOW: ${x.offenders.join(" | ")}` : "") +
+ (x.smallTargets.length ? `\n SMALL-TAP: ${x.smallTargets.join(", ")}` : "") +
+ (x.errors.length ? `\n ERRORS: ${x.errors.join(" ;; ")}` : "")
+ );
+ }
+})();
lib/format.ts +8 -2
@@ -33,10 +33,16 @@ export function formatDate(iso: string): string {
});
}
-/** ET wall-clock label like "14:02 ET" from an ISO string, or "" if undated. */
+/** ET wall-clock label like "14:02 ET" from an ISO string, or "" if there is
+ * no real time component. Date-only values (e.g. Federal Register documents,
+ * which are dated but not timed) return "" so callers fall back to the DATE -
+ * otherwise a date-only string parses as UTC midnight and every row would
+ * wrongly read "20:00 ET". Only emit a time when the source actually has one. */
export function formatEt(iso: string): string {
if (!iso) return "";
- const d = new Date(iso.length === 10 ? iso + "T00:00:00Z" : iso);
+ // No time component → not a timed event; let the caller show the date.
+ if (!iso.includes("T") || /T00:00(:00)?(\.0+)?Z?$/.test(iso)) return "";
+ const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return (
d.toLocaleTimeString("en-US", {