595273277b
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 │
└──────────┴─────────┴────────────────────────────────────────────────────────────────────────────────────────────────────┘
192 lines
9.3 KiB
Bash
Executable File
192 lines
9.3 KiB
Bash
Executable File
#!/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
|