From 6be486b275fe43ce05f4d4028f975cd6c038c860 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:48:07 +0200 Subject: [PATCH] Fix auto-update file gating: mark releases.file protected (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The E2E caught a real leak: a non-nsfw (even anonymous) account could download an adult artifact. In PocketBase, file protection is a per-FIELD flag, not derived from the collection view rule — the original migration left releases.file unprotected, so its URL was public despite the gated read rules. - Add protected:true to the file field (correct for fresh installs). - 1786500001_releases_protect_file.js: alter the field on the already-deployed instance (applied migrations don't re-run, so the fix needs its own migration). - Doc: correct the gating explanation (protection is the field flag; the file token grant then re-checks the view rule). With this, a protected file needs a file token whose grant re-checks the view rule, so a clean account is denied the adult artifact. --- .gitignore | 2 ++ docs/auto-update-contract.md | 11 ++++---- pb_migrations/1786500000_releases.js | 6 ++++- .../1786500001_releases_protect_file.js | 25 +++++++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 pb_migrations/1786500001_releases_protect_file.js diff --git a/.gitignore b/.gitignore index 96bbd94..55824e6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ pb_data/ # Local env / secrets .env .env.local +# Local-only secrets (e.g. a minted superuser token for admin scripting) +*.local.token diff --git a/docs/auto-update-contract.md b/docs/auto-update-contract.md index 271b476..e548b7b 100644 --- a/docs/auto-update-contract.md +++ b/docs/auto-update-contract.md @@ -30,11 +30,12 @@ Unique index on `(platform, variant, buildNumber)`. **Access rules.** Read is gated: `@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)` — any signed-in account reads **clean** rows; only an `nsfwEnabled` account reads -**adult** rows. Because the view rule needs auth, the attached `file` is served -as a **protected file**: downloading needs a short-lived file token -(`POST /api/files/token`), and minting/using it re-checks the same rule — so -adult bytes never reach a non-flagged account. `create`/`update`/`delete` are -**superuser-only** (null rules); publishing goes through the admin API. +**adult** rows. The `file` field is **`protected: true`**, which is what gates the +bytes: a protected file is served only with a short-lived file token +(`POST /api/files/token`) whose grant **re-checks the view rule above** — so a +non-flagged account can't download an adult artifact (and without the flag the +file URL would be public regardless of the view rule). `create`/`update`/`delete` +are **superuser-only** (null rules); publishing goes through the admin API. ## Endpoint diff --git a/pb_migrations/1786500000_releases.js b/pb_migrations/1786500000_releases.js index 884eccb..98237a9 100644 --- a/pb_migrations/1786500000_releases.js +++ b/pb_migrations/1786500000_releases.js @@ -34,7 +34,11 @@ migrate((app) => { { type: "text", name: "version", required: true, max: 40 }, { type: "number", name: "buildNumber", required: true, min: 1, onlyInt: true }, // The artifact: desktop = a .zip of the release bundle, android = the .apk. - { type: "file", name: "file", required: true, maxSelect: 1, maxSize: 524288000 }, + // `protected` is what actually gates the bytes: a protected file is served + // only with a short-lived file token whose grant re-checks the view rule + // below (so a non-nsfw account can't fetch an adult artifact). WITHOUT this + // flag the file URL is public regardless of the view rule. + { type: "file", name: "file", required: true, maxSelect: 1, maxSize: 524288000, protected: true }, // Lowercase hex SHA-256 of the artifact; the app verifies it before install. { type: "text", name: "sha256", required: true, min: 64, max: 64 }, { type: "number", name: "size", required: true, min: 0, onlyInt: true }, diff --git a/pb_migrations/1786500001_releases_protect_file.js b/pb_migrations/1786500001_releases_protect_file.js new file mode 100644 index 0000000..bf92a70 --- /dev/null +++ b/pb_migrations/1786500001_releases_protect_file.js @@ -0,0 +1,25 @@ +/// + +// Fix gating on the releases `file` field (issue #16). +// +// The original migration created the file field without `protected: true`, which +// left the artifact URL PUBLIC — a non-nsfw account (and even an anonymous +// request) could download the adult build, defeating the whole point of the +// gating rules. Protection in PocketBase is a per-field flag, not derived from +// the collection view rule: with it on, the file is served only via a +// short-lived file token whose grant re-checks the view rule. +// +// This alters the field on already-deployed instances; a fresh install gets it +// correct straight from 1786500000. + +migrate((app) => { + const c = app.findCollectionByNameOrId("releases") + const f = c.fields.getByName("file") + f.protected = true + app.save(c) +}, (app) => { + const c = app.findCollectionByNameOrId("releases") + const f = c.fields.getByName("file") + f.protected = false + app.save(c) +})