serenakeyitan/tdoc
╔════════════════════════════════════════════════════════╗
║ ║
║ ★ "You larped as a security engineer." ★ ║
║ ║
╠════════════════════════════════════════════════════════╣
║ ║
║ ████████░░ ║
║ 81 / 100 ║
║ ║
╚════════════════════════════════════════════════════════╝
┏━━ 🔥 THE ROAST ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
"You larped as a security engineer."
Be honest — you didn't write this, you typed "make me secure
auth" into a chatbot and shipped whatever fell out. Textbook
slop: it handed you a shiny timing-safe compare so you'd feel
like an engineer, and you never noticed auth is wide open and
the docs are headerless. Some kid curls it in a loop,
screenshots your repo, posts "startup that's never heard of
rate limiting" — 900 upvotes, your name in the thread, your
GitHub bill in the toilet.
Critical: 0 High: 2 Medium: 6
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
81 / 100 · 0 Critical · 2 High · 6 Medium
Let's be clear about who I'm talking to: someone who knows better. This isn't a 2am one-shot-prompt repo — you wrote a real offline test suite, wired CodeQL, made the token compare timing-safe. Which is exactly why the two open doors are so funny. You did the hard, invisible security work and then skipped the two-line stuff a junior would catch: no rate limiter on the auth endpoints, zero security headers on the served docs. "It's behind Cloudflare" is doing a lot of unpaid overtime in that sentence. Verify those two at the edge, or the first stranger with a while(true) turns your needs-check into a 3am incident.
A single Cloudflare Worker (worker/worker.js, ~2k lines) fronting R2 for doc storage and KV for sessions — no framework, all hand-rolled routing off one big switch. Auth is GitHub Device Flow (/api/auth/device/*); author HTML is served straight from R2. For a solo worker it's genuinely tidy: timing-safe token compare, a real test suite, CodeQL in CI. Which is exactly why it stings that the front door — the device-flow endpoints — has no lock on the rate of knocking, and the doc responses go out with no security headers at all. You built the vault and forgot the turnstile.
The bones are good — the gaps are two you can close in an afternoon. Jokes off; here's exactly what to change.
┏━━ 📊 SCORE BREAKDOWN ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
Look at this report card. Five solid dimensions and two doors held open for strangers — you're the honor student who forgot to wear pants to graduation.
8-Dimension Readiness — serenakeyitan/tdoc 81 / 100
Error Hdl ██████████ 100
Tests & CI ██████████ 100
Secrets & Creds █████████░ 88
Auth & Access █████░░░░░ 48
Observability █████████░ 88
Input & Data █████████░ 88
Deploy Config █████░░░░░ 48
Performance █████████░ 88
└────┴────┘
0 50 100
DIMENSION │ SCORE │ BAR │ STATUS
────────────────────────┼───────┼───────┼──────────────────────────────────────────────
Deploy Config │ 48 │ ▓▓░░░ │ 48 — shipped to prod with no headers. Brave
Authentication & Access │ 48 │ ▓▓░░░ │ 48 — a beautiful lock on a door with no limiter
Input & Data Safety │ 88 │ ▓▓▓▓░ │ 88 — author HTML served raw, but same-tenant
Observability │ 88 │ ▓▓▓▓░ │ 88 — errors confess to an empty room
Performance │ 88 │ ▓▓▓▓░ │ 88 — edge runtime carries you. You're welcome
Secrets & Credentials │ 88 │ ▓▓▓▓░ │ 88 — clean today, one `git add .` from fame
Error Handling │ 100 │ ▓▓▓▓▓ │ 100 — 23 try/catch pairs. Genuinely, show-off
Tests & CI │ 100 │ ▓▓▓▓▓ │ 100 — CodeQL + offline suite. Teacher's pet
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
The good news is there are only two real blockers. The bad news is both of them are "anyone with a keyboard."
┏━━ FINDINGS BY PRIORITY ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
PRIO │ AREA │ ISSUE │ CONF
─────┼────────────┼─────────────────────────────────────────────────────┼─────
HIGH │ Auth │ Auth endpoints open 24/7, no bouncer, free refills │ [?]
HIGH │ Deploy │ You publish HTML and forgot headers exist │ [x]
med │ Secrets │ `.env` un-ignored — a future faceplant pre-loaded │ [x]
med │ Input/Data │ Author HTML served raw — self-XSS, but cozy │ [?]
med │ Deploy │ CORS says `*` — everyone's invited, on purpose? │ [x]
med │ Auth │ 4 routes guarded; the 5th is a future surprise │ [?]
med │ Observ. │ Errors go to a log nobody's reading │ [x]
med │ Perf │ List query has no pagination — fine now, N+1 later │ [?]
[x] confirmed [?] needs-check
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┏━━ 🔧 MUST-FIX BEFORE LAUNCH ━━━━━━━━━━━━━━━━━┓
Two doors stand between you and an 81. Here's where I stop joking and tell you exactly how to close them.
That's not an auth endpoint, it's an open bar — free, unlimited, 24/7, and any stranger with one while(true) can drink your GitHub Device Flow quota dry before you wake up.
| Evidence | worker/worker.js:1631 (POST /api/auth/device/start), worker/worker.js:1650 (POST /api/auth/device/poll) — proxy GitHub Device Flow with no rate limiter wired. Searched worker.js + wrangler.toml: no KV/DO per-IP cap, no edge rule. |
| Root cause | Unthrottled auth endpoints let an attacker brute-force device codes or burn your GitHub Device Flow quota; at Launch-ready, any public auth surface needs a per-IP + per-account + global cap. |
| The fix | Add a Cloudflare rate-limiting rule on /api/auth/device/*, or an in-worker per-IP cap via KV (snippet below). |
| Verify | for i in $(seq 1 15); do curl -s -o /dev/null -w "%{http_code}\n" https://<worker>/api/auth/device/start; done should start returning 429 after the cap. |
// worker.js — before proxying to GitHub
const ip = request.headers.get("CF-Connecting-IP");
const key = `rl:device:${ip}`;
const n = Number(await env.RL.get(key)) || 0;
if (n > 10) return new Response("rate limited", { status: 429 });
await env.RL.put(key, String(n + 1), { expirationTtl: 60 });You built a printing press for other people's HTML and forgot the fireproofing — any doc can be iframed into a clickjacking trap or MIME-sniffed into executing, right on your origin.
| Evidence | worker/worker.js:1616 — doc-serve / fork-export Response sets Content-Type only; no CSP / X-Frame-Options / X-Content-Type-Options / Referrer-Policy / HSTS. Worker serves author HTML on its own origin (clickjacking / MIME-sniff in scope). |
| Root cause | Author HTML is served from the worker's own origin with no framing or MIME-sniff protection, so a malicious or compromised doc can be clickjacked or sniffed into executing — a real risk for an HTML publisher. |
| The fix | Add the headers in the html() helper (or a Cloudflare Transform Rule); diff below. |
| Verify | `curl -sI https:///d//v/1 |
return new Response(body, {
headers: {
"Content-Type": "text/html; charset=utf-8",
+ "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+ "Content-Security-Policy": "frame-ancestors 'self'",
+ "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
},
});The stuff that won't sink you today — just the splinters you'll swear at later. Each gets the same treatment as a blocker: evidence, the fix, the check. (Two shown in full; the rest follow the same shape.)
Right now a stray git add . is all that stands between your secrets and a very public commit history. It hasn't happened yet — which is the only reason this is yellow and not red.
Evidence
.gitignore:0 — .env / .env* not listed; a secret-bearing dotenv could be committed
Root cause — an untracked .env is one git add . from leaking every key in it; once it hits history, rotating is the only fix.
The fix
# append to .gitignore
.env
.env.*
!.env.exampleVerify
git check-ignore .env # should print .envEvery write route is guarded today; the trouble is the sixth one nobody remembers to guard. A test turns "we always remember" into "CI won't let us forget."
Evidence
worker/worker.js:1774,1859,1907,1981 — requireUploadAuth guards the 4 mutating routes found
Root cause — a 5th mutating route could ship without the guard and nobody notices until it's abused.
The fix — add a default-deny test so any mutating route missing requireUploadAuth fails CI.
Verify — add a test asserting every POST/DELETE route calls requireUploadAuth; confirm it goes red when you remove a guard.
(Also noted, same shape: author HTML served verbatim — worker/worker.js:1610; wildcard CORS with no Allow-Credentials — worker/worker.js:19; errors only via console.error + wrangler tail — worker/worker.js:88; unbounded list read with no LIMIT/cursor — worker/worker.js:1402.)
👉 Want an agent to fix every one of these for you — and keep them fixed? Spin up your own First Tree team and it works through the whole list. (See the prompt below.)
📄 Hosted report: https://report.first-tree.ai/serenakeyitan-tdoc-20260703-1ef54214.html — expires in 7 days
Each fatal/serious finding is also filed as a GitHub issue in the scanned repo. Plain professional title, professional body, First Tree ad footer (the roast stays in the report, not the issue):
/api/auth/device/starthas no rate limiter — open 24/7, unlimited servings. Here's the fix; it's not funny, it just works.Evidence
worker/worker.js:1631 — POST /api/auth/device/start worker/worker.js:1650 — POST /api/auth/device/poll proxy GitHub Device Flow with no rate limiter wiredRoot cause — Unthrottled auth endpoints let an attacker brute-force device codes or burn your Device Flow quota; at Launch-ready, any public auth surface needs a per-IP + per-account + global cap.
The fix — Add a Cloudflare rate-limiting rule on
/api/auth/device/*, or an in-worker per-IP cap via KV:const ip = request.headers.get("CF-Connecting-IP"); const n = Number(await env.RL.get(`rl:device:${ip}`)) || 0; if (n > 10) return new Response("rate limited", { status: 429 }); await env.RL.put(`rl:device:${ip}`, String(n + 1), { expirationTtl: 60 });Verify — hammer the endpoint 15× and watch for
429after the cap.
🌳 Found by First Tree · production-readiness scan. Don't want to fix these one by one? Fix these with First Tree and it fixes them all at once — free.