security(music): ensure read permission via authz

This commit is contained in:
Edouard Vanbelle
2026-07-04 23:31:10 +02:00
parent 2cda8e7e22
commit b95e740b2f
7 changed files with 280 additions and 65 deletions
+74
View File
@@ -159,3 +159,77 @@ Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.items" count == 0
# ─────────────────────────────────────────────────────────────
# Step 12 — Cross-tenant regression (post-Drive AuthZ audit,
# Round 1 HIGH). Before this fix, `POST /api/favorites/…`
# accepted any UUID and enrolled it; the listing endpoint
# then JOINed back to storage.files/folders and returned
# name/mime/size/drive_id for anything the caller had
# managed to add — an information oracle over the whole
# tenant. Now the write path calls `authz.require(Read, …)`
# per item; a caller with no grant gets 404 (anti-enum)
# + `authz.denied` audit line. See
# `docs/plan/authz_audit/rest_storage.md`.
# ─────────────────────────────────────────────────────────────
# Create a second, unprivileged user. Idempotent: `HTTP *` accepts
# either 201 (first run) or 409 (subsequent runs). The login below
# is the actual precondition — if it succeeds we know the user
# exists with the expected password.
POST {{base_url}}/api/admin/users
Authorization: Bearer {{token}}
Content-Type: application/json
{ "username": "fav_mallory", "password": "FavMalloryPassword1!", "email": "fav_mallory@example.com", "role": "user" }
HTTP *
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "fav_mallory", "password": "FavMalloryPassword1!" }
HTTP 200
[Captures]
mallory_token: jsonpath "$.access_token"
# Step 12a — Single-add on admin's file: 404 (anti-enum shape).
POST {{base_url}}/api/favorites/file/{{file_id}}
Authorization: Bearer {{mallory_token}}
HTTP 404
# Step 12b — Single-add on admin's folder: 404.
POST {{base_url}}/api/favorites/folder/{{test1_id}}
Authorization: Bearer {{mallory_token}}
HTTP 404
# Step 12c — Batch: must fail wholesale on the first denial. A partial
# success would still leak "which items are valid" — the same
# oracle we're closing.
POST {{base_url}}/api/favorites/batch
Authorization: Bearer {{mallory_token}}
Content-Type: application/json
{
"items": [
{ "item_id": "{{file_id}}", "item_type": "file" },
{ "item_id": "{{test1_id}}", "item_type": "folder" }
]
}
HTTP 404
# Step 12d — Mallory's favorites list is EMPTY — no partial success
# slipped through.
GET {{base_url}}/api/favorites/resources
Authorization: Bearer {{mallory_token}}
HTTP 200
[Asserts]
jsonpath "$.items" count == 0
+79
View File
@@ -274,6 +274,85 @@ status >= 400
status < 500
# ─────────────────────────────────────────────────────────────
# 14b — Viewer-laundering regression (post-Drive AuthZ audit,
# Round 1 HIGH). Before the fix, `POST /api/shares` checked
# only "does the item exist" — any authenticated user who
# could name the UUID could mint a public Viewer link,
# laundering read access into a permanent anonymous URL
# that survived their own grant revocation. Now the
# service calls `authz.require(Share, resource)` before
# minting the token; a caller without `Share`
# (Viewer/Commenter/Contributor/no-grant-at-all) gets 404
# (anti-enum) + `authz.denied` audit line. See
# `docs/plan/authz_audit/admin_membership.md`.
#
# We test the strongest form: an unrelated user with no
# grant at all. The intermediate case (Viewer with Read
# but not Share) is covered by the same code path — Share
# is bundled only with owner/editor role_grants.
# ─────────────────────────────────────────────────────────────
# Create/lookup the attacker. Idempotent: `HTTP *` accepts either
# 201 (first run) or 409 (subsequent runs). Login below is the real
# precondition.
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "username": "sh_mallory", "password": "ShMalloryPassword1!", "email": "sh_mallory@example.com", "role": "user" }
HTTP *
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "sh_mallory", "password": "ShMalloryPassword1!" }
HTTP 200
[Captures]
mallory_token: jsonpath "$.access_token"
# Step 14b.i — Mallory tries to mint a public share on admin's
# folder: 404 (anti-enum). No token appears in the
# response body.
POST {{base_url}}/api/shares
Authorization: Bearer {{mallory_token}}
Content-Type: application/json
{
"item_id": "{{share_folder_id}}",
"item_name": "public-share-test",
"item_type": "folder"
}
HTTP 404
# Step 14b.ii — Same attempt on admin's file: 404.
POST {{base_url}}/api/shares
Authorization: Bearer {{mallory_token}}
Content-Type: application/json
{
"item_id": "{{shared_file_id}}",
"item_name": "hello.txt",
"item_type": "file"
}
HTTP 404
# Step 14b.iii — Mallory has no shares — no partial success slipped
# through. (`GET /api/shares` returns only shares the
# caller created; response is paginated.)
GET {{base_url}}/api/shares
Authorization: Bearer {{mallory_token}}
HTTP 200
[Asserts]
jsonpath "$.items" isCollection
jsonpath "$.items" count == 0
# ─────────────────────────────────────────────────────────────
# 15 — Teardown: revoke the password share + the direct
# file-share, then delete the folder.
+64
View File
@@ -187,3 +187,67 @@ DELETE {{base_url}}/api/recent/clear
Authorization: Bearer {{token}}
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 10 — Cross-tenant regression (post-Drive AuthZ audit,
# Round 1 HIGH). Before this fix, `POST /api/recent/…`
# accepted any UUID and the listing endpoint JOINed back
# to storage.files/folders (name/mime/size/drive_id) — a
# metadata oracle over the whole tenant. Now the write
# path calls `authz.require(Read, …)`; unauthorised
# callers get 404 (anti-enum) + `authz.denied` audit line.
# See `docs/plan/authz_audit/rest_storage.md`.
# ─────────────────────────────────────────────────────────────
# Re-discover a folder id so the attacker has TWO targets to probe
# (file + folder). Same test1 folder as favorites.hurl.
GET {{base_url}}/api/folders/{{home_folder_id}}/resources?resource_types=folder
Authorization: Bearer {{token}}
HTTP 200
[Captures]
test1_id: jsonpath "$.items[0].resource.id"
# Create/lookup the attacker. Idempotent: `HTTP *` accepts either
# 201 (first run) or 409 (subsequent runs). Login below is the real
# precondition.
POST {{base_url}}/api/admin/users
Authorization: Bearer {{token}}
Content-Type: application/json
{ "username": "rec_mallory", "password": "RecMalloryPassword1!", "email": "rec_mallory@example.com", "role": "user" }
HTTP *
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "rec_mallory", "password": "RecMalloryPassword1!" }
HTTP 200
[Captures]
mallory_token: jsonpath "$.access_token"
# Step 10a — Record admin's file into mallory's recent: 404.
POST {{base_url}}/api/recent/file/{{file_id}}
Authorization: Bearer {{mallory_token}}
HTTP 404
# Step 10b — Same for admin's folder: 404.
POST {{base_url}}/api/recent/folder/{{test1_id}}
Authorization: Bearer {{mallory_token}}
HTTP 404
# Step 10c — Mallory's recent list stays empty.
GET {{base_url}}/api/recent/resources
Authorization: Bearer {{mallory_token}}
HTTP 200
[Asserts]
jsonpath "$.items" count == 0