From 3501857a70ef9e2d4edbb2008b6953f88970e5e9 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Wed, 1 Jul 2026 22:54:56 +0200 Subject: [PATCH] test(webdav): cover protected-property PROPPATCH rejection Native + NC surfaces: DAV: displayname/getetag, oc:fileid/ permissions, nc:has-preview all 403 and never land in the store; mixed request shows per-property granularity (protected prop 403 alongside an ordinary custom prop 200); oc:favorite regression guard confirms its special-case still works despite being on the protected list. --- tests/api/run.sh | 1 + tests/api/webdav_protected_properties.hurl | 368 +++++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 tests/api/webdav_protected_properties.hurl diff --git a/tests/api/run.sh b/tests/api/run.sh index 472272c5..57a4b8bc 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -169,6 +169,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/cross_drive_copy.hurl" \ "$API_DIR/webdav_dead_properties.hurl" \ "$API_DIR/nc_webdav_dead_properties.hurl" \ + "$API_DIR/webdav_protected_properties.hurl" \ "$API_DIR/webdav_nested_move_cascade.hurl" #bash "$API_DIR/dedup_bulk_upload.sh" diff --git a/tests/api/webdav_protected_properties.hurl b/tests/api/webdav_protected_properties.hurl new file mode 100644 index 00000000..66219024 --- /dev/null +++ b/tests/api/webdav_protected_properties.hurl @@ -0,0 +1,368 @@ +# ============================================================= +# OxiCloud — WebDAV protected properties (RFC 4918 §9.2 / §15) +# ============================================================= +# `DeadPropertyStore` lets a PROPPATCH set arbitrary namespace/name +# pairs verbatim (RFC 4918 §4.2). Without a denylist, a client could +# PROPPATCH `DAV:getetag`, `oc:fileid`, `oc:permissions`, etc. — names +# the server ALSO emits as live state in PROPFIND/REPORT responses +# (see `write_file_response` / `write_folder_response` in the NC +# handler and the native PROPFIND writer). That produces either a +# forged live property (the server would need to pick which of two +# values to emit) or a silently stored, never-read row. +# +# `is_protected_property()` (src/application/adapters/webdav_adapter.rs) +# defends the whole `DAV:` namespace plus the specific oc:/nc:/ocs: +# names the server actually emits elsewhere. Both PROPPATCH handlers +# (native `/webdav/` and NC `/remote.php/dav/`) consult it before +# touching `DeadPropertyStore`, and reject with RFC 4918 §9.2's +# per-property `403 Forbidden` inside the 207 multi-status — not a +# blanket request failure, and not a silent no-op success. +# +# Coverage: +# 1. Native /webdav/: PROPPATCH set on `D:displayname` (DAV: +# namespace) → 207 envelope, inner 403 for that property. +# 2. PROPFIND confirms the live displayname is unchanged — the +# forged value never landed anywhere. +# 3. Native /webdav/: PROPPATCH remove on `D:getetag` → same 403 +# contract on the Remove path, not just Set. +# 4. Native /webdav/: an oc:-namespaced protected name +# (`oc:fileid`) is blocked even on the surface that doesn't +# normally speak NextCloud namespaces — protection is +# namespace-global, not surface-scoped. +# 5. Mixed request: one protected DAV: prop + one ordinary custom +# dead property in the SAME PROPPATCH → 207 with both a 403 +# propstat block and a 200 propstat block; the custom property +# DOES get stored (per-property granularity, not all-or-nothing +# rejection). +# 6. NC surface: PROPPATCH set on a protected oc: name +# (`oc:permissions`) → 403; PROPFIND confirms it was never +# written to the dead-property store. +# 7. NC surface: PROPPATCH set on a protected nc: name +# (`nc:has-preview`) → 403. +# 8. Regression guard: `oc:favorite` is on the protected list too +# (it's live state the NC handler emits), but the handler's +# favorite special-case runs BEFORE the protected-property +# check, so toggling favorite through PROPPATCH still works — +# protection must not swallow the one oc: name that's +# legitimately client-writable via a side channel. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT; mint an NC app password for the +# NC-surface half of this file (NC DAV uses Basic Auth). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{token}} +Content-Type: application/json +{ "label": "webdav_protected_properties" } + +HTTP 200 +[Captures] +nc_username: jsonpath "$.username" +nc_password: jsonpath "$.password" +ap_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — PUT a probe file via native WebDAV. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +hello protected properties +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 3 — PROPPATCH set on DAV:displayname (live property) must +# be rejected with a per-property 403, not silently +# accepted into DeadPropertyStore. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + forged-name + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — PROPFIND confirms the live displayname is untouched. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Depth: 0 +Content-Type: application/xml; charset=utf-8 +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='displayname'])" == "protected-props-probe.txt" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PROPPATCH remove on DAV:getetag → same 403 contract +# on the Remove path. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Protection is namespace-global: an oc:-namespaced +# protected name is blocked even on the native /webdav/ +# surface, which doesn't otherwise speak NextCloud +# namespaces. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + should-not-be-stored + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Mixed request: one protected DAV: prop + one ordinary +# custom dead property in the SAME PROPPATCH → per- +# property granularity, not all-or-nothing rejection. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + forged + allowed-alongside-protected + + + +``` + +HTTP 207 +[Asserts] +xpath "count(//*[local-name()='propstat'])" == 2 +xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='resourcetype']]/*[local-name()='status'])" contains "403" +xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='testlabel']]/*[local-name()='status'])" contains "200 OK" + + +PROPFIND {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Depth: 0 +Content-Type: application/xml; charset=utf-8 +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='testlabel'])" == "allowed-alongside-protected" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — native probe file. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — NC surface: PUT a probe file via the NC DAV mount. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: text/plain +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` +hello nc protected properties +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — NC surface: PROPPATCH set on a protected oc: name +# (`oc:permissions`, not the specially-handled favorite) +# → 403. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + forged + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='permissions'])" != "forged" + + +# ───────────────────────────────────────────────────────────── +# Step 10 — NC surface: PROPPATCH set on a protected nc: name +# (`nc:has-preview`) → 403. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + forged + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Regression guard: oc:favorite is on the protected +# list too, but the handler's favorite special-case +# runs before the protection check, so toggling it via +# PROPPATCH must still work end to end. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + 1 + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK" + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='favorite'])" == "1" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — NC probe file, teardown app password. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}} +Authorization: Bearer {{token}} + +HTTP 200