diff --git a/backend/services/auth.py b/backend/services/auth.py index bf1e26e..57c35f9 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -88,7 +88,7 @@ def _compute_fgp(user_agent: str, accept_lang: str) -> str: """Return 16-char hex fingerprint binding a token to its client context (D-04).""" return hmac.new( settings.secret_key.encode(), - (user_agent + accept_lang).encode(), + (user_agent + "\x00" + accept_lang).encode(), hashlib.sha256, ).hexdigest()[:16] diff --git a/backend/tests/test_auth_fgp.py b/backend/tests/test_auth_fgp.py index 7768c8b..6d46d0b 100644 --- a/backend/tests/test_auth_fgp.py +++ b/backend/tests/test_auth_fgp.py @@ -164,6 +164,32 @@ async def test_no_fgp_claim_allowed(auth_client, db_session): assert resp.status_code == 200 +@pytest.mark.asyncio +async def test_fgp_no_concatenation_collision(auth_client, db_session): + """FGP-05 regression: token bound to UA='foobar'+AL='' must not match UA='foo'+AL='bar'. + + Without a separator in the HMAC input, both produce the same bytes ('foobar'), + allowing a different header split to pass fingerprint validation. + """ + from services.auth import create_access_token + + user = await _create_user(db_session, role="user") + # Token issued for UA='foobar', no Accept-Language + token = create_access_token(str(user.id), "user", user_agent="foobar", accept_lang="") + + # Request arrives from a split: UA='foo', AL='bar' — same concatenation without sep + resp = await auth_client.get( + "/test/me", + headers={ + "Authorization": f"Bearer {token}", + "User-Agent": "foo", + "Accept-Language": "bar", + }, + ) + assert resp.status_code == 401 + assert resp.json()["detail"] == "Token fingerprint mismatch" + + @pytest.mark.asyncio async def test_missing_headers_empty_string_binding(auth_client, db_session): """FGP-04: token issued with empty-string binding, request with empty UA → 200.