Skip to content

Position lifecycle endpoints accept unauthenticated GET mutations: arbitrary position close #409

Description

@YaronZaki

Labels / Complexity: security, authz, api, Backend · Extremely High — 500

Problem

The position lifecycle in quantara/web_app/api/position.py is inconsistent about authentication. create_position_with_transaction_data and add_extra_deposit require Depends(verify_wallet_signature), but four state-changing or position-specific endpoints do not:

  • close_position (GET /api/close-position) closes a position and writes a Transaction row with no wallet check.
  • open_position (GET /api/open-position) writes an OutboxEvent that later flips a position to OPENED.
  • get_repay_data (GET /api/get-repay-data) and get_withdraw_data (GET /api/get-withdraw-all-data) return the position's contract_address, position_id, and token symbol for any caller who knows a wallet_id.

All four are also GET handlers that mutate state or return per-user financial data, so they are additionally exposed to CSRF, prefetch, and browser caching. Consequence: any caller who can guess or enumerate a position UUID (returned in the unauthenticated get_user_positions response for a known wallet_id) can close or re-open another user's position and read the contract address needed to craft repay/withdraw transactions. There is no ownership check tying position_id or wallet_id back to the authenticated caller anywhere in this module.

Root cause

@router.get("/api/close-position")
@limiter.limit(WRITE_LIMIT)
async def close_position(request: Request, position_id: UUID, transaction_hash: str) -> str:
    # ← no Depends(verify_wallet_signature), no ownership check against position.user
    position_status = position_db_connector.close_position(str(position_id))
    position_db_connector.save_transaction(position_id=position_id, status="closed", transaction_hash=transaction_hash)
    return position_status

Why this is architecturally hard

  1. A naive fix — adding Depends(verify_wallet_signature) — does not close the hole by itself, because that dependency only proves the caller controls some wallet; it does not prove that wallet owns the position. The correct fix must load the Position (quantara/web_app/db/models.py) and compare position.user_id to the authenticated wallet's User record.
  2. The mutation-via-GET problem cannot be papered over with auth alone; the endpoints must be migrated to POST (or the state change removed from the read path), which is a breaking change for the frontend callers in quantara/frontend/src/hooks/useClosePosition.js and useWithdrawAll.js, both of which call these routes with axiosInstance.get(...).
  3. verify_wallet_signature relies on the in-memory nonce store in quantara/web_app/api/wallet_auth.py, so tightening these endpoints also surfaces the multi-worker nonce question — a contributor must not silently assume the existing dependency is sufficient.
  4. The endpoints should ideally be reworked to the same APIError envelope used elsewhere (quantara/web_app/api/errors.py) so 401/403/404 responses are machine-readable, rather than the bare HTTPException calls used today.

Proposed design

New authorization table for the affected entry points:

Endpoint Method (after fix) Auth Ownership check
/api/close-position POST verify_wallet_signature position.user.wallet_id == authenticated wallet_id
/api/open-position POST verify_wallet_signature same as above
/api/get-repay-data POST verify_wallet_signature wallet_id from auth, not query
/api/get-withdraw-all-data POST verify_wallet_signature wallet_id from auth, not query

Prefer a reusable FastAPI dependency (e.g. Depends(require_position_owner)) rather than repeating the ownership query in each handler.

Downstream impact

The frontend call sites in quantara/frontend/src/hooks/useClosePosition.js and quantara/frontend/src/hooks/useWithdrawAll.js currently send GET requests with wallet_id/position_id in query params. They must switch to POST bodies and attach the auth headers built by getAuthHeaders() in quantara/frontend/src/utils/axios.js. This is an API contract change and must land with the frontend update in the same PR or a coordinated follow-up.

Acceptance criteria

Service

  • close_position and open_position reject unauthenticated requests (401) and reject a wallet that does not own the position (403).
  • get_repay_data and get_withdraw_data return repay data only to the authenticated owner of that wallet.
  • The four endpoints use POST and no longer accept state changes or per-user data over GET.

Tests

  • quantara/web_app/tests/test_positions.py (or an equivalent new file) covers: missing auth header, wrong-owner position, and the happy path for each of the four endpoints.
  • Frontend hooks updated, with vitest coverage for the new POST calls.

Documentation

  • OpenAPI docs reflect the new methods and 401/403 responses.

Out of scope

Do not refactor the broader auth model or the in-memory nonce store in this issue; only enforce ownership and method correctness on these four endpoints.

Getting started

Files in scope: quantara/web_app/api/position.py, quantara/web_app/db/crud/position.py, quantara/frontend/src/hooks/useClosePosition.js, quantara/frontend/src/hooks/useWithdrawAll.js. Verify with:

cd quantara && poetry run pytest web_app/tests
cd quantara/frontend && yarn test:run

Good first files to read: quantara/web_app/api/position.py, quantara/web_app/api/vault.py (for the auth pattern), quantara/web_app/api/errors.py.

Metadata

Metadata

Assignees

Labels

BackendGrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignapiImported from PRODUCTION_ISSUES.mdauthzImported from PRODUCTION_ISSUES.mdsecurity

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions