Restores both sys.modules and parent src.database package state after the webhook SSRF tests import src.webhook_manager against the real database module. Fixes one focused #2580 CI-baseline pollution bucket.
160 lines
5.2 KiB
Python
160 lines
5.2 KiB
Python
import sys
|
|
import json
|
|
from datetime import datetime
|
|
|
|
# conftest.py stubs src.database with a fake module; webhook_manager imports
|
|
# from it, so drop the stub here to load the real module under test. We RESTORE
|
|
# both the sys.modules entry AND the parent `src` package attribute afterwards,
|
|
# so the real src.database never leaks into sibling test modules (e.g.
|
|
# llm_core.list_model_ids resolves `from src.database import ...` against
|
|
# sys.modules at call time, and `import src.database as X` resolves through the
|
|
# parent attribute). This mirrors the routes.session_routes isolation fix.
|
|
_ABSENT = object()
|
|
|
|
|
|
def _save_module_and_parent_attr(dotted_name):
|
|
"""Capture a module's sys.modules entry *and* its parent-package attribute.
|
|
|
|
Returns a (module, attr) pair to hand back to
|
|
_restore_module_and_parent_attr. Either may be _ABSENT when not present.
|
|
"""
|
|
saved_module = sys.modules.get(dotted_name, _ABSENT)
|
|
pkg_name, _, attr = dotted_name.rpartition(".")
|
|
pkg = sys.modules.get(pkg_name)
|
|
saved_attr = getattr(pkg, attr, _ABSENT) if pkg is not None else _ABSENT
|
|
return saved_module, saved_attr
|
|
|
|
|
|
def _restore_module_and_parent_attr(dotted_name, saved_module, saved_attr):
|
|
"""Restore (or remove) both the sys.modules entry and the parent attribute.
|
|
|
|
Passing _ABSENT for both clears the cache, which is how we drop the stub
|
|
before the real import below.
|
|
"""
|
|
if saved_module is _ABSENT:
|
|
sys.modules.pop(dotted_name, None)
|
|
else:
|
|
sys.modules[dotted_name] = saved_module
|
|
pkg_name, _, attr = dotted_name.rpartition(".")
|
|
pkg = sys.modules.get(pkg_name)
|
|
if pkg is None:
|
|
return
|
|
if saved_attr is _ABSENT:
|
|
if hasattr(pkg, attr):
|
|
delattr(pkg, attr)
|
|
else:
|
|
setattr(pkg, attr, saved_attr)
|
|
|
|
|
|
# Capture the stub state, then clear both bindings so webhook_manager's import
|
|
# below produces/binds the real src.database with no stale stub behind it.
|
|
_src_database_saved = _save_module_and_parent_attr("src.database")
|
|
_restore_module_and_parent_attr("src.database", _ABSENT, _ABSENT)
|
|
_core_database = sys.modules.get("core.database")
|
|
_core_database_all = getattr(_core_database, "__all__", None) if _core_database is not None else None
|
|
if (
|
|
_core_database is not None
|
|
and (
|
|
not getattr(_core_database, "__file__", None)
|
|
or (
|
|
_core_database_all is not None
|
|
and (
|
|
not isinstance(_core_database_all, (list, tuple, set))
|
|
or not all(isinstance(name, str) for name in _core_database_all)
|
|
)
|
|
)
|
|
)
|
|
):
|
|
del sys.modules["core.database"]
|
|
|
|
import pytest
|
|
from src.webhook_manager import validate_webhook_url
|
|
|
|
# webhook_manager is now bound to the real src.database, so restore both the
|
|
# sys.modules entry and the parent `src.database` attribute to their original
|
|
# stub state to avoid polluting sibling test modules.
|
|
_restore_module_and_parent_attr("src.database", *_src_database_saved)
|
|
|
|
|
|
def test_webhook_url_ssrf_mitigation():
|
|
# SSRF bypasses that must be rejected, including IPv6 unspecified and
|
|
# IPv4-mapped IPv6 (loopback + cloud metadata).
|
|
private_urls = [
|
|
"http://[::]/",
|
|
"http://[::ffff:127.0.0.1]/",
|
|
"http://[::ffff:169.254.169.254]/",
|
|
"http://127.0.0.1/",
|
|
"http://0.0.0.0/",
|
|
]
|
|
for url in private_urls:
|
|
with pytest.raises(ValueError) as exc:
|
|
validate_webhook_url(url)
|
|
assert "private/internal addresses" in str(exc.value)
|
|
|
|
# A clearly public IP literal must still be accepted.
|
|
public_url = "http://93.184.216.34/"
|
|
assert validate_webhook_url(public_url) == public_url
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_webhook_delivery_uses_naive_utc_timestamps(monkeypatch):
|
|
import src.webhook_manager as wm
|
|
|
|
class _Query:
|
|
def __init__(self, updates):
|
|
self.updates = updates
|
|
|
|
def filter(self, *_args, **_kwargs):
|
|
return self
|
|
|
|
def update(self, values):
|
|
self.updates.append(values)
|
|
|
|
class _Db:
|
|
def __init__(self):
|
|
self.updates = []
|
|
self.committed = False
|
|
self.closed = False
|
|
|
|
def query(self, _model):
|
|
return _Query(self.updates)
|
|
|
|
def commit(self):
|
|
self.committed = True
|
|
|
|
def rollback(self):
|
|
pass
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
class _Response:
|
|
status_code = 204
|
|
|
|
class _Client:
|
|
def __init__(self):
|
|
self.content = ""
|
|
|
|
async def post(self, _url, content, headers):
|
|
self.content = content
|
|
assert headers["X-Odysseus-Event"] == "webhook.test"
|
|
return _Response()
|
|
|
|
db = _Db()
|
|
client = _Client()
|
|
monkeypatch.setattr(wm, "SessionLocal", lambda: db)
|
|
|
|
manager = wm.WebhookManager()
|
|
await manager._client.aclose()
|
|
manager._client = client
|
|
|
|
await manager._deliver("hook-1", "http://93.184.216.34/", None, "webhook.test", {"ok": True})
|
|
|
|
body = json.loads(client.content)
|
|
payload_timestamp = datetime.fromisoformat(body["timestamp"])
|
|
assert payload_timestamp.tzinfo is None
|
|
assert db.updates[0]["last_triggered_at"].tzinfo is None
|
|
assert db.updates[0]["last_status_code"] == 204
|
|
assert db.committed is True
|
|
assert db.closed is True
|