From 570e8d4f3051459ca0073d68d6062f4807b00b43 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 28 Apr 2026 08:50:47 +0200 Subject: [PATCH 1/3] fix ethid profile syncing --- src/siwe_django/ethid.py | 18 +++++---- src/siwe_django/services.py | 19 +++++++--- tests/test_enrichment.py | 76 ++++++++++++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 15 deletions(-) diff --git a/src/siwe_django/ethid.py b/src/siwe_django/ethid.py index 33d6606..c995d1b 100644 --- a/src/siwe_django/ethid.py +++ b/src/siwe_django/ethid.py @@ -67,6 +67,14 @@ def _fetch_json(path: str, *, fresh: bool | None = None) -> dict[str, Any]: return data if isinstance(data, dict) else {} +def _fetch_profile_part(path: str, *, fresh: bool | None = None) -> dict[str, Any]: + try: + return _fetch_json(path, fresh=fresh) + except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError): + logger.exception("EthID profile part lookup failed for %s.", path) + return {} + + def _as_int(value: Any) -> int: try: return int(value or 0) @@ -128,13 +136,9 @@ def fetch_ethid_profile( address_or_name: str, *, fresh: bool | None = None ) -> EthIDProfile: encoded = quote(address_or_name, safe="") - try: - simple = _fetch_json(f"users/{encoded}/simple-profile", fresh=fresh) - details = _fetch_json(f"users/{encoded}/details", fresh=fresh) - ens = _fetch_json(f"users/{encoded}/ens", fresh=fresh) - except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError): - logger.exception("EthID profile lookup failed for %s.", address_or_name) - return EthIDProfile() + simple = _fetch_profile_part(f"users/{encoded}/simple-profile", fresh=fresh) + details = _fetch_profile_part(f"users/{encoded}/details", fresh=fresh) + ens = _fetch_profile_part(f"users/{encoded}/ens", fresh=fresh) return _merge_profile(simple, details, ens) diff --git a/src/siwe_django/services.py b/src/siwe_django/services.py index da52344..f3616d3 100644 --- a/src/siwe_django/services.py +++ b/src/siwe_django/services.py @@ -229,7 +229,8 @@ def _user_factory(): def _update_user_identity_fields(user, identity: SiweIdentity) -> None: changed = [] - for field, value in ( + has_identity_profile = bool(identity.identity_profile) + fields = [ ("ethereum_address", identity.address), ("chain_id", identity.chain_id), ("ens_name", identity.ens_name), @@ -241,12 +242,18 @@ def _update_user_identity_fields(user, identity: SiweIdentity) -> None: ("identity_avatar", identity.identity_avatar), ("identity_url", identity.identity_url), ("identity_profile", identity.identity_profile or {}), - ("followers_count", identity.followers_count), - ("following_count", identity.following_count), - ): + ] + if has_identity_profile: + fields.extend( + [ + ("followers_count", identity.followers_count), + ("following_count", identity.following_count), + ] + ) + for field, value in fields: if ( hasattr(user, field) - and value not in ("", None) + and value not in ("", None, {}) and getattr(user, field) != value ): setattr(user, field, value) @@ -446,7 +453,7 @@ def eth_identity_kit_nonce_payload(nonce: SiweNonce) -> dict[str, Any]: def get_public_identity_profile( address_or_name: str, *, fresh: bool | None = None ) -> dict[str, Any]: - if not (get_setting("ETHID_ENABLED") or get_setting("ETHID_PROFILE_PROXY_ENABLED")): + if not get_setting("ETHID_PROFILE_PROXY_ENABLED"): raise SiweAuthError("EthID profile lookups are disabled.", status_code=404) profile = fetch_ethid_profile(address_or_name, fresh=fresh) if profile.is_empty: diff --git a/tests/test_enrichment.py b/tests/test_enrichment.py index e4e5f88..77e9141 100644 --- a/tests/test_enrichment.py +++ b/tests/test_enrichment.py @@ -1,10 +1,13 @@ +from urllib.error import HTTPError + import pytest from django.test import override_settings from siwe_django.ens import ENSProfile -from siwe_django.ethid import EthIDProfile +from siwe_django.ethid import EthIDProfile, fetch_ethid_profile from siwe_django.gates import sync_wallet_groups -from siwe_django.models import SiweWallet +from siwe_django.models import EthereumUser, SiweWallet +from siwe_django.services import SiweIdentity, _update_user_identity_fields from .helpers import post_json, signed_payload @@ -151,3 +154,72 @@ def test_public_profile_endpoint_uses_ethid(client, mocker): assert response.json()["profile"]["displayName"] == "vitalik.eth" assert response.json()["profile"]["ens"]["records"] == {"name": "Vitalik"} mock.assert_called_once_with("vitalik.eth", fresh=True) + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "ETHID_ENABLED": True, + "ETHID_PROFILE_PROXY_ENABLED": False, + } +) +def test_public_profile_endpoint_respects_proxy_setting(client, mocker): + mock = mocker.patch("siwe_django.services.fetch_ethid_profile") + + response = client.get("/siwe/profile/vitalik.eth/") + + assert response.status_code == 404 + assert response.json()["error"] == "siwe_error" + mock.assert_not_called() + + +def test_ethid_profile_keeps_partial_data_when_endpoint_404s(mocker): + def fake_fetch(path, *, fresh=None): + if path.endswith("/details"): + raise HTTPError( + url="https://example.com/details", + code=404, + msg="Not Found", + hdrs=None, + fp=None, + ) + if path.endswith("/simple-profile"): + return { + "address": "0x0000000000000000000000000000000000000001", + "display_name": "alice.eth", + "avatar": "https://example.com/avatar.png", + "followers_count": 12, + } + return {"ens": {"name": "alice.eth", "records": {"com.github": "alice"}}} + + mocker.patch("siwe_django.ethid._fetch_json", side_effect=fake_fetch) + + profile = fetch_ethid_profile("alice.eth") + + assert profile.address == "0x0000000000000000000000000000000000000001" + assert profile.display_name == "alice.eth" + assert profile.followers_count == 12 + assert profile.ens_records == {"com.github": "alice"} + assert profile.raw["details"] == {} + + +@pytest.mark.django_db +def test_user_identity_counts_survive_missing_profile_payload(): + user = EthereumUser.objects.create_user( + ethereum_address="0x0000000000000000000000000000000000000001", + identity_profile={"simple_profile": {"display_name": "alice.eth"}}, + followers_count=25, + following_count=7, + ) + identity = SiweIdentity( + address="0x0000000000000000000000000000000000000001", + chain_id=1, + caip10="eip155:1:0x0000000000000000000000000000000000000001", + ) + + _update_user_identity_fields(user, identity) + + user.refresh_from_db() + assert user.identity_profile == {"simple_profile": {"display_name": "alice.eth"}} + assert user.followers_count == 25 + assert user.following_count == 7 From 6940beb30e17da9865a2f9a7c77a0ea4e7cd0238 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 28 Apr 2026 08:51:51 +0200 Subject: [PATCH 2/3] harden request security --- src/siwe_django/drf/views.py | 4 ++++ src/siwe_django/settings.py | 1 + src/siwe_django/views.py | 2 +- tests/test_drf.py | 39 ++++++++++++++++++++++++++++++++++++ tests/test_policy.py | 34 +++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/siwe_django/drf/views.py b/src/siwe_django/drf/views.py index 0a2da3d..de4fe5a 100644 --- a/src/siwe_django/drf/views.py +++ b/src/siwe_django/drf/views.py @@ -2,6 +2,8 @@ from django.contrib.auth import login as auth_login from django.contrib.auth import logout as auth_logout +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -32,6 +34,7 @@ def _error(error: SiweAuthError) -> Response: ) +@method_decorator(ensure_csrf_cookie, name="dispatch") class NonceView(APIView): authentication_classes = [] permission_classes = [] @@ -50,6 +53,7 @@ def get(self, request): ) +@method_decorator(csrf_protect, name="dispatch") class VerifyView(APIView): authentication_classes = [] permission_classes = [] diff --git a/src/siwe_django/settings.py b/src/siwe_django/settings.py index 961643d..ad78ca1 100644 --- a/src/siwe_django/settings.py +++ b/src/siwe_django/settings.py @@ -22,6 +22,7 @@ "AUTO_CREATE_USERS": True, "USER_FACTORY": "siwe_django.services.default_user_factory", "RATE_LIMITS": {}, + "RATE_LIMIT_TRUST_X_FORWARDED_FOR": False, "TOKEN_GATES": [], "SYNC_TOKEN_GATES_ON_LOGIN": True, } diff --git a/src/siwe_django/views.py b/src/siwe_django/views.py index 83f5b50..9751939 100644 --- a/src/siwe_django/views.py +++ b/src/siwe_django/views.py @@ -48,7 +48,7 @@ def _error_response(error: SiweAuthError) -> JsonResponse: def _client_ip(request: HttpRequest) -> str: forwarded = request.META.get("HTTP_X_FORWARDED_FOR") - if forwarded: + if forwarded and get_setting("RATE_LIMIT_TRUST_X_FORWARDED_FOR"): return forwarded.split(",", 1)[0].strip() return request.META.get("REMOTE_ADDR", "") diff --git a/tests/test_drf.py b/tests/test_drf.py index cc36fc4..0186a0f 100644 --- a/tests/test_drf.py +++ b/tests/test_drf.py @@ -27,6 +27,45 @@ def test_drf_nonce_and_verify(): assert SiweWallet.objects.count() == 1 +@pytest.mark.django_db +def test_drf_verify_enforces_csrf(): + client = APIClient(enforce_csrf_checks=True) + nonce = client.get("/siwe-drf/nonce/").json()["nonce"] + + from eth_account import Account + + account = Account.create() + message = build_message(account, nonce) + response = client.post( + "/siwe-drf/verify/", + {"message": message, "signature": sign_message(account, message)}, + format="json", + ) + + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_drf_verify_accepts_csrf_token_from_nonce(): + client = APIClient(enforce_csrf_checks=True) + nonce = client.get("/siwe-drf/nonce/").json()["nonce"] + csrf_token = client.cookies["csrftoken"].value + + from eth_account import Account + + account = Account.create() + message = build_message(account, nonce) + response = client.post( + "/siwe-drf/verify/", + {"message": message, "signature": sign_message(account, message)}, + format="json", + HTTP_X_CSRFTOKEN=csrf_token, + ) + + assert response.status_code == 200 + assert response.json()["wallet"]["address"] == account.address + + @pytest.mark.django_db def test_drf_me_requires_authentication(): client = APIClient() diff --git a/tests/test_policy.py b/tests/test_policy.py index d505f95..a8a1625 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -94,3 +94,37 @@ def test_nonce_rate_limit(client): assert response.status_code == 429 assert response.json()["error"] == "rate_limited" + + +@pytest.mark.django_db +@override_settings(SIWE_DJANGO={"RATE_LIMITS": {"nonce": "1/m"}}) +def test_nonce_rate_limit_ignores_x_forwarded_for_by_default(client): + cache.clear() + + assert ( + client.get("/siwe/nonce/", HTTP_X_FORWARDED_FOR="203.0.113.10").status_code + == 200 + ) + response = client.get("/siwe/nonce/", HTTP_X_FORWARDED_FOR="203.0.113.11") + + assert response.status_code == 429 + assert response.json()["error"] == "rate_limited" + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "RATE_LIMITS": {"nonce": "1/m"}, + "RATE_LIMIT_TRUST_X_FORWARDED_FOR": True, + } +) +def test_nonce_rate_limit_can_trust_x_forwarded_for(client): + cache.clear() + + assert ( + client.get("/siwe/nonce/", HTTP_X_FORWARDED_FOR="203.0.113.10").status_code + == 200 + ) + response = client.get("/siwe/nonce/", HTTP_X_FORWARDED_FOR="203.0.113.11") + + assert response.status_code == 200 From 7782ed3e7187bea52df1ebc4a56d4b3dfaff753e Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 28 Apr 2026 08:52:52 +0200 Subject: [PATCH 3/3] fix default user mapping and docs --- README.md | 3 ++- src/siwe_django/services.py | 2 +- tests/test_views.py | 26 ++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3817b06..e47cd5a 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ const { handleSignIn } = useSiwe({ const data = await response.json(); return data.nonce; }, - verifySignature: async (message, signature) => { + verifySignature: async (message, _nonce, signature) => { const response = await fetch("/auth/siwe/verify/", { method: "POST", credentials: "include", @@ -208,6 +208,7 @@ All settings live under `SIWE_DJANGO`. | `AUTO_CREATE_USERS` | `True` | Create a user when a new wallet signs in. | | `USER_FACTORY` | built-in | Dotted path for custom user creation. | | `RATE_LIMITS` | `{}` | Optional per-view limits like `{ "verify": "5/m" }`. | +| `RATE_LIMIT_TRUST_X_FORWARDED_FOR` | `False` | Use the first `X-Forwarded-For` address for rate limits. Enable only behind a trusted proxy that strips client-supplied forwarding headers. | | `TOKEN_GATES` | `[]` | Optional group sync gates. | | `SYNC_TOKEN_GATES_ON_LOGIN` | `True` | Sync token gates after login/linking. | diff --git a/src/siwe_django/services.py b/src/siwe_django/services.py index f3616d3..13e3329 100644 --- a/src/siwe_django/services.py +++ b/src/siwe_django/services.py @@ -210,7 +210,7 @@ def default_user_factory(identity: SiweIdentity, request=None): if username_field == "email": value = f"{identity.address.lower()}@ethereum.local" else: - value = f"siwe_{identity.chain_id}_{identity.address[2:].lower()}" + value = f"siwe_{identity.address[2:].lower()}" field = UserModel._meta.get_field(username_field) max_length = getattr(field, "max_length", None) diff --git a/tests/test_views.py b/tests/test_views.py index 5ff9f1b..109ee4f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -32,6 +32,32 @@ def test_verify_logs_in_and_creates_user_wallet(client, django_user_model): assert me.json()["wallet"]["caip10"].startswith("eip155:1:") +@pytest.mark.django_db +def test_default_user_factory_reuses_user_across_chains(client, django_user_model): + account = Account.create() + + first = signed_payload(client, account, chain_id=1) + first_response = post_json( + client, + "/siwe/verify/", + {"message": first["message"], "signature": first["signature"]}, + ) + second = signed_payload(client, account, chain_id=11155111) + second_response = post_json( + client, + "/siwe/verify/", + {"message": second["message"], "signature": second["signature"]}, + ) + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + assert django_user_model.objects.count() == 1 + user = django_user_model.objects.get() + assert user.username == f"siwe_{account.address[2:].lower()}" + assert SiweWallet.objects.count() == 2 + assert set(SiweWallet.objects.values_list("user_id", flat=True)) == {user.pk} + + @pytest.mark.django_db def test_nonce_response_includes_eth_identity_kit_metadata(client): response = client.get("/siwe/nonce/")