security(wopi): add authz to Wopi
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WOPI authorization at token redemption
|
||||
# =============================================================
|
||||
# Regression coverage for the WOPI verb-handler bypass documented in
|
||||
# memory note `wopi-authz-bypass`. Two bugs closed:
|
||||
#
|
||||
# 1. Verb handlers (check_file_info, get_file, put_file,
|
||||
# file_operations, host_page) previously did NOT call
|
||||
# `AuthorizationEngine::require` at redemption. A grant
|
||||
# revoked between mint-time and request-time silently kept
|
||||
# working until the token TTL expired.
|
||||
#
|
||||
# 2. The mint helper decided `can_write` from the client's
|
||||
# `requested_action` string (`!= "view"` → write). A Viewer
|
||||
# clicking "Edit in Collabora" received a write-capable
|
||||
# token because the string was "edit".
|
||||
#
|
||||
# The fix wires `authz.require` on every verb and derives
|
||||
# `can_write` from the caller's actual Update permission. This
|
||||
# suite hits both paths through the real HTTP surface.
|
||||
#
|
||||
# Note on infra:
|
||||
# * `OXICLOUD_WOPI_ENABLED=true` in tests/common/server.env
|
||||
# * `OXICLOUD_WOPI_SECRET` pinned so the tokens the server mints
|
||||
# round-trip verify-able through the suite
|
||||
# * WOPI discovery served by `tests/common/wopi_mock_discovery.py`
|
||||
# started by run.sh — mock URL points at a black-hole editor
|
||||
# so we only assert on OxiCloud's own responses
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login as admin (owner) and capture home folder id
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_token: jsonpath "$.access_token"
|
||||
alice_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_home_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Create a Bob user (Viewer under test) via admin API
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "wopi-bob",
|
||||
"password": "WopiBobPassword1!",
|
||||
"email": "wopi-bob@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
bob_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "wopi-bob", "password": "WopiBobPassword1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
bob_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Alice uploads a plain-text file the WOPI verbs will
|
||||
# target. `text/plain` is in the mock discovery XML so
|
||||
# `/api/wopi/editor-url` resolves to a real (black-hole)
|
||||
# editor URL — the endpoint returns 200 with an
|
||||
# access_token we can then poke at the verbs.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{alice_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{alice_home_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
file_id: jsonpath "$.id"
|
||||
[Asserts]
|
||||
jsonpath "$.mime_type" == "text/plain"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Alice mints an editor-URL for her own file with
|
||||
# `action=edit`. Owner has Update → can_write=true.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_edit_token: jsonpath "$.access_token"
|
||||
[Asserts]
|
||||
jsonpath "$.access_token" isString
|
||||
jsonpath "$.editor_url" contains "edit"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — CheckFileInfo with the owner's edit token. Verb
|
||||
# re-checks Read → allowed. `user_can_write=true`
|
||||
# reflects real Update.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{alice_edit_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.UserId" == "{{alice_user_id}}"
|
||||
jsonpath "$.UserCanWrite" == true
|
||||
jsonpath "$.SupportsUpdate" == true
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — GetFile with the owner's edit token. Verb re-checks
|
||||
# Read → 200 with body.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_edit_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Hello"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — PutFile with the owner's edit token. Verb re-checks
|
||||
# Update → 200. The owner overwrites her own file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_edit_token}}
|
||||
Content-Type: application/octet-stream
|
||||
```
|
||||
owner overwrite via WOPI PutFile
|
||||
```
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Alice explicitly requests view mode. Even the owner
|
||||
# gets `can_write=false` — the token respects the
|
||||
# client's downgrade so Collabora can open a doc
|
||||
# "read-only for co-browsing".
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=view
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_view_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{alice_view_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Owner explicitly requested view — supports_update flips off.
|
||||
jsonpath "$.UserCanWrite" == false
|
||||
jsonpath "$.SupportsUpdate" == false
|
||||
|
||||
|
||||
# View token trying to write → 401 (token's can_write bit says no
|
||||
# before the authz.require ever runs).
|
||||
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_view_token}}
|
||||
Content-Type: application/octet-stream
|
||||
```
|
||||
owner trying to write with view token
|
||||
```
|
||||
|
||||
HTTP 401
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — SECURITY: Bob has NO grant on Alice's file. Requests
|
||||
# an edit-URL. The mint helper's Read gate fires → 404
|
||||
# (anti-enum). This is the pre-fix behaviour holding
|
||||
# — mint-time Read was already enforced via
|
||||
# get_file_with_perms.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Alice grants Bob the Viewer role on the file.
|
||||
# Capture the grant id off the POST response so Step
|
||||
# 13's revoke doesn't need to LIST + filter (the LIST
|
||||
# endpoint returns a bare JSON array, not
|
||||
# `.grants[?...]`, and Hurl's single-match filter
|
||||
# capture behaviour is quirky — see memory note
|
||||
# `feedback_hurl_jsonpath_filter_empty`).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||||
"resource": { "type": "file", "id": "{{file_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — SECURITY: Bob (Viewer) requests an EDIT token. Fix
|
||||
# #12: mint helper derives `can_write` from real
|
||||
# Update permission, not from the requested_action
|
||||
# string. Bob has Read but not Update → token is
|
||||
# minted with `can_write=false` even though he asked
|
||||
# for "edit".
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
bob_forged_edit_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# CheckFileInfo with Bob's "edit" token shows UserCanWrite=false
|
||||
# because the token's can_write bit was scrubbed at mint. Prior
|
||||
# to the fix this was `true` — a Viewer editing Alice's file.
|
||||
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_forged_edit_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.UserId" == "{{bob_user_id}}"
|
||||
jsonpath "$.UserCanWrite" == false
|
||||
jsonpath "$.SupportsUpdate" == false
|
||||
|
||||
|
||||
# Bob attempting PutFile with his "edit" token → 401. The
|
||||
# token's own can_write=false is the outer gate; even if the
|
||||
# token had somehow been forged with can_write=true, the
|
||||
# redemption-time authz.require(Update) would return 404.
|
||||
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_forged_edit_token}}
|
||||
Content-Type: application/octet-stream
|
||||
```
|
||||
Bob trying to write as Viewer
|
||||
```
|
||||
|
||||
HTTP 401
|
||||
|
||||
|
||||
# Bob CAN read (his Read grant is real).
|
||||
GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_forged_edit_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — SECURITY: promote Bob to Editor. Now he legitimately
|
||||
# holds Update, so an edit token becomes truly write-
|
||||
# capable.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||||
"resource": { "type": "file", "id": "{{file_id}}" },
|
||||
"role": "editor"
|
||||
}
|
||||
|
||||
# The engine's `ON CONFLICT UPDATE` collapses one role row per
|
||||
# (subject, resource), so this Editor grant REPLACES the Viewer
|
||||
# grant from Step 10 rather than stacking. Bob now holds
|
||||
# Editor alone; revoking it in Step 13 leaves him with no
|
||||
# grants at all.
|
||||
HTTP 201
|
||||
[Captures]
|
||||
bob_grant_id: jsonpath "$.grants[0].id"
|
||||
|
||||
|
||||
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
bob_real_edit_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_real_edit_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Bob is a real Editor now → can_write flips to true.
|
||||
jsonpath "$.UserCanWrite" == true
|
||||
jsonpath "$.SupportsUpdate" == true
|
||||
|
||||
|
||||
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}}
|
||||
Content-Type: application/octet-stream
|
||||
```
|
||||
Bob as Editor legitimately writes
|
||||
```
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 13 — SECURITY: revoke Bob's grant AFTER his edit token was
|
||||
# minted. The token stays cryptographically valid until
|
||||
# TTL, but every subsequent verb call must hit the
|
||||
# authorization engine and reject.
|
||||
#
|
||||
# This is the CORE bug the memory note describes: prior
|
||||
# to the fix Bob's PutFile still succeeded here because
|
||||
# the verb handlers trusted the token in isolation.
|
||||
#
|
||||
# The Editor grant from Step 12 REPLACED the Viewer
|
||||
# grant from Step 10 (engine's ON CONFLICT UPDATE —
|
||||
# one role row per subject/resource). So revoking the
|
||||
# Editor grant leaves Bob with no grants at all; every
|
||||
# verb — Read AND Update — must refuse.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/grants/{{bob_grant_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# CheckFileInfo — no Read → 404. Prior to the fix the verb
|
||||
# handler trusted the token and returned 200 with the file's
|
||||
# metadata.
|
||||
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_real_edit_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# GetFile — no Read → 404. Prior to the fix Bob could still
|
||||
# download the file content until the token TTL expired.
|
||||
GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# PutFile — no Update → 404 (verb-side require_wopi_perm), OR
|
||||
# 401 if the token's own `!claims.can_write` gate happened to
|
||||
# fire first. The important assertion is "not 200" — a revoked
|
||||
# grant must never let the caller through.
|
||||
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}}
|
||||
Content-Type: application/octet-stream
|
||||
```
|
||||
Bob post-revoke tries to write
|
||||
```
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — delete the test file so subsequent Hurl files don't
|
||||
# see it. Bob user stays; other tests may reuse the `wopi-bob`
|
||||
# username, but the grants that made this test meaningful are
|
||||
# gone.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/files/{{file_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
Reference in New Issue
Block a user