test(e2e): webdav + nextcloud full e2e test coverage
add a full coverage of Webdav and Nextcloud
purpose: prepare move to Drives and ensure no regression at all
test scenarios are in docs/plan/BASELINE_TESTS_NC_WEBDAV.md
current existing bugs identified via these tests:
┌──────────┬─────────┬────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Bug │ Surface │ Pin location │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ G4/G5/K5 │ NC │ AlreadyExists → 500 instead of 412 (handle_move + trashbin restore) │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ G9 │ NC │ Folder DELETE not row-recursive — orphan descendants stay live │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ M5/M7 │ Native │ resolve_path_for_user mismatch — PUT writes, GET reads via lenient lookup, MOVE/DELETE can't find │
│ │ │ via strict │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ M8 │ Native │ COPY discards destination filename — collides with source │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ N2 │ Native │ LOCK creates the token, mutators don't check it — class-2 advertisement is aspirational │
└──────────┴─────────┴────────────────────────────────────────────────────────────────────────────────────────────────────┘
This commit is contained in:
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# OxiCloud — Baseline: NC cross-user isolation (security)
|
||||
# =============================================================
|
||||
# Group O from BASELINE_TESTS_NC_WEBDAV.md (4 scenarios).
|
||||
#
|
||||
# Security baseline. The NC handlers must enforce the
|
||||
# (username, app-password) auth identity against every URL
|
||||
# path's `{user}` segment — alice authenticated via her app
|
||||
# password must NEVER read, write, or even enumerate files
|
||||
# belonging to bob, regardless of clever URL crafting.
|
||||
#
|
||||
# Depends on `tests/api/nc_second_user_setup.hurl` having
|
||||
# created the bob fixture earlier in run.sh.
|
||||
#
|
||||
# Coverage:
|
||||
# O1 — PROPFIND bob's home folder while auth'd as alice → 403
|
||||
# O2 — Path-traversal attempt
|
||||
# (`/dav/files/alice/../bob/...`) → 400
|
||||
# O3 — MOVE alice's file → bob's home → 403 / 4xx
|
||||
# O4 — Alice's PROPFIND of her own home returns ONLY her
|
||||
# files (no bob files leak across)
|
||||
#
|
||||
# Notes on phrasing of pass conditions:
|
||||
# * For O1 / O3 the "rejection" status code may legitimately
|
||||
# be 403 (URL/auth user mismatch — the cross-check in the
|
||||
# middleware) or 401 (Basic Auth challenge). Both are
|
||||
# acceptable; what matters is that the request does NOT
|
||||
# succeed.
|
||||
# * For O2 the path-traversal rejection is asserted at the
|
||||
# `reject_path_traversal` helper in the dispatcher; if
|
||||
# that returns 400, good. If somehow it sneaks through to
|
||||
# a real lookup, we'd see a 404, which we treat as a fail
|
||||
# because it implies the dispatcher accepted the traversal.
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
source test.env
|
||||
source common.sh
|
||||
source lib/dav_helpers.sh
|
||||
|
||||
echo
|
||||
echo "=== NC cross-user isolation (Group O baseline) ==="
|
||||
echo
|
||||
|
||||
oxicloud_login # logs in as admin (= "alice" in the scenario phrasing)
|
||||
mint_app_password # admin's app password — referenced as alice's
|
||||
ALICE_APP_PASS="$APP_PASS"
|
||||
ALICE_USERNAME="$username"
|
||||
resolve_home_folder_id
|
||||
wipe_home_folder
|
||||
|
||||
# ── Register bob + mint his app password ─────────────────────────────────────
|
||||
# tests/webdav/run.sh spawns its own postgres + server, so the bob fixture
|
||||
# created by tests/api/nc_second_user_setup.hurl in the api suite is NOT
|
||||
# visible here. Register him inline. The endpoint is anti-enumeration mode
|
||||
# (uniform 200 on success or "already exists"); the real existence check is
|
||||
# the login below — if it returns a JWT, the account is usable.
|
||||
|
||||
BOB_USERNAME="bob"
|
||||
BOB_LOGIN_PW="BobPassword1!"
|
||||
BOB_EMAIL="bob@example.com"
|
||||
|
||||
curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$BOB_USERNAME\",\"email\":\"$BOB_EMAIL\",\"password\":\"$BOB_LOGIN_PW\"}" \
|
||||
"$base_url/api/auth/register" > /dev/null
|
||||
|
||||
BOB_LOGIN_RESP=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$BOB_USERNAME\",\"password\":\"$BOB_LOGIN_PW\"}" \
|
||||
"$base_url/api/auth/login")
|
||||
BOB_JWT=$(jq -r '.access_token // empty' <<< "$BOB_LOGIN_RESP" 2>/dev/null || echo "")
|
||||
[[ -n "$BOB_JWT" ]] \
|
||||
|| fail "preflight: bob login failed after inline registration. response=$BOB_LOGIN_RESP"
|
||||
|
||||
BOB_APP_RESP=$(curl -s -X POST \
|
||||
-H "Authorization: Bearer $BOB_JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"label":"nc_cross_user_isolation test (bob)"}' \
|
||||
"$base_url/api/auth/app-passwords")
|
||||
BOB_APP_PASS=$(jq -r '.password // empty' <<< "$BOB_APP_RESP")
|
||||
BOB_APP_PASS_ID=$(jq -r '.id // empty' <<< "$BOB_APP_RESP")
|
||||
[[ -n "$BOB_APP_PASS" ]] \
|
||||
|| fail "preflight: could not mint bob app password (response: $BOB_APP_RESP)"
|
||||
|
||||
# Seed each user's home folder with a probe file so O3/O4 have
|
||||
# something to ask about. Alice's seed goes via REST (`api_upload_file`
|
||||
# already targets her home). Bob's seed goes via NC PUT under his
|
||||
# Basic-Auth identity, since we want it owned by bob.
|
||||
ALICE_FIXTURE_DIR=$(mktemp -d)
|
||||
echo "alice's secret" > "$ALICE_FIXTURE_DIR/alice-secret.txt"
|
||||
api_upload_file "$ALICE_FIXTURE_DIR/alice-secret.txt" "$HOME_FOLDER_ID"
|
||||
|
||||
curl -s -u "$BOB_USERNAME:$BOB_APP_PASS" -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary 'bob secret' \
|
||||
"$base_url/remote.php/dav/files/$BOB_USERNAME/bob-secret.txt" > /dev/null
|
||||
|
||||
trap 'rm -rf "$ALICE_FIXTURE_DIR"; \
|
||||
curl -s -X DELETE -H "Authorization: Bearer $BOB_JWT" \
|
||||
"$base_url/api/auth/app-passwords/$BOB_APP_PASS_ID" >/dev/null 2>&1 || true; \
|
||||
wipe_home_folder 2>/dev/null || true' EXIT
|
||||
|
||||
NC_FILES_ALICE="$base_url/remote.php/dav/files/$ALICE_USERNAME"
|
||||
NC_FILES_BOB="$base_url/remote.php/dav/files/$BOB_USERNAME"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# O1 — PROPFIND bob's home while auth'd as alice → 403
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " O1: alice PROPFINDs /dav/files/bob/ → 403"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$ALICE_USERNAME:$ALICE_APP_PASS" \
|
||||
-X PROPFIND -H "Depth: 0" "$NC_FILES_BOB/")
|
||||
case "$STATUS" in
|
||||
403|401)
|
||||
pass "O1: cross-user PROPFIND rejected ($STATUS)"
|
||||
;;
|
||||
*)
|
||||
fail "O1: cross-user PROPFIND should be 403/401, got $STATUS — alice may be reading bob's home!"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# O2 — Path traversal in URL → 400
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " O2: path traversal /dav/files/$ALICE_USERNAME/../$BOB_USERNAME/bob-secret.txt → 400"
|
||||
# Send the traversal in raw form (curl --path-as-is keeps `..` instead
|
||||
# of letting curl normalise the URL client-side).
|
||||
STATUS=$(curl -s --path-as-is -o /dev/null -w "%{http_code}" \
|
||||
-u "$ALICE_USERNAME:$ALICE_APP_PASS" \
|
||||
-X PROPFIND -H "Depth: 0" \
|
||||
"$NC_FILES_ALICE/../$BOB_USERNAME/bob-secret.txt")
|
||||
case "$STATUS" in
|
||||
400|403)
|
||||
pass "O2: path traversal rejected ($STATUS)"
|
||||
;;
|
||||
*)
|
||||
fail "O2: path-traversal expected 400/403, got $STATUS"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# O3 — MOVE alice's file → bob's home → reject
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " O3: alice MOVEs alice-secret.txt → bob's home → rejected"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$ALICE_USERNAME:$ALICE_APP_PASS" \
|
||||
-X MOVE \
|
||||
-H "Destination: $NC_FILES_BOB/alice-secret-stolen.txt" \
|
||||
"$NC_FILES_ALICE/alice-secret.txt")
|
||||
case "$STATUS" in
|
||||
403|401|400)
|
||||
pass "O3: cross-user MOVE rejected ($STATUS)"
|
||||
;;
|
||||
201|204)
|
||||
# Confirm the destination actually landed in bob's home before
|
||||
# we call this a security failure — the request might have
|
||||
# legitimately failed silently elsewhere.
|
||||
DST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$BOB_USERNAME:$BOB_APP_PASS" \
|
||||
-X PROPFIND -H "Depth: 0" "$NC_FILES_BOB/alice-secret-stolen.txt")
|
||||
if [[ "$DST_STATUS" == "207" ]]; then
|
||||
fail "O3: SECURITY REGRESSION — alice's file was successfully moved into bob's home (status $STATUS, dest PROPFIND $DST_STATUS)"
|
||||
else
|
||||
pass "O3: MOVE returned $STATUS but destination is NOT in bob's home — effectively rejected"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "O3: unexpected status $STATUS — cross-user MOVE should be rejected"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# O4 — Alice's home PROPFIND must contain alice-secret.txt,
|
||||
# must NOT contain bob-secret.txt
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " O4: alice's home PROPFIND contains alice-secret.txt only (not bob-secret)"
|
||||
BODY=$(curl -s -u "$ALICE_USERNAME:$ALICE_APP_PASS" \
|
||||
-X PROPFIND -H "Depth: 1" "$NC_FILES_ALICE/")
|
||||
grep -q 'alice-secret.txt' <<< "$BODY" \
|
||||
|| fail "O4: alice-secret.txt missing from alice's home PROPFIND"
|
||||
if grep -q 'bob-secret.txt' <<< "$BODY"; then
|
||||
fail "O4: SECURITY REGRESSION — bob-secret.txt leaked into alice's home PROPFIND"
|
||||
fi
|
||||
pass "O4: alice's home contains only her own file; no bob leakage"
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed."
|
||||
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
|
||||
Reference in New Issue
Block a user