Fix auto-update file gating: mark releases.file protected (#16)

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.
This commit is contained in:
Claude 2026-07-20 11:48:07 +02:00
parent 785a621c49
commit 6be486b275
4 changed files with 38 additions and 6 deletions

2
.gitignore vendored
View file

@ -4,3 +4,5 @@ pb_data/
# Local env / secrets # Local env / secrets
.env .env
.env.local .env.local
# Local-only secrets (e.g. a minted superuser token for admin scripting)
*.local.token

View file

@ -30,11 +30,12 @@ Unique index on `(platform, variant, buildNumber)`.
**Access rules.** Read is gated: **Access rules.** Read is gated:
`@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)` `@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)`
— any signed-in account reads **clean** rows; only an `nsfwEnabled` account reads — 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 **adult** rows. The `file` field is **`protected: true`**, which is what gates the
as a **protected file**: downloading needs a short-lived file token bytes: a protected file is served only with a short-lived file token
(`POST /api/files/token`), and minting/using it re-checks the same rule — so (`POST /api/files/token`) whose grant **re-checks the view rule above** — so a
adult bytes never reach a non-flagged account. `create`/`update`/`delete` are non-flagged account can't download an adult artifact (and without the flag the
**superuser-only** (null rules); publishing goes through the admin API. 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 ## Endpoint

View file

@ -34,7 +34,11 @@ migrate((app) => {
{ type: "text", name: "version", required: true, max: 40 }, { type: "text", name: "version", required: true, max: 40 },
{ type: "number", name: "buildNumber", required: true, min: 1, onlyInt: true }, { type: "number", name: "buildNumber", required: true, min: 1, onlyInt: true },
// The artifact: desktop = a .zip of the release bundle, android = the .apk. // 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. // Lowercase hex SHA-256 of the artifact; the app verifies it before install.
{ type: "text", name: "sha256", required: true, min: 64, max: 64 }, { type: "text", name: "sha256", required: true, min: 64, max: 64 },
{ type: "number", name: "size", required: true, min: 0, onlyInt: true }, { type: "number", name: "size", required: true, min: 0, onlyInt: true },

View file

@ -0,0 +1,25 @@
/// <reference path="../pb_data/types.d.ts" />
// 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)
})