appsec: BOLA / authorization audit — backend FastAPI routers
Scope: backend/app/routers/* mounted in backend/app/main.py. Read-only review against the endpoint-authorization checklist (authN coverage, object-level authz / BOLA-IDOR, field-level / mass-assignment, pydantic validation). FuzeKeys stores secrets, credentials, and PII (identities), so a missing object-level check is high/critical severity.
Reviewer: appsec-reviewer (independent). Fix owner: backend-engineer. This issue does not implement fixes.
Note: governance/architecture-guidelines.md referenced by the review task is not present in the repo at master. Review was performed against the canonical endpoint-authorization checklist plus the reference auth model (per-resource ownership: resource.user_id == current_user.id). Recommend adding the guidelines doc so gate-authz has a declared contract.
Findings by severity
CRITICAL-1 — google_integration.py: no authN + BOLA on every route (cross-tenant PII disclosure and account creation)
File: backend/app/routers/google_integration.py (mounted at /api/google)
Every route takes identity_id (or returns account data) and looks it up by raw id with no get_current_user dependency and no ownership check:
POST /api/google/signup/{identity_id} — db.query(Identity).filter(Identity.id == identity_id) then creates a real Google account from that identity's PII. Any unauthenticated caller can drive signup using any user's identity.
GET /api/google/accounts/{identity_id} — returns account email, status, metadata for any identity (IDOR read).
POST /api/google/test/identity-conversion/{identity_id} — returns decrypted PII (first/last name, username, phone_number, recovery_email, birth_date, gender) for any identity. Unauthenticated PII exfiltration.
POST /api/google/signup/manual — unauthenticated; drives signup automation from request body.
Fix: add current_user: User = Depends(get_current_user) to every route, and scope every identity/account lookup to the caller, e.g. select(Identity).where(Identity.id == identity_id, Identity.user_id == current_user.id) (mirror accounts.py / identities.py). Return 404 on no-match (do not leak existence). Add a non-owner to 403/404 test.
CRITICAL-2 — llm_scraper.py: no authN on any route (public-by-default; destructive plus cost-bearing)
File: backend/app/routers/llm_scraper.py (mounted at /api/llm-scraper)
No route has any auth dependency. Notably:
POST /generate, POST /improve, POST /debug — unauthenticated, invoke the LLM with attacker-controlled input (cost/abuse, prompt-injection surface).
DELETE /scrapers/{site_name}/{action_type} — any unauthenticated caller can delete scrapers and all versions.
GET /scrapers, GET /scrapers/{...}, GET /scrapers/{...}/history, GET /stats — unauthenticated read of generated scraper source/metadata.
Fix: require the application JWT (Depends(get_current_user)) — or the service-key model used by credentials.py — on all mutating and read routes (a /health may stay open). Add per-route auth and an unauthenticated to 401 test. If scrapers are owned, add an ownership column plus scoped filter.
HIGH-1 — site_integrations.py: no authN on operational routes
File: backend/app/routers/site_integrations.py (mounted at /api/v1/integrations)
POST /signup, POST /signin, POST /apikey accept raw email plus password and drive browser automation against external sites (permit.io) unauthenticated. No internal resource id (so not strictly BOLA), but a missing-authN/abuse finding: anyone can drive credential-bearing automation and resource exhaustion. GET /sites, /sites/{site_name}/capabilities are lower risk but also open.
Fix: add Depends(get_current_user) to the signup/signin/apikey operations. Validation is present (pydantic plus EmailStr); keep it.
HIGH-2 — credentials.py: residual missing per-tenant scoping on get_identity_accounts (service-key trust gap)
File: backend/app/routers/credentials.py (mounted at /api/credentials)
This router was previously hardened: verify_api_key fails closed, the credential encryption key fails closed (503), and the IDOR fix on request_account_credentials / store_account_credentials / GET /account/{account_id}/credentials scopes the lookup by (Account.id == account_id) & (Account.identity_id == identity_id). However the model's own TODO is unresolved: a valid service key is trusted to act on behalf of any identity it names. GET /identity/{identity_id}/accounts does Identity.id == identity_id with no tenant binding — any holder of a service key can enumerate any identity's accounts (account ids, site names, has-stored-credentials flags).
Fix: introduce per-tenant/per-key scoping (map each service key to the identities/tenant it may act for) and verify identity_id is within that key's allowed scope on get_identity_accounts and the credential routes. Tracked here so it is not lost.
MEDIUM-1 — Unauthenticated WebSocket endpoints (documented residuals)
sms.py -> WS /api/sms/ws/sms-interceptor
infrastructure.py -> WS /api/infrastructure/ws/mobile-commands
Both join a broadcast group with no auth handshake. The code documents the residual (require X-Device-Key as the first frame, verify via _verify_device before joining). OTP values are intentionally not broadcast, which limits impact, but request notifications/commands still flow to any connected socket.
Fix: implement the documented first-frame device-key handshake before connect().
MEDIUM-2 — Weak default secrets / config (defense-in-depth)
auth.py: SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key") — the JWT signing key has an insecure default; with the default, tokens are forgeable, which undermines every get_current_user check. Fail closed (no default) outside dev.
main.py: CORS/TrustedHost are dev-only (localhost); ensure prod config is environment-driven before exposure.
(Hand secret-hygiene specifics to security if preferred; the authZ-relevant part is the forgeable-JWT risk that underpins all object-level checks.)
Routers reviewed and judged OK (gold-standard pattern in this repo)
accounts.py — every route Depends(get_current_user) plus scoped by Identity.user_id == current_user.id; create-path verifies identity ownership; stage update joins through to the owner. OK
identities.py — GET/PUT/DELETE by id all scoped (Identity.id == id) & (Identity.user_id == current_user.id); PII encrypted at rest; pydantic models. OK
automation.py — JWT-gated. OK
chat.py — JWT-gated; /signup scopes identity by owner. (Latent non-authz bug: select is used but not imported — out of appsec scope; flag to backend-engineer.) OK for authz
credentials.py — partially hardened; see HIGH-2 for the residual.
sms.py, infrastructure.py — device-key plus JWT split correctly applied; see MEDIUM-1 for the WS residual.
Not mounted (not live endpoints — do NOT wire without auth)
background.py, sites.py, sites_mock.py are present but not include_router-ed in main.py. The inline mock sites router in main.py (/api/v1/sites/*) serves static demo data (no DB, no PII) — acceptable as-is but should not be promoted to real data without auth.
Acceptance criteria for the fix (owner: backend-engineer)
google_integration.py: every route gated by get_current_user and every identity/account lookup scoped to the caller; non-owner gets 404. Test added.
llm_scraper.py: all mutating and read routes require auth; unauthenticated -> 401. DELETE additionally restricted. Test added.
site_integrations.py: signup/signin/apikey require auth. Test added.
credentials.py: get_identity_accounts (and the credential routes) enforce per-key/tenant scope. Test added.
- WS endpoints: device-key first-frame handshake. Test added.
auth.py: no insecure SECRET_KEY default outside dev.
- (Recommend) add
governance/architecture-guidelines.md declaring the authZ contract so gate-authz has a baseline.
appsec: BOLA / authorization audit — backend FastAPI routers
Scope:
backend/app/routers/*mounted inbackend/app/main.py. Read-only review against the endpoint-authorization checklist (authN coverage, object-level authz / BOLA-IDOR, field-level / mass-assignment, pydantic validation). FuzeKeys stores secrets, credentials, and PII (identities), so a missing object-level check is high/critical severity.Reviewer: appsec-reviewer (independent). Fix owner:
backend-engineer. This issue does not implement fixes.Findings by severity
CRITICAL-1 —
google_integration.py: no authN + BOLA on every route (cross-tenant PII disclosure and account creation)File:
backend/app/routers/google_integration.py(mounted at/api/google)Every route takes
identity_id(or returns account data) and looks it up by raw id with noget_current_userdependency and no ownership check:POST /api/google/signup/{identity_id}—db.query(Identity).filter(Identity.id == identity_id)then creates a real Google account from that identity's PII. Any unauthenticated caller can drive signup using any user's identity.GET /api/google/accounts/{identity_id}— returns accountemail,status,metadatafor any identity (IDOR read).POST /api/google/test/identity-conversion/{identity_id}— returns decrypted PII (first/last name, username, phone_number, recovery_email, birth_date, gender) for any identity. Unauthenticated PII exfiltration.POST /api/google/signup/manual— unauthenticated; drives signup automation from request body.Fix: add
current_user: User = Depends(get_current_user)to every route, and scope every identity/account lookup to the caller, e.g.select(Identity).where(Identity.id == identity_id, Identity.user_id == current_user.id)(mirroraccounts.py/identities.py). Return 404 on no-match (do not leak existence). Add a non-owner to 403/404 test.CRITICAL-2 —
llm_scraper.py: no authN on any route (public-by-default; destructive plus cost-bearing)File:
backend/app/routers/llm_scraper.py(mounted at/api/llm-scraper)No route has any auth dependency. Notably:
POST /generate,POST /improve,POST /debug— unauthenticated, invoke the LLM with attacker-controlled input (cost/abuse, prompt-injection surface).DELETE /scrapers/{site_name}/{action_type}— any unauthenticated caller can delete scrapers and all versions.GET /scrapers,GET /scrapers/{...},GET /scrapers/{...}/history,GET /stats— unauthenticated read of generated scraper source/metadata.Fix: require the application JWT (
Depends(get_current_user)) — or the service-key model used bycredentials.py— on all mutating and read routes (a/healthmay stay open). Add per-route auth and an unauthenticated to 401 test. If scrapers are owned, add an ownership column plus scoped filter.HIGH-1 —
site_integrations.py: no authN on operational routesFile:
backend/app/routers/site_integrations.py(mounted at/api/v1/integrations)POST /signup,POST /signin,POST /apikeyaccept rawemailpluspasswordand drive browser automation against external sites (permit.io) unauthenticated. No internal resource id (so not strictly BOLA), but a missing-authN/abuse finding: anyone can drive credential-bearing automation and resource exhaustion.GET /sites,/sites/{site_name}/capabilitiesare lower risk but also open.Fix: add
Depends(get_current_user)to the signup/signin/apikey operations. Validation is present (pydantic plusEmailStr); keep it.HIGH-2 —
credentials.py: residual missing per-tenant scoping onget_identity_accounts(service-key trust gap)File:
backend/app/routers/credentials.py(mounted at/api/credentials)This router was previously hardened:
verify_api_keyfails closed, the credential encryption key fails closed (503), and the IDOR fix onrequest_account_credentials/store_account_credentials/GET /account/{account_id}/credentialsscopes the lookup by(Account.id == account_id) & (Account.identity_id == identity_id). However the model's ownTODOis unresolved: a valid service key is trusted to act on behalf of any identity it names.GET /identity/{identity_id}/accountsdoesIdentity.id == identity_idwith no tenant binding — any holder of a service key can enumerate any identity's accounts (account ids, site names, has-stored-credentials flags).Fix: introduce per-tenant/per-key scoping (map each service key to the identities/tenant it may act for) and verify
identity_idis within that key's allowed scope onget_identity_accountsand the credential routes. Tracked here so it is not lost.MEDIUM-1 — Unauthenticated WebSocket endpoints (documented residuals)
sms.py->WS /api/sms/ws/sms-interceptorinfrastructure.py->WS /api/infrastructure/ws/mobile-commandsBoth join a broadcast group with no auth handshake. The code documents the residual (require
X-Device-Keyas the first frame, verify via_verify_devicebefore joining). OTP values are intentionally not broadcast, which limits impact, but request notifications/commands still flow to any connected socket.Fix: implement the documented first-frame device-key handshake before
connect().MEDIUM-2 — Weak default secrets / config (defense-in-depth)
auth.py:SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key")— the JWT signing key has an insecure default; with the default, tokens are forgeable, which undermines everyget_current_usercheck. Fail closed (no default) outside dev.main.py: CORS/TrustedHost are dev-only (localhost); ensure prod config is environment-driven before exposure.(Hand secret-hygiene specifics to
securityif preferred; the authZ-relevant part is the forgeable-JWT risk that underpins all object-level checks.)Routers reviewed and judged OK (gold-standard pattern in this repo)
accounts.py— every routeDepends(get_current_user)plus scoped byIdentity.user_id == current_user.id; create-path verifies identity ownership; stage update joins through to the owner. OKidentities.py— GET/PUT/DELETE by id all scoped(Identity.id == id) & (Identity.user_id == current_user.id); PII encrypted at rest; pydantic models. OKautomation.py— JWT-gated. OKchat.py— JWT-gated;/signupscopes identity by owner. (Latent non-authz bug:selectis used but not imported — out of appsec scope; flag to backend-engineer.) OK for authzcredentials.py— partially hardened; see HIGH-2 for the residual.sms.py,infrastructure.py— device-key plus JWT split correctly applied; see MEDIUM-1 for the WS residual.Not mounted (not live endpoints — do NOT wire without auth)
background.py,sites.py,sites_mock.pyare present but notinclude_router-ed inmain.py. The inline mock sites router inmain.py(/api/v1/sites/*) serves static demo data (no DB, no PII) — acceptable as-is but should not be promoted to real data without auth.Acceptance criteria for the fix (owner: backend-engineer)
google_integration.py: every route gated byget_current_userand every identity/account lookup scoped to the caller; non-owner gets 404. Test added.llm_scraper.py: all mutating and read routes require auth; unauthenticated -> 401.DELETEadditionally restricted. Test added.site_integrations.py: signup/signin/apikey require auth. Test added.credentials.py:get_identity_accounts(and the credential routes) enforce per-key/tenant scope. Test added.auth.py: no insecureSECRET_KEYdefault outside dev.governance/architecture-guidelines.mddeclaring the authZ contract sogate-authzhas a baseline.