--- phase: 07.4-security-token-fingerprinting-token-binding-inserted reviewed: 2026-06-06T00:00:00Z depth: standard files_reviewed: 11 files_reviewed_list: - backend/api/auth.py - backend/deps/auth.py - backend/main.py - backend/services/auth.py - backend/tests/conftest.py - backend/tests/test_auth_deps.py - backend/tests/test_auth_fgp.py - backend/tests/test_cloud.py - backend/tests/test_documents.py - backend/tests/test_security_headers.py - frontend/package.json findings: critical: 3 warning: 3 info: 2 total: 8 status: issues_found --- # Phase 07.4: Code Review Report **Reviewed:** 2026-06-06T00:00:00Z **Depth:** standard **Files Reviewed:** 11 **Status:** issues_found ## Summary Phase 7.4 introduced token fingerprinting (the `fgp` JWT claim) binding access tokens to the requesting client's `User-Agent` and `Accept-Language` headers. The core mechanism is implemented correctly — HMAC-SHA256 with `hmac.compare_digest` for constant-time comparison — but three critical defects were found: the grace-period bypass condition uses truthiness (`if fgp_claim:`) rather than key presence, which conflates a legitimately-absent `fgp` key (pre-7.4 migration) with a deliberately empty `fgp` value in a crafted token; the `_compute_fgp` function uses `settings.secret_key` whose default is the literal string `"CHANGEME"`, meaning any deployment that does not set that env var exposes a predictable HMAC key; and the `authed_client` fixture in `test_auth_api.py` was not updated to send `_TEST_USER_AGENT`, creating a latent test fragility that depends on httpx's version-specific default header value. Two additional logic warnings concern the refresh endpoint discarding `remember_me` session type on every rotation and a redundant local import of `hashlib` inside `logout`. The `_compute_fgp` function is accessed across a module boundary via the `_`-prefixed name, which is a minor encapsulation violation. ## Narrative Findings (AI reviewer) ## Critical Issues ### CR-01: `fgp` bypass condition uses truthiness, not key presence — empty `fgp` in payload silently skips fingerprint validation **File:** `backend/deps/auth.py:92-93` **Issue:** The migration grace-period guard is written as: ```python fgp_claim = payload.get("fgp", "") if fgp_claim: ``` `payload.get("fgp", "")` returns `""` in two distinct situations: (a) the key is absent (pre-7.4 token — intended bypass), and (b) the key is present but set to `""`. The `if fgp_claim:` truthiness test treats both cases identically and skips validation for both. A token whose payload contains `"fgp": ""` will therefore pass through the fingerprint check regardless of the request's `User-Agent`. Because the JWT is ES256-signed the attacker must already possess the private key to exploit this — but the condition is still semantically wrong: it should express "key absent", not "empty string", and will cause subtle false passes if any code path ever produces a token with `fgp=""` (e.g., if `_compute_fgp` were to return an empty string in a future edge case). **Fix:** ```python # Correct: distinguish absent key from empty value fgp_claim = payload.get("fgp") # None when absent, str when present (even empty) if fgp_claim is not None: # pre-7.4 tokens have no fgp key — skip gracefully fgp_actual = auth_service._compute_fgp( request.headers.get("User-Agent", ""), request.headers.get("Accept-Language", ""), ) if not hmac.compare_digest(fgp_claim, fgp_actual): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token fingerprint mismatch", headers={"WWW-Authenticate": "Bearer"}, ) ``` --- ### CR-02: `_compute_fgp` uses `settings.secret_key` whose default is `"CHANGEME"` — predictable HMAC key in unpatched deployments **File:** `backend/services/auth.py:87-93`, `backend/config.py:31` **Issue:** The fingerprint HMAC key is `settings.secret_key`, which defaults to the literal string `"CHANGEME"` in `config.py`. Any deployment that does not explicitly set `SECRET_KEY` in its environment will use this known key. An attacker who knows the algorithm (`HMAC-SHA256`, `secret_key="CHANGEME"`, `user_agent + accept_lang`) can precompute valid `fgp` values for arbitrary header combinations and forge tokens that pass fingerprint validation — provided they can also forge ES256 signatures, which requires the private key. The immediate exploitability is blocked by ES256, but the defense-in-depth principle is violated: if the private key ever leaks, the predictable HMAC key adds zero friction. Additionally, `secret_key` is already used for other purposes (slowapi, unrelated HMAC operations) — the fgp HMAC key should be an independent secret or at minimum derived via HKDF with a distinct purpose label, consistent with the pattern used for cloud credentials (`cloud_creds_key` + HKDF). **Fix:** 1. Add a dedicated `fgp_hmac_key: str = ""` field to `Settings` with no default value that passes a non-empty check: ```python # config.py fgp_hmac_key: str = "" # must be set; absence is caught at startup ``` 2. Guard startup: if `fgp_hmac_key` is empty, raise `RuntimeError` so the app fails fast rather than silently using an insecure key. 3. Update `_compute_fgp` to use `settings.fgp_hmac_key`. 4. Alternatively, derive a sub-key via HKDF from `settings.secret_key` with `info=b"fgp"` — consistent with the cloud-creds pattern already in the codebase. --- ### CR-03: `authed_client` in `test_auth_api.py` sends no explicit `User-Agent` — fgp validation silently depends on httpx's version-default header **File:** `backend/tests/test_auth_api.py:118` **Issue:** The `authed_client` fixture creates its `AsyncClient` without a `headers=` argument: ```python async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: ``` httpx injects its own `User-Agent: python-httpx/X.Y.Z` header by default. The login endpoint (`POST /api/auth/login`) reads this header and embeds it in the `fgp` claim of the issued access token. Subsequent requests in the same test session re-use the same httpx default `User-Agent`, so the fgp check currently passes. However: - If `httpx` is upgraded and the default UA string changes between the login call and a token-use call within a single test session (unlikely but possible with per-request header overrides), tests will start failing with 401 "Token fingerprint mismatch" with no obvious cause. - More practically: any test that obtains a token via login and then sets explicit headers (including an `Authorization` header without also propagating `User-Agent`) will trigger a mismatch if the per-request headers override the client-level default. - The `async_client`, `headers_client`, and `auth_client` fixtures were all updated in Phase 7.4 to send `_TEST_USER_AGENT`. The `authed_client` fixture was not updated, creating an inconsistency that will be difficult to diagnose when a future test adds a per-request `User-Agent` override. **Fix:** ```python # test_auth_api.py, authed_client fixture (line 118) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test", headers={"User-Agent": _TEST_USER_AGENT}, # add this line ) as c: yield c ``` Also add the import at the top of `test_auth_api.py`: ```python from tests.conftest import _TEST_USER_AGENT ``` --- ## Warnings ### WR-01: `refresh_token` endpoint always sets `remember_me=False` on cookie — 30-day sessions silently downgraded to 16 hours on first rotation **File:** `backend/api/auth.py:367` **Issue:** On token rotation, `_set_refresh_cookie` is called without a `remember_me` argument: ```python _set_refresh_cookie(response, new_raw) # remember_me defaults to False ``` The `remember_me` state from the original login is not stored anywhere (it is not in the `RefreshToken` DB row, not in a cookie, not in the JWT). Therefore, a user who logged in with `remember_me=True` (30-day session) will have their cookie's `Max-Age` silently reset to 16 hours on the first token rotation. The refresh token in the DB retains its original 30-day `expires_at`, so the token remains valid server-side for 30 days, but the browser will discard the cookie after 16 hours — effectively capping sessions to 16 hours regardless of the `remember_me` choice. **Fix:** Add a `remember_me` boolean column to `RefreshToken` (default `False`) populated at creation time, then read it during rotation: ```python # services/auth.py — rotate_refresh_token new_raw = await create_refresh_token(session, row.user_id, remember_me=row.remember_me) return new_raw, str(row.user_id), row.remember_me # return the flag # api/auth.py — refresh_token endpoint new_raw, user_id_str, remember_me = await auth_service.rotate_refresh_token(session, raw_token) _set_refresh_cookie(response, new_raw, remember_me=remember_me) ``` --- ### WR-02: `_compute_fgp` (private by Python convention) is called across a module boundary via `auth_service._compute_fgp` **File:** `backend/deps/auth.py:94` **Issue:** `_compute_fgp` has a leading underscore indicating it is module-private. `deps/auth.py` reaches across the module boundary to call `auth_service._compute_fgp(...)`. While Python does not enforce this at runtime, it violates the convention that `_`-prefixed names are internal implementation details. If `_compute_fgp` is renamed or its signature changes, the linter will not flag the call site in `deps/auth.py` because the name is accessed via attribute lookup on the imported module object. This also prevents type-checkers and IDEs from warning on incorrect usage. **Fix:** Rename `_compute_fgp` to `compute_fgp` (drop the underscore) in `services/auth.py` and update all call sites. The function is already documented as part of the Phase 7.4 public API surface (it is cited in `D-04` and `D-05` design notes). --- ### WR-03: `logout` endpoint contains a redundant local `import hashlib` shadowing the module-level import **File:** `backend/api/auth.py:392` **Issue:** `hashlib` is already imported at the module level (line 22). Inside the `logout` handler, the code performs: ```python import hashlib as _hashlib ... token_hash = _hashlib.sha256(raw_token.encode()).hexdigest() ``` This local import is unnecessary and creates a confusing divergence from every other use of `hashlib` in the same file (which uses the module-level name). The local alias `_hashlib` serves no purpose since there is no name conflict. **Fix:** Remove the local import and use the module-level `hashlib` directly: ```python # Remove line 392: import hashlib as _hashlib token_hash = hashlib.sha256(raw_token.encode()).hexdigest() ``` --- ## Info ### IN-01: `frontend/package.json` uses `^` (caret) version ranges for security-adjacent dependencies **File:** `frontend/package.json` **Issue:** CLAUDE.md Security Protocol states: "Dependency pinning: `requirements.txt` and `package-lock.json` pin exact versions; no floating `>=` for security-critical packages." While `package-lock.json` pins exact versions at install time, the `package.json` itself uses caret ranges (`^`) for all dependencies including `vue`, `pinia`, `vue-router`, and `vite`. If `package-lock.json` is deleted and regenerated (e.g., after a `npm install --legacy-peer-deps` or CI environment without a lockfile), versions will float to the latest compatible release, potentially pulling in a dependency with a CVE. This is a minor risk in a lockfile-driven workflow but worth noting for the CLAUDE.md requirement. **Fix:** Consider pinning to exact versions in `package.json` for the packages most relevant to security (at minimum `vite` and `vue`), or document that `package-lock.json` must always be committed and never regenerated without review. --- ### IN-02: No test asserts that `fgp` with an empty-string value in the payload bypasses validation (the CR-01 gap has no dedicated test) **File:** `backend/tests/test_auth_fgp.py` **Issue:** FGP-03 tests the case where the `fgp` key is entirely absent from the payload — confirming the migration grace period works. There is no test for the case where `fgp` is present but set to `""`. After CR-01 is fixed (changing to `is not None`), this edge case should have an explicit test to prevent regression. **Fix:** Add a FGP-05 test after fixing CR-01: ```python @pytest.mark.asyncio async def test_fgp_empty_string_value_triggers_mismatch(auth_client, db_session): """FGP-05: token with fgp='' (empty string) must fail validation, not bypass it.""" user = await _create_user(db_session, role="user") now = datetime.now(timezone.utc) payload_empty_fgp = { "sub": str(user.id), "role": "user", "typ": "access", "iat": now, "exp": now + timedelta(minutes=15), "jti": str(_uuid.uuid4()), "fgp": "", # present but empty — must NOT bypass validation } private_pem = base64.b64decode(settings.jwt_private_key).decode() token = _jwt.encode(payload_empty_fgp, private_pem, algorithm="ES256") resp = await auth_client.get( "/test/me", headers={"Authorization": f"Bearer {token}", "User-Agent": "Mozilla/5.0"} ) assert resp.status_code == 401 ``` --- _Reviewed: 2026-06-06T00:00:00Z_ _Reviewer: Claude (gsd-code-reviewer)_ _Depth: standard_