From c77b97b6d3fad4c8aa0eae76500f39003cd61ca7 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 6 Jun 2026 23:03:48 +0200 Subject: [PATCH] fix(fgp): add null-byte separator in HMAC input to prevent concatenation collision Without a separator, UA='foobar'+AL='' and UA='foo'+AL='bar' produced the same HMAC input, allowing a token fingerprint to match a different header split. Fix: use '\x00' as separator between user_agent and accept_lang fields. Regression test added: test_fgp_no_concatenation_collision (FGP-05). Co-Authored-By: Claude Sonnet 4.6 --- backend/services/auth.py | 2 +- backend/tests/test_auth_fgp.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) 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.