fix(api): block cross-user delete and address review feedback (#824)

Review of PR #825 surfaced an auth bypass introduced by adding loginName
support to delete_app_password: with the OCS-resolved UID discarded, a user
could authenticate as their own loginName (via the request body) while
targeting another user's path and delete the victim's stored app password.
Add the same UID-mismatch guard provisioning already has, so the
authenticated account must own the path UID (403 otherwise).

Also:
- integration test: build the BasicAuth header via base64 instead of
  httpx.BasicAuth._auth_header (private attribute); mark the throwaway test
  credential NOSONAR(S2068).
- unit tests: cover the httpx.RequestError -> 502 branch, the standard OCS v2
  success shape (meta.statuscode 200), and the cross-user delete 403 guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-01 13:04:42 +02:00
co-authored by Claude Opus 4.8
parent a11a4e709c
commit 3c55d03ecf
3 changed files with 142 additions and 4 deletions
+14 -1
View File
@@ -495,7 +495,7 @@ async def delete_app_password(request: Request) -> JSONResponse:
status_code=500,
)
_, error_response = await _validate_nextcloud_credentials(
ocs_user_id, error_response = await _validate_nextcloud_credentials(
nextcloud_host, login_name, password
)
if error_response is not None:
@@ -507,6 +507,19 @@ async def delete_app_password(request: Request) -> JSONResponse:
)
return error_response
# The authenticated account must be the UID whose password is being deleted.
# ``_extract_basic_auth`` only checks the BasicAuth *name* field equals the
# path UID, not that the supplied credential authenticates as that account —
# without this guard a user could authenticate with their own loginName (via
# the body) while targeting another user's path and delete the victim's
# stored password.
if ocs_user_id != path_user_id:
logger.warning("User ID mismatch in OCS response for delete")
return JSONResponse(
{"success": False, "error": "User ID mismatch"},
status_code=403,
)
try:
storage = await _get_app_password_storage(request)
deleted = await storage.delete_app_password(username)