๐ Security view
Security findings, each cited to the code it comes from ยท Security Engineer, CISO, Pen Tester
โก Agent trace ยท 13 steps ยท $0.0404 ยท click to expand
search_claims(query='authentication authorization login', category='security') โ
search_claims(query='password secret credential token', category='security') โ
search_claims(query='SQL injection input validation', category='security') โ
search_claims(category='security', query='security') โ
search_claims(category='security', query='vulnerability exploit risk') โ
search_claims(query='password', max_results=30) โ
search_claims(query='authentication', max_results=30) โ
search_claims(query='injection', max_results=30) โ
search_claims(max_results=30, query='JWT token expire secret key') โ
search_claims(max_results=30, query='CORS origin middleware') โ
search_claims(max_results=30, query='superuser permission dependency') โ
read_source_file(file_path='backend/app/core/security.py', start_line=1) โ
read_source_file(file_path='backend/app/api/deps.py', start_line=1) โ
read_source_file(file_path='backend/app/core/config.py', start_line=1) โ
read_source_file(end_line=130, file_path='backend/app/core/config.py') โ
read_source_file(end_line=130, file_path='backend/app/api/routes/login.py') โ
read_source_file(end_line=170, file_path='backend/app/api/routes/users.py') โ
read_source_file(end_line=260, file_path='backend/app/api/routes/users.py') โ
read_source_file(end_line=130, file_path='backend/app/utils.py') โ
read_source_file(end_line=330, file_path='backend/app/api/routes/users.py') โ
read_source_file(end_line=60, file_path='backend/app/api/routes/private.py') โ
read_source_file(end_line=60, file_path='backend/app/main.py') โ
get_dependencies(file_path='backend/app/api/routes/private.py') โ
read_source_file(end_line=120, file_path='backend/app/models.py') โ
read_source_file(end_line=60, file_path='backend/app/api/main.py') โ
get_dependencies(file_path='backend/app/api/main.py') โ
read_source_file(end_line=60, file_path='frontend/src/hooks/useAuth.ts') โ
read_source_file(end_line=40, file_path='frontend/src/main.tsx') โ
read_source_file(end_line=60, file_path='backend/app/api/routes/utils.py') โ
read_source_file(end_line=200, file_path='backend/app/api/routes/items.py') โ
read_source_file(end_line=90, file_path='backend/app/crud.py')
Security Review
HIGH โ Exploitable Issues
1. Unauthenticated user-creation endpoint exposed in local environments
File: backend/app/api/routes/private.py:23-38, mounted at backend/app/api/main.py:13-14
The POST /api/v1/private/users/ endpoint creates a user with no authentication dependency whatsoever โ no CurrentUser, no get_current_active_superuser, no token check. It accepts arbitrary email, password, full_name and creates an active user (is_active defaults to True via UserBase).
Conditions for exploit: The router is only mounted when settings.ENVIRONMENT == "local" (main.py:13). In a local/staging deployment where ENVIRONMENT is misconfigured to "local" but the app is network-reachable, anyone can create accounts. More importantly, this is a latent footgun: the gating is a single environment string, and the endpoint itself has zero defense-in-depth. If an operator deploys with ENVIRONMENT=local (e.g., for debugging) against a reachable host, it's a full open-signup backdoor that bypasses the normal /signup flow's constraints.
Evidence: private.py:23-38 (no Depends), main.py:13-14 (conditional include), models.py:16 (is_active: bool = True default).
Fix: Add dependencies=[Depends(get_current_active_superuser)] to the route regardless of environment, and/or gate it behind an explicit feature flag rather than the generic ENVIRONMENT value. Never ship an unauthenticated write endpoint.
MEDIUM โ Real Risks Worth Fixing
2. JWT sub claim is a raw UUID string with no token-type/audience separation
Files: backend/app/core/security.py:22-26, backend/app/api/deps.py:30-46, backend/app/utils.py:103-113
Access tokens and password-reset tokens are both HS256 JWTs signed with the same SECRET_KEY and the same algorithm, and both put a string in sub (user UUID for access tokens, email for reset tokens). get_current_user (deps.py:32-41) decodes any validly-signed token and treats sub as a user ID via session.get(User, token_data.sub).
Risk: There is no type/aud/purpose claim distinguishing an access token from a password-reset token. A password-reset token (which contains an email in sub) is signed with the same key. If it were ever presented to an authenticated endpoint, session.get(User, <email>) would simply return None โ 404, so it is not directly exploitable today. However, the shared key + shared algorithm + no audience separation is a fragile design: any future endpoint that decodes a token and trusts sub as an identifier (e.g., a "reset my password" endpoint that reads sub as the target) would become a privilege-escalation vector. This is a real design weakness worth fixing before it becomes exploitable.
Evidence: security.py:22-26 (access token, sub=subject), utils.py:108-112 (reset token, sub=email), both use settings.SECRET_KEY + HS256.
Fix: Add a distinct type claim (e.g., "type": "access" vs "type": "reset") and validate it in get_current_user; or use a separate signing key for reset tokens. Also consider adding aud claims.
3. Access token lifetime is 8 days with no revocation mechanism
File: backend/app/core/config.py:36, backend/app/api/routes/login.py:37-41
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 8 (8 days). Tokens are stateless JWTs; there is no server-side session store, no token versioning, and no revocation on password change or account deletion. update_password_me (users.py:117-120) changes the hash but does not invalidate outstanding tokens.
Risk: A stolen token remains valid for up to 8 days even after the user changes their password or is deactivated. get_current_user checks user.is_active on every request (deps.py:44-45), so deactivation does block tokens โ but password change does not. For a template app this is a moderate real risk, especially combined with localStorage token storage (see #4).
Evidence: config.py:36, users.py:117-120 (no token invalidation on password change).
Fix: Reduce the default lifetime, and/or add a password_changed_at timestamp to the user and reject tokens issued before it (compare iat claim).
4. Access token stored in localStorage (XSS-exfiltratable)
Files: frontend/src/hooks/useAuth.ts:45, frontend/src/main.tsx:17-19
The JWT is persisted in localStorage and read back via OpenAPI.TOKEN. localStorage is accessible to any JavaScript running on the origin, so any XSS (e.g., a compromised dependency, a rendered user-supplied string) can exfiltrate the token. The app renders user-controlled content (full names, item titles) โ if any is ever injected unsanitized, the 8-day token is immediately stealable.
Risk: Real but conditional on an XSS primitive existing. This is the standard tradeoff for SPA auth; still worth flagging given the 8-day token lifetime amplifies impact.
Evidence: useAuth.ts:45 (localStorage.setItem), main.tsx:17-19 (OpenAPI.TOKEN reads localStorage).
Fix: Prefer HttpOnly + Secure cookies for the token, or at minimum shorten token lifetime and add a Content-Security-Policy that restricts script sources.
LOW โ Informational
5. Password-recovery endpoint returns a generic message but timing still leaks user existence
File: backend/app/api/routes/login.py:53-61 and crud.py:45-60
The recovery endpoint intentionally returns the same response whether or not the user exists (comment at login.py:60), and authenticate uses a DUMMY_HASH to equalize timing (crud.py:40-50). However, the recovery path (recover_password) does not run a dummy hash โ it only calls crud.get_user_by_email and branches on existence. The email-sending branch will take measurably longer than the no-op branch, so a remote attacker can still enumerate valid emails via response-time analysis on /password-recovery/{email}.
Evidence: login.py:58-61 (branch on user existence, no dummy work), contrast with crud.py:47-51 (dummy hash in authenticate).
Fix: Apply the same dummy-work pattern in recover_password (e.g., always generate and "send" a dummy email payload when the user doesn't exist).
6. SECRET_KEY auto-generates a random value when unset
File: backend/app/core/config.py:34
SECRET_KEY: str = secrets.token_urlsafe(32) means if no .env provides it, a new random key is generated on every process start. This invalidates all outstanding JWTs on restart (users get logged out) and, more subtly, means multi-worker deployments that don't share a .env will each sign tokens with different keys โ causing intermittent auth failures. The _check_default_secret guard (config.py:97-106) only catches the literal string "changethis", not an unset key.
Risk: Availability/reliability issue more than a direct vuln, but a misconfigured deployment could silently rotate keys. Worth documenting.
Evidence: config.py:34, config.py:97-106.
Fix: Make SECRET_KEY required (no default) so a missing value fails fast at startup.
FALSE POSITIVES
7. "Private user creation endpoint is a security hole" โ partially false
The claim that /private/users/ is exploitable in production is not accurate as stated: it is only mounted when ENVIRONMENT == "local" (main.py:13-14), and _enforce_non_default_secrets (config.py:108-116) raises in non-local environments if secrets are default. So in a correctly-configured production deployment the endpoint does not exist. It is still a genuine latent risk (see #1) because the gating is fragile, but it is not currently exploitable in production.
8. "Password reset token could be used as an access token" โ not currently exploitable
Although reset tokens and access tokens share a key/algorithm (see #2), get_current_user does session.get(User, token_data.sub) where sub is an email string for reset tokens โ this returns None and yields a 404, not an authenticated session. The UUID-vs-email mismatch prevents direct reuse today. This is a design weakness, not a live exploit.
9. "Weak password policy" โ not a vulnerability
Passwords are enforced at min_length=8, max_length=128 in the Pydantic models (models.py:23,28,35,44-45) and mirrored in the frontend Zod schemas. There is no complexity requirement, but that is a policy choice, not a defect โ and the backend correctly hashes with Argon2id (primary) via pwdlib (security.py:11-16), with automatic hash re-verification/upgrade on login (crud.py:55-59). No issue.
10. "CORS allows all origins" โ false
main.py:24-31 sets allow_origins=settings.all_cors_origins, which is derived from BACKEND_CORS_ORIGINS + FRONTEND_HOST (config.py:46-49), not ["*"]. allow_credentials=True is set, but origins are explicitly enumerated, so this is correctly locked down. Not a wildcard.
Summary for the CISO
The codebase (full-stack FastAPI + React template) has solid baseline security: Argon2id password hashing with automatic upgrade, timing-attack mitigation on login, superuser gating on admin endpoints, owner checks on item access, secret validation that rejects "changethis" in non-local environments, and email-enumeration-resistant login responses.
The one genuinely dangerous item is the unauthenticated /private/users/ endpoint (#1) โ currently gated behind ENVIRONMENT == "local" but with zero in-route authentication, making it a single misconfiguration away from being an open account-creation backdoor. The most important architectural weakness is the shared JWT secret/algorithm across access and reset tokens with no purpose separation (#2), which is a latent privilege-escalation design flaw. The 8-day token lifetime + localStorage storage (#3, #4) is the highest-impact practical risk if any XSS is ever introduced.