"""SCIM/Navan runner builder SSRF guard. ``_build_runner`` constructs an authenticated SCIM client from an admin-entered ``base_url`` on the integration row. Since #956 added outbound SCIM group-push calls through this client, the builder must refuse to point it at IMDS / link-local / private addresses — a compromised or mistaken admin config must not be able to make Helix's authed client probe the internal network. This mirrors the guard the huilianyi builder already applies. """ from __future__ import annotations import uuid import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException from app.domains.identity_sync.models import IdentitySyncTarget from app.domains.identity_sync.service import IdentitySyncService from app.integrations.models import Integration pytestmark = pytest.mark.asyncio @pytest.fixture(autouse=True) def _pin_non_development_env(monkeypatch): """Pin ``APP_ENV`` so this security test does not depend on the runner's ``.env``. ``_build_runner`` skips the SSRF guard entirely for loopback hosts when ``settings.APP_ENV == "development"`` (``development_loopback_tls_exception`` — a deliberate local-dev affordance). CI sets ``APP_ENV=test`` so all four cases ran there, while a developer's ``.env`` sets ``development`` and the ``127.0.0.1`` case silently stopped asserting anything — the worst failure mode for a security regression test, and how this coupling went unnoticed (found 2026-08-06). The bypass itself is verified separately below. """ from app.core.config import settings monkeypatch.setattr(settings, "APP_ENV", "production", raising=False) async def _make_scim_target( db: AsyncSession, base_url: str, kind: str = "scim" ) -> IdentitySyncTarget: integ = Integration( id=uuid.uuid4(), kind="scim", provider="scim", name=f"scim-{uuid.uuid4().hex[:6]}", slug=f"scim-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, # config_public is merged plaintext by _resolve — no encryption # needed for the test. Both base_url and api_token must resolve # so the builder reaches the SSRF guard. config_public={"base_url": base_url, "api_token": "tok-test"}, ) db.add(integ) await db.flush() tgt = IdentitySyncTarget( id=uuid.uuid4(), name="scim", slug=f"t-{uuid.uuid4().hex[:6]}", kind=kind, enabled=False, on_offboard="deactivate", integration_id=integ.id, mapping_config={"rules": []}, filter_config={}, schedule_config={"trigger": "manual", "cron_expr": None, "timezone": "UTC"}, runner_options={"auth_scheme": "bearer"}, ) db.add(tgt) await db.commit() return tgt @pytest.mark.parametrize( "bad_url", [ "http://169.254.169.254/latest/meta-data/", # AWS IMDS "http://127.0.0.1:8000/scim/v2", # loopback "http://10.0.0.5/scim/v2", # RFC1918 private ], ) async def test_scim_builder_rejects_internal_base_url( db_session: AsyncSession, bad_url: str ): tgt = await _make_scim_target(db_session, bad_url) svc = IdentitySyncService(db_session) with pytest.raises(AppException) as ei: await svc._build_runner(tgt) assert ei.value.code == "IDENTITY_SYNC_UNSAFE_BASE_URL" # The error message must not echo the full URL back (token redaction # posture) — host + reason only. assert "tok-test" not in (ei.value.message or "") async def test_navan_builder_also_guarded(db_session: AsyncSession): # navan shares the same builder branch as scim. tgt = await _make_scim_target( db_session, "http://169.254.169.254/", kind="navan" ) svc = IdentitySyncService(db_session) with pytest.raises(AppException) as ei: await svc._build_runner(tgt) assert ei.value.code == "IDENTITY_SYNC_UNSAFE_BASE_URL" async def test_development_loopback_bypass_is_deliberate_and_scoped( db_session: AsyncSession, monkeypatch, ): """The dev bypass must exist, must require BOTH conditions, and must never extend past loopback. Pinned deliberately rather than left implicit: production runs ``APP_ENV=production`` (verified on the running container 2026-08-06), so the bypass is inert there — but if it ever widened to private ranges or stopped checking ``APP_ENV``, that would be a live SSRF hole and nothing else would catch it. """ from app.core.config import settings from app.domains.identity_sync.runners.tls import ( development_loopback_tls_exception, ) monkeypatch.setattr(settings, "APP_ENV", "development", raising=False) # Loopback in development: bypass applies. assert development_loopback_tls_exception("http://127.0.0.1:8000/scim/v2") assert development_loopback_tls_exception("http://localhost:9000/x") assert development_loopback_tls_exception("http://[::1]:8000/x") # NOT loopback: never bypassed, even in development. assert not development_loopback_tls_exception( "http://169.254.169.254/latest/meta-data/", ) assert not development_loopback_tls_exception("http://10.0.0.5/scim/v2") # Outside development the bypass is inert even for loopback. for env in ("production", "test", "staging"): monkeypatch.setattr(settings, "APP_ENV", env, raising=False) assert not development_loopback_tls_exception( "http://127.0.0.1:8000/scim/v2", ), f"loopback must not bypass the SSRF guard under APP_ENV={env}"