From 702e90ffc62e41f3e5ac1eb5c75eaab710247e29 Mon Sep 17 00:00:00 2001 From: Ondrej Machek Date: Fri, 24 Jul 2026 23:42:45 +0200 Subject: [PATCH] Stop Cloudflare's WAF blocking publishes: no DROP TABLE in the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every 3.0.0 publish attempt died with `E403 Forbidden - PUT` after all five gates passed — from CI and locally, over OIDC and with an owner's OTP, with and without --provenance. npm reported only its generic "forbidden by your security policy" text and no response body. Proxying the publish through localhost showed why: the body was not an npm JSON error at all but a Cloudflare HTML page, "Attention Required! | Sorry, you have been blocked". The registry sits behind Cloudflare, `npm publish` sends README.md as *plaintext* metadata, and the managed SQL-injection ruleset matches the literal string `DROP TABLE`. The block happens in front of the registry, which is why every credential failed identically and why `await-parallel-limit` — same account, same workflow, no SQL in its README — publishes fine. Confirmed by probing the registry with unauthenticated PUTs (the WAF is in front of auth, so it needs no token and publishes nothing): `DROP TABLE` is blocked in every spelling tried, while `DELETE FROM`, `TRUNCATE` and even `UNION SELECT` pass. Only that one phrase matters. Swap the three README payloads to `DELETE FROM users; --`, which demonstrates a destructive injection just as well. The tarball is gzipped and base64'd before it goes into the request, so llms.txt, src/ and dist/ are unaffected and keep their examples. Record the whole thing in AGENTS.md with the probe recipe — this is invisible from the repo and will silently break releases again otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 33 +++++++++++++++++++++++++++++++++ README.md | 6 +++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc6dcc0..78bfe33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,39 @@ touch the release path: Node 22 ships npm 10, which has no OIDC support at all and fails with `ENEEDAUTH`. That is the error `await-parallel-limit` hit on its first release attempt. + +### Do not write `DROP TABLE` in README.md + +The registry sits behind Cloudflare, and `npm publish` sends **README.md as +plaintext** in the publish metadata (the tarball itself is gzipped and base64'd, +so its contents are invisible to the WAF — `llms.txt`, `src/`, and `dist/` are +all safe). Cloudflare's managed SQL-injection ruleset matches the literal string +`DROP TABLE` in a request body and blocks the request. + +The failure is maximally misleading: `E403 Forbidden - PUT` *after* every gate +has passed, with npm's generic "forbidden by your security policy" text and no +response body, from CI and locally alike, with OIDC and with an owner's OTP — +because the block happens in front of the registry, not inside it. It cost a +long debugging session on 3.0.0 before a proxy revealed the body was a +Cloudflare HTML error page rather than an npm JSON error. + +Verified by probing the registry with unauthenticated `PUT`s (the WAF sits in +front of auth, so this needs no token and publishes nothing): + +| `readme` payload | Result | +| --- | --- | +| `1; DROP TABLE users` | blocked | +| `1; DROP TABLE users; #` | blocked | +| `'; DROP TABLE users; --` | blocked | +| `1; DELETE FROM users; --` | passes | +| `1; TRUNCATE users; --` | passes | +| `UNION SELECT password FROM users` | passes | + +Only `DROP TABLE` matters — the `--` comment and quoting are irrelevant. Use +`DELETE FROM` in README examples; it demonstrates a destructive injection just +as well. To re-test after editing the README, PUT its contents as `readme` to +`https://registry.npmjs.org/simple-builder` with no auth: a Cloudflare HTML +body means blocked, `{"error":"Not found"}` means it will get through. - Renaming `publish.yml`, or moving the publish step into a different workflow file, breaks publishing until the trusted publisher is updated to match. - The tag must agree with `package.json` — a guard step fails the run otherwise. diff --git a/README.md b/README.md index ff003e6..aeace64 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ parameterise something, which makes accidental injection structurally impossible: ```javascript -const evil = "1; DROP TABLE users; --" +const evil = "1; DELETE FROM users; --" pg(sql`SELECT * FROM users WHERE id = ${evil}`) -// { text: 'SELECT * FROM users WHERE id = $1', values: ["1; DROP TABLE users; --"] } +// { text: 'SELECT * FROM users WHERE id = $1', values: ["1; DELETE FROM users; --"] } ``` ### Composition @@ -225,7 +225,7 @@ never tables or columns. So `simple-builder` handles them two ways: ```javascript pg(['UPDATE users SET ?', req.body]) - // throws if req.body has a key like "x=1; DROP TABLE users; --" + // throws if req.body has a key like "x=1; DELETE FROM users; --" ``` This is a backstop, not a licence — still choose the columns yourself: