diff --git a/tests/api/permissions.hurl b/tests/api/permissions.hurl new file mode 100644 index 00000000..e30bb5b3 --- /dev/null +++ b/tests/api/permissions.hurl @@ -0,0 +1,344 @@ +# ============================================================= +# OxiCloud – Cross-user permission / IDOR scenarios +# ============================================================= +# Verifies the ownership checks added to FolderService::create_folder +# and FileManagementService move/copy/rename, plus the shared +# FolderDbRepository::verify_owner helper. +# +# Plan reference: /Users/ed/.claude/plans/compiled-shimmying-bonbon.md +# — "Verification → 2. Manual integration tests" +# +# Run via tests/api/run.sh; must be ordered LAST in the runner because +# it creates a second user (bob) and writes into admin's home folder. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 – Login as admin (the user created by setup.hurl) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "{{password}}" +} + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 – Capture admin's home folder +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Captures] +admin_home_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 – Admin creates a private folder inside their home +# This is the resource bob will attempt to attack. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "name": "admin-private-folder", + "parent_id": "{{admin_home_id}}" +} + +HTTP 201 +[Captures] +admin_private_id: jsonpath "$.id" +[Asserts] +jsonpath "$.name" == "admin-private-folder" +jsonpath "$.parent_id" == {{admin_home_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 4 – Admin uploads a file into their home +# This is the file bob will attempt to access. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{admin_home_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +admin_file_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 – Admin creates user bob (via /api/admin/users) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "bob", + "password": "BobPassword1!", + "email": "bob@example.com", + "role": "user" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 6 – Login as bob, capture his token + home folder +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "bob", + "password": "BobPassword1!" +} + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + +GET {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Captures] +bob_home_id: jsonpath "$[0].id" +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].parent_id" == null + + +# ═════════════════════════════════════════════════════════════ +# IDOR tests — every request below uses bob's token +# ═════════════════════════════════════════════════════════════ + + +# ───────────────────────────────────────────────────────────── +# Step 7 – Bob attempts to create a folder inside admin's home +# Expected: 404 (NotFound, not 403, to avoid leaking +# the existence of admin's folder). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "name": "bob-attack-1", + "parent_id": "{{admin_home_id}}" +} + +HTTP 404 +[Asserts] +jsonpath "$.error_type" == "Not Found" + + +# ───────────────────────────────────────────────────────────── +# Step 8 – Bob attempts to create a folder inside admin's +# private folder. Same expectation as Step 7. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "name": "bob-attack-2", + "parent_id": "{{admin_private_id}}" +} + +HTTP 404 +[Asserts] +jsonpath "$.error_type" == "Not Found" + + +# ───────────────────────────────────────────────────────────── +# Step 9 – Bob omits parent_id (null). The REST handler +# auto-resolves null to the caller's home folder +# (folder_handler.rs:55-77), so the request succeeds +# and the folder lands in bob's home — NOT at the +# database root. The service-level validation_error +# ("Root folder creation is reserved for registration") +# is defense-in-depth for callers that bypass this +# handler convenience. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "name": "bob-auto-resolved", + "parent_id": null +} + +HTTP 201 +[Asserts] +jsonpath "$.name" == "bob-auto-resolved" +jsonpath "$.parent_id" == {{bob_home_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 10 – Positive control: bob CAN create a folder inside +# his own home. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "name": "bob-own-folder", + "parent_id": "{{bob_home_id}}" +} + +HTTP 201 +[Captures] +bob_folder_id: jsonpath "$.id" +[Asserts] +jsonpath "$.name" == "bob-own-folder" +jsonpath "$.parent_id" == {{bob_home_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 11 – Bob uploads a file into his own home (for the +# file-move tests below). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{bob_token}} +[MultipartFormData] +folder_id: {{bob_home_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +bob_file_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 12 – Bob attempts to move his own file into admin's +# private folder. He owns the file but not the target +# → verify_target_folder_owner rejects with 404. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/files/{{bob_file_id}}/move +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "folder_id": "{{admin_private_id}}" +} + +HTTP 404 +[Asserts] +jsonpath "$.error_type" == "Not Found" + + +# ───────────────────────────────────────────────────────────── +# Step 13 – Bob moves his file to folder_id: null (his root +# namespace). storage.files.folder_id IS NULL is a +# legitimate state — verify_target_folder_owner +# short-circuits to Ok(()) when target is None. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/files/{{bob_file_id}}/move +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "folder_id": null +} + +HTTP 200 +[Asserts] +jsonpath "$.id" == {{bob_file_id}} +jsonpath "$.folder_id" == null + + +# ───────────────────────────────────────────────────────────── +# Step 14 – Bob attempts to access admin's file directly. +# verify_owner on the file (not the folder) catches +# this — IDOR on file reads, also 404. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/files/{{admin_file_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 +[Asserts] +jsonpath "$.error_type" == "Not Found" + + +# ───────────────────────────────────────────────────────────── +# Step 15 – Admin's private folder still exists & is untouched. +# Bob's attacks must not have polluted admin's tree. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders/{{admin_home_id}}/contents +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" contains {{admin_private_id}} +jsonpath "$[*].name" not contains "bob-attack-1" +jsonpath "$[*].name" not contains "bob-attack-2" + + +# ═════════════════════════════════════════════════════════════ +# WebDAV MKCOL — namespace isolation +# ═════════════════════════════════════════════════════════════ +# WebDAV requests are isolated per-user by `resolve_webdav_path` +# (webdav_handler.rs:189). If the requested path doesn't begin +# with the caller's home folder name ("My Folder - "), +# the handler silently prefixes the caller's home folder path +# onto the front. Effect: any WebDAV path a client sends is +# always resolved INSIDE the caller's own tree, regardless of +# what they wrote. +# +# These tests assert the isolation works (regression guard) and +# that the service-level verify_owner still acts as +# defense-in-depth for the legitimate path. + + +# ───────────────────────────────────────────────────────────── +# Step 16 – Bob crafts a path that looks like it targets admin's +# home. The WebDAV handler rewrites the path to live +# under bob's home, so the request succeeds (201) but +# the new folders land in BOB's tree — never admin's. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/My%20Folder%20-%20admin/bob-webdav-attack +Authorization: Bearer {{bob_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 17 – Positive control: bob MKCOL inside his own home. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/My%20Folder%20-%20bob/bob-webdav-own +Authorization: Bearer {{bob_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 18 – Bob's home now contains: +# - "bob-webdav-own" (from Step 17, normal MKCOL) +# - "My Folder - admin" (from Step 16 — the prefix +# rewrite turned admin's home name into a literal +# sub-folder name inside bob's tree). +# This proves the path prefix re-rooted the attack +# into bob's own namespace. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders/{{bob_home_id}}/contents +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].name" contains "bob-webdav-own" +jsonpath "$[*].name" contains "My Folder - admin" + + +# ───────────────────────────────────────────────────────────── +# Step 19 – Admin's tree is unchanged by bob's WebDAV traffic. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders/{{admin_home_id}}/contents +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].name" not contains "bob-webdav-attack" +jsonpath "$[*].name" not contains "bob-webdav-own" diff --git a/tests/api/run.sh b/tests/api/run.sh index 5f660b71..48e9f438 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -96,7 +96,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/recent.hurl" \ "$API_DIR/batch_folder_copy.hurl" \ "$API_DIR/dedup_blob_cleanup.hurl" \ - "$API_DIR/contacts.hurl" + "$API_DIR/contacts.hurl" \ + "$API_DIR/permissions.hurl" #bash "$API_DIR/dedup_bulk_upload.sh"