feat(06-05): trusted-proxy get_client_ip, per-account rate limiter, promote 8 xfail tests (D-11/D-12/D-13)

- D-11: replace get_client_ip body with trusted-proxy CIDR check (127/8, 172.16/12,
  192.168/16, ::1/128); untrusted peers always return their own IP, preventing XFF
  spoofing by external callers
- D-12: create services/rate_limiting.py with _account_key() keyed by user.id
  (falls back to peer IP when no authenticated user in request.state); exports
  account_limiter = Limiter(key_func=_account_key)
- Wire: auth.py switches from get_remote_address to get_client_ip; main.py imports
  account_limiter; all 9 documents.py endpoints and 7 cloud.py endpoints decorated
  @account_limiter.limit("100/minute") with request.state.current_user = current_user
  as the first handler statement (A1 ordering invariant)
- Promote all 8 xfail stubs to real assertions; add autouse fixture in conftest.py
  to reset MemoryStorage between tests preventing cross-test 429 contamination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-04 18:27:49 +02:00
co-authored by Claude Sonnet 4.6
parent eaa3399ec0
commit a826738e18
8 changed files with 301 additions and 13 deletions
+19
View File
@@ -38,6 +38,7 @@ from db.models import CloudConnection, User
from deps.auth import get_regular_user
from deps.db import get_db
from services.audit import write_audit_log
from services.rate_limiting import account_limiter
from storage.cloud_utils import encrypt_credentials, decrypt_credentials, validate_cloud_url
# ── Router definitions ────────────────────────────────────────────────────────
@@ -312,6 +313,7 @@ async def _upsert_cloud_connection(
@router.get("/oauth/initiate/{provider}")
@account_limiter.limit("100/minute")
async def oauth_initiate(
provider: str,
request: Request,
@@ -331,6 +333,7 @@ async def oauth_initiate(
- Only google_drive and onedrive are accepted (T-05-05-06)
- Endpoint requires get_regular_user — no unauthenticated access (T-05-10-01)
"""
request.state.current_user = current_user
from fastapi.responses import JSONResponse # already available via fastapi
if provider not in VALID_OAUTH_PROVIDERS:
@@ -550,6 +553,7 @@ async def oauth_callback(
@router.post("/connections/webdav", status_code=status.HTTP_201_CREATED)
@account_limiter.limit("100/minute")
async def connect_webdav(
body: WebDAVConnectRequest,
request: Request,
@@ -566,6 +570,7 @@ async def connect_webdav(
- health_check() requires a successful PROPFIND before storing credentials
- credentials_enc never returned in response (CloudConnectionOut whitelist)
"""
request.state.current_user = current_user
if body.provider not in VALID_WEBDAV_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -641,7 +646,9 @@ async def connect_webdav(
@router.get("/connections")
@account_limiter.limit("100/minute")
async def list_connections(
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
@@ -651,6 +658,7 @@ async def list_connections(
- Only connections owned by current_user.id are returned
- credentials_enc excluded by CloudConnectionOut whitelist (T-05-05-03)
"""
request.state.current_user = current_user
result = await session.execute(
select(CloudConnection).where(CloudConnection.user_id == current_user.id)
)
@@ -674,7 +682,9 @@ async def list_connections(
@router.get("/connections/{connection_id}/config")
@account_limiter.limit("100/minute")
async def get_connection_config(
request: Request,
connection_id: uuid.UUID,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
@@ -693,6 +703,7 @@ async def get_connection_config(
- password is never included in the response (D-18)
- Returns 404 for wrong-owner connections (prevents ID enumeration)
"""
request.state.current_user = current_user
conn = await session.get(CloudConnection, connection_id)
if conn is None or conn.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
@@ -725,6 +736,7 @@ async def get_connection_config(
@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
@account_limiter.limit("100/minute")
async def delete_connection(
connection_id: uuid.UUID,
request: Request,
@@ -738,6 +750,7 @@ async def delete_connection(
On success: connection row is deleted, audit log written, cache invalidated.
"""
request.state.current_user = current_user
conn = await session.get(CloudConnection, connection_id)
# Return 404 for any access failure — prevents ID enumeration (T-05-05-04)
@@ -770,7 +783,9 @@ async def delete_connection(
@router.get("/folders/{provider}/{folder_id:path}")
@account_limiter.limit("100/minute")
async def list_cloud_folders(
request: Request,
provider: str,
folder_id: str,
session: AsyncSession = Depends(get_db),
@@ -784,6 +799,7 @@ async def list_cloud_folders(
Returns 404 if no active connection found (prevents enumeration).
"""
request.state.current_user = current_user
all_providers = VALID_OAUTH_PROVIDERS | VALID_WEBDAV_PROVIDERS
if provider not in all_providers:
raise HTTPException(
@@ -925,7 +941,9 @@ async def list_cloud_folders(
@users_router.patch("/me/default-storage")
@account_limiter.limit("100/minute")
async def update_default_storage(
request: Request,
body: DefaultStorageRequest,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
@@ -935,6 +953,7 @@ async def update_default_storage(
The backend value is stored as-is (validated by the frontend dropdown).
Returns the updated default_storage_backend value.
"""
request.state.current_user = current_user
user = await session.get(User, current_user.id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")