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:
parent
785a621c49
commit
6be486b275
4 changed files with 38 additions and 6 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
25
pb_migrations/1786500001_releases_protect_file.js
Normal file
25
pb_migrations/1786500001_releases_protect_file.js
Normal 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)
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue