security(wopi): add authz to Wopi

This commit is contained in:
Edouard Vanbelle
2026-07-05 23:17:14 +02:00
parent 1786fe4111
commit 75601beb43
5 changed files with 649 additions and 16 deletions
+19 -1
View File
@@ -38,17 +38,34 @@ wait_for_http() {
SERVER_PID=""
WOPI_MOCK_PID=""
cleanup() {
if [[ -n "$SERVER_PID" ]]; then
log "Stopping OxiCloud server (pid $SERVER_PID)..."
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [[ -n "$WOPI_MOCK_PID" ]]; then
log "Stopping WOPI mock discovery (pid $WOPI_MOCK_PID)..."
kill "$WOPI_MOCK_PID" 2>/dev/null || true
wait "$WOPI_MOCK_PID" 2>/dev/null || true
fi
bash "$COMMON/stop-db.sh"
}
trap cleanup EXIT
# ── 0. WOPI mock discovery ────────────────────────────────────────────────────
# Serves the static discovery.xml `OXICLOUD_WOPI_DISCOVERY_URL`
# points at (server.env pins port 9100). Started BEFORE OxiCloud so
# the server's cache-fill on first WOPI request finds it. The mock
# is stdlib-only Python (no deps) — see the file header for what it
# returns and why it's cheap.
log "Starting WOPI mock discovery on port 9100..."
node "$COMMON/wopi_mock_discovery.js" > /tmp/wopi-mock-discovery.log 2>&1 &
WOPI_MOCK_PID=$!
# ── 1. Start postgres ─────────────────────────────────────────────────────────
bash "$COMMON/spawn-db.sh"
@@ -168,7 +185,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/cross_drive_move.hurl" \
"$API_DIR/cross_drive_copy.hurl" \
"$API_DIR/webdav_dead_properties.hurl" \
"$API_DIR/webdav_nested_move_cascade.hurl"
"$API_DIR/webdav_nested_move_cascade.hurl" \
"$API_DIR/wopi_authz.hurl"
#bash "$API_DIR/dedup_bulk_upload.sh"
+377
View File
@@ -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
+11 -1
View File
@@ -13,7 +13,17 @@ OXICLOUD_ENABLE_SEARCH=true
OXICLOUD_ENABLE_FILE_SHARING=true
OXICLOUD_ENABLE_MUSIC=true
OXICLOUD_EXPOSE_SYSTEM_USERS=true
OXICLOUD_WOPI_ENABLED=false
OXICLOUD_WOPI_ENABLED=true
# Fixed secret so the Hurl WOPI test can hand-craft valid access
# tokens with a known signing key. Prod deployments MUST override
# this to a random per-deployment value.
OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod
# Discovery URL points at a black hole — VERB endpoints don't need
# discovery, and the WOPI Hurl suite deliberately does NOT touch
# `/api/wopi/editor-url` (the only path that would fetch it), so
# an unreachable URL keeps startup fast and hermetic.
OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml
OXICLOUD_WOPI_TOKEN_TTL_SECS=3600
OXICLOUD_OIDC_ENABLED=false
OXICLOUD_NEXTCLOUD_ENABLED=true
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
// Minimal mock WOPI discovery server for the Hurl WOPI suite.
//
// Serves a valid RFC-shaped discovery XML on `GET /discovery.xml` so
// `OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:<port>/discovery.xml`
// resolves to a real editor URL when `/api/wopi/editor-url` fetches it.
//
// The `urlsrc` we hand back points at a black-hole host so no real
// editor process needs to be running — the Hurl suite only asserts on
// OxiCloud's own responses (token contents, HTTP status codes,
// headers). The mock exists purely to let `get_editor_url` succeed
// end-to-end so we can exercise the mint-time authz path (Viewer-
// clicks-Edit gets a read-only token).
//
// Node stdlib only — matches the tooling used by tests/oidc/fake_idp
// (both are stdlib-free apart from `node-oidc-provider` on that side).
// No package.json, no npm install, no extra dependency for the api
// test suite. Started + reaped by `tests/api/run.sh`. Port comes from
// `WOPI_MOCK_PORT` env var (default 9100).
'use strict';
const http = require('http');
const DISCOVERY_XML = `<?xml version="1.0" encoding="utf-8"?>
<wopi-discovery>
<net-zone name="external-http">
<!-- text/plain lets the txt files the Hurl suite uploads round-trip. -->
<app name="text/plain" favIconUrl="http://mock-editor.invalid/favicon.ico">
<action name="edit" ext="txt" urlsrc="http://mock-editor.invalid/edit?"/>
<action name="view" ext="txt" urlsrc="http://mock-editor.invalid/view?"/>
</app>
<!-- One office extension so tests can also exercise the docx path
if they need to. -->
<app name="application/vnd.openxmlformats-officedocument.wordprocessingml.document">
<action name="edit" ext="docx" urlsrc="http://mock-editor.invalid/edit?"/>
<action name="view" ext="docx" urlsrc="http://mock-editor.invalid/view?"/>
</app>
</net-zone>
<proof-key oldvalue="" oldmodulus="" oldexponent=""
value="" modulus="" exponent=""/>
</wopi-discovery>
`;
const port = Number(process.env.WOPI_MOCK_PORT || 9100);
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/discovery.xml') {
res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Length': Buffer.byteLength(DISCOVERY_XML),
});
res.end(DISCOVERY_XML);
return;
}
res.writeHead(404);
res.end();
});
// SIGTERM from `kill` in run.sh cleanup — exit quietly so the test
// runner's tail-of-log stays clean.
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, () => server.close(() => process.exit(0)));
}
server.listen(port, '127.0.0.1', () => {
console.log(`wopi-mock-discovery listening on 127.0.0.1:${port}`);
});