CRODE

Cloudflare Workers infrastructure audit

I audited my own Worker and found fourteen things

Litescope Cloud runs on Workers, D1 and Workers Assets. I sell audits of services like it, so I ran one on mine and published the whole thing, including the parts that are embarrassing.

500,513before
→
564after

Virtual machine steps for one dashboard load, 25 databases, 2,000 events each. The fix is two lines of SQL.

The dashboard query read the entire table

Both dashboard queries find the newest row per database with a correlated subquery ordered by id DESC LIMIT 1. The indexes were keyed on (database_id, created_at). Neither can satisfy an ordering by id, so SQLite reads every matching row and sorts it to take one.

|--SEARCH d USING INDEX idx_databases_org (org_id=?)
|--SEARCH h USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN
|--CORRELATED SCALAR SUBQUERY 1
|  |--SEARCH health_events USING COVERING INDEX idx_health_db_time (database_id=?)
|  `--USE TEMP B-TREE FOR ORDER BY
`--USE TEMP B-TREE FOR ORDER BY

USE TEMP B-TREE FOR ORDER BY inside a correlated subquery is the tell. It runs once per database in the fleet.

The cost grows with retained history rather than with fleet size, which is the part that makes it dangerous. It looks fine on the day you ship it. Measured on a seeded copy of the schema:

Events per databaseRows in tableSteps beforeSteps after
1002,50025,513564
50012,500125,513564
2,00050,000500,513564

Exactly linear, about 250 × events_per_database + 513. Sort operations per load went from 25 to zero. A fleet pushing hourly for a year reaches 8,760 events per database, which puts one dashboard load at roughly 219,000 rows read.

The fix is an index that matches the ordering the query actually uses:

CREATE INDEX idx_health_db_id ON health_events(database_id, id DESC);
CREATE INDEX idx_snap_db_id   ON schema_snapshots(database_id, id DESC);

After that the subquery is a covering index seek, there is no temp b-tree, and the number stops moving when the table grows.

The sign-in code had no rate limit

Sign-in is a six digit code with a ten minute lifetime. There is no attempt counter, no lockout, no delay, and no Rate Limiting binding anywhere in the Worker. The code space is one million.

Requesting a code returns 200 whether or not the address exists, which is correct for enumeration. It also means someone who already knows a customer's address can mint a fresh code for it on demand and then guess against it, bounded only by how fast they can open connections.

The code is bound to the email address when it is hashed, so guesses cannot be sprayed across accounts. That narrows the attack to one target at a time. It does not stop it.

This one is not in the patch set. It needs a binding added to the Worker config and a schema change, and that belongs in a review rather than in a drive-by diff.

All fourteen

2 critical3 high5 medium4 low

#FindingSeverityEffort
F-01Dashboard reads all retained events per loadCritical30 min
F-02Sign-in code has no rate limitCritical4 h
F-03Failed webhook is permanently swallowedHigh1 h
F-04Ingestion blocks on a non-blocking writeHigh20 min
F-05No retention policy on any tableHigh3 h
F-06Email casing not normalisedMedium1 h
F-07login_tokens has no index on emailMedium10 min
F-08Duplicate index on api_keys.key_hashMedium10 min
F-09No security headers on the dashboardMedium1 h
F-10Failed welcome email loses the API keyMedium2 h
F-11compatibility_date 17 months oldLow15 min
F-12Logout accepts any HTTP methodLow5 min
F-13Login code has modulo biasLow10 min
F-14Webhook JSON.parse unguardedLow10 min

F-13 is in the list on purpose

The login code is generated with buf[0] % 1000000 over a uint32. Codes 000000 to 967295 occur 4,295 times per 232 and the rest 4,294 times, a skew of about 0.023 percent. It is not exploitable. It is in the report so the next person to read that line does not have to work it out, and because a report that only contains alarming findings is a sales document rather than an audit.

What was already right

Secrets are handled correctly, the webhook signature is verified with a constant-time comparison, tokens are stored hashed, and a cancelled subscription loses dashboard access even on an unexpired session. Those are the things teams usually get wrong.

No dollar figure

Billing data was not part of this scope, so the report gives the row-count arithmetic and stops there. For a single tenant at this size the honest framing is latency and scaling headroom rather than this month's invoice. It becomes a billing line as tenants multiply, because the number is per org and per page load.

An audit that invents a savings figure to justify its own price is worth less than one that says what it does not know.

Three patches, applied and checked

Every audit I deliver includes three fixes applied to a copy of the repository and verified, as diffs you can merge. Not a slide deck, and not a list of things someone else should go and do.

#PatchResolvesVerified by
P-01Index the latest-row lookups, drop the duplicate indexF-01, F-08Migration applied to a clean database, both query plans re-run
P-02Move the last-used write into waitUntilF-04tsc --noEmit
P-03Release the idempotency claim when processing failsF-03tsc --noEmit

P-02 is my favourite kind of finding. The code had a comment on it that read best-effort last_used bump, don't block the request on it, directly above a line that awaited the write. Someone knew. The comment outlived the intention.

Open for three

I will do this for your Worker, free, for the next three teams

If you run Cloudflare Workers with D1 in production, I will audit it the same way and hand you the report and the patches. No charge and no obligation. In exchange I would like to publish the findings with your name and anything sensitive removed, and if you would rather stay anonymous that is fine too.

After those three it is a fixed price for three days of work.

dl_litescopecrode.net

Useful to include: roughly how many Workers, whether you use D1, and the thing that is currently bothering you.