Cloudflare Workers infrastructure audit
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.
Virtual machine steps for one dashboard load, 25 databases, 2,000 events each. The fix is two lines of SQL.
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 database | Rows in table | Steps before | Steps after |
|---|---|---|---|
| 100 | 2,500 | 25,513 | 564 |
| 500 | 12,500 | 125,513 | 564 |
| 2,000 | 50,000 | 500,513 | 564 |
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.
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.
2 critical3 high5 medium4 low
| # | Finding | Severity | Effort |
|---|---|---|---|
| F-01 | Dashboard reads all retained events per load | Critical | 30 min |
| F-02 | Sign-in code has no rate limit | Critical | 4 h |
| F-03 | Failed webhook is permanently swallowed | High | 1 h |
| F-04 | Ingestion blocks on a non-blocking write | High | 20 min |
| F-05 | No retention policy on any table | High | 3 h |
| F-06 | Email casing not normalised | Medium | 1 h |
| F-07 | login_tokens has no index on email | Medium | 10 min |
| F-08 | Duplicate index on api_keys.key_hash | Medium | 10 min |
| F-09 | No security headers on the dashboard | Medium | 1 h |
| F-10 | Failed welcome email loses the API key | Medium | 2 h |
| F-11 | compatibility_date 17 months old | Low | 15 min |
| F-12 | Logout accepts any HTTP method | Low | 5 min |
| F-13 | Login code has modulo bias | Low | 10 min |
| F-14 | Webhook JSON.parse unguarded | Low | 10 min |
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.
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.
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.
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.
| # | Patch | Resolves | Verified by |
|---|---|---|---|
| P-01 | Index the latest-row lookups, drop the duplicate index | F-01, F-08 | Migration applied to a clean database, both query plans re-run |
| P-02 | Move the last-used write into waitUntil | F-04 | tsc --noEmit |
| P-03 | Release the idempotency claim when processing fails | F-03 | tsc --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
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.