7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
238 lines
9.7 KiB
Python
238 lines
9.7 KiB
Python
"""Integration tests for the egress-policy PEP inside the notification
|
|
dispatch path (``notifications/manager.py``).
|
|
|
|
These drive the REAL ``NotificationManager._filter_urls_by_egress_policy``
|
|
and the real ``send_notification`` / ``test_service`` call sites. They assert
|
|
the *policy decision* (vendor/cloud webhook filtered vs. allowed, fail-closed
|
|
on a corrupt scope, master-switch gating) which happens BEFORE any Apprise /
|
|
HTTP work. The only thing mocked is the dispatch boundary
|
|
(``service.send_event`` / ``service.test_service``) so nothing actually leaves
|
|
the box, plus the env master switches and — for http(s) hosts — the DNS-backed
|
|
host classifier, so the tests are in-process and deterministic.
|
|
|
|
Why filtering vendor webhooks matters: research results contain the user's
|
|
queries and retrieved local-corpus chunks. A ``discord://`` / ``slack://`` /
|
|
``https://hooks...`` notification URL would exfiltrate those to an external
|
|
vendor API. Under PRIVATE_ONLY ("nothing leaves the box") and, for public
|
|
http(s) hosts, STRICT, those URLs must be dropped before dispatch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from local_deep_research.notifications.manager import NotificationManager
|
|
from local_deep_research.notifications.templates import EventType
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Construction helper. NotificationManager.__init__ reads two ENV-only
|
|
# master switches and builds a NotificationService (an in-process
|
|
# apprise.Apprise() — no network). We patch the env reads so the manager is
|
|
# built deterministically; everything else is the real code.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_manager(snapshot, *, outbound=True, user_id="notif-egress-test"):
|
|
def fake_env(key, default=None):
|
|
if key == "notifications.allow_outbound":
|
|
return outbound
|
|
if key == "notifications.allow_private_ips":
|
|
return False
|
|
return default
|
|
|
|
with patch(
|
|
"local_deep_research.notifications.manager.get_env_setting",
|
|
side_effect=fake_env,
|
|
):
|
|
mgr = NotificationManager(snapshot, user_id=user_id)
|
|
# Sever the real dispatch path: any send must go through this mock, never
|
|
# apprise/HTTP. Tests assert on whether/with-what it is called.
|
|
mgr.service.send_event = MagicMock(return_value=True)
|
|
mgr.service.test_service = MagicMock(
|
|
return_value={"success": True, "message": "ok"}
|
|
)
|
|
return mgr
|
|
|
|
|
|
def _snapshot(scope, *, tool="arxiv", service_url=None):
|
|
snap = {
|
|
"policy.egress_scope": scope,
|
|
"search.tool": tool,
|
|
}
|
|
if service_url is not None:
|
|
snap["notifications.service_url"] = service_url
|
|
return snap
|
|
|
|
|
|
_CLASSIFY = "local_deep_research.security.egress.policy._classify_host"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A. Vendor (non-http) scheme: the core exfiltration guard.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_private_only_filters_vendor_scheme_but_both_allows():
|
|
"""discord:// is a vendor API egress that cannot be verified local, so
|
|
PRIVATE_ONLY must drop it; BOTH (the permissive scope) passes it through.
|
|
"""
|
|
url = "discord://webhook_id/token"
|
|
|
|
deny_mgr = _build_manager(_snapshot("private_only"))
|
|
assert deny_mgr._filter_urls_by_egress_policy(url) == ""
|
|
|
|
allow_mgr = _build_manager(_snapshot("both"))
|
|
assert allow_mgr._filter_urls_by_egress_policy(url) == url
|
|
|
|
|
|
def test_private_only_mixed_urls_keeps_only_local_https():
|
|
"""A space-separated Apprise string mixing a vendor scheme, a public
|
|
cloud webhook, and a self-hosted local webhook: under PRIVATE_ONLY only
|
|
the local https survives. Drives both the non-http branch and the real
|
|
evaluate_url http branch in one shot.
|
|
"""
|
|
urls = (
|
|
"slack://tokA/tokB/tokC "
|
|
"https://hooks.bad.test/services/x "
|
|
"https://intranet.local/hook"
|
|
)
|
|
|
|
def fake_classify(host, ctx, *a, **k):
|
|
return host == "intranet.local"
|
|
|
|
mgr = _build_manager(_snapshot("private_only"))
|
|
with patch(_CLASSIFY, side_effect=fake_classify):
|
|
result = mgr._filter_urls_by_egress_policy(urls)
|
|
|
|
assert result == "https://intranet.local/hook"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C. STRICT: refuses public http(s) hosts (provenance-spoofing risk).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_strict_filters_public_https_webhook_but_both_allows():
|
|
url = "https://hooks.bad.test/services/x"
|
|
|
|
# Public host classification (False), deterministic — no real DNS.
|
|
with patch(_CLASSIFY, return_value=False):
|
|
deny_mgr = _build_manager(_snapshot("strict"))
|
|
assert deny_mgr._filter_urls_by_egress_policy(url) == ""
|
|
|
|
allow_mgr = _build_manager(_snapshot("both"))
|
|
assert allow_mgr._filter_urls_by_egress_policy(url) == url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# D. PUBLIC_ONLY: refuses a private/intranet webhook, allows the public one.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_public_only_filters_local_webhook_but_allows_public():
|
|
local_url = "https://intranet.local/hook"
|
|
public_url = "https://hooks.bad.test/services/x"
|
|
|
|
def fake_classify(host, ctx, *a, **k):
|
|
return host == "intranet.local"
|
|
|
|
mgr = _build_manager(_snapshot("public_only"))
|
|
with patch(_CLASSIFY, side_effect=fake_classify):
|
|
assert mgr._filter_urls_by_egress_policy(local_url) == ""
|
|
assert mgr._filter_urls_by_egress_policy(public_url) == public_url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# E. Corrupt / unevaluable scope must fail CLOSED (filter, not dispatch).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_corrupt_scope_fails_closed():
|
|
url = "discord://webhook_id/token"
|
|
|
|
# A garbage scope makes context_from_snapshot raise PolicyDeniedError;
|
|
# the PEP must refuse ALL urls rather than fall open to unfiltered send.
|
|
deny_mgr = _build_manager(_snapshot("totally-not-a-scope"))
|
|
assert deny_mgr._filter_urls_by_egress_policy(url) == ""
|
|
|
|
# Sanity pair: a valid permissive scope on the same input passes it.
|
|
allow_mgr = _build_manager(_snapshot("both"))
|
|
assert allow_mgr._filter_urls_by_egress_policy(url) == url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# I. Snapshot-less back-compat branch: no snapshot => unchanged passthrough.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_snapshotless_manager_passes_through_but_private_only_filters():
|
|
url = "discord://webhook_id/token"
|
|
|
|
passthrough = _build_manager({}) # empty snapshot is falsy
|
|
assert passthrough._filter_urls_by_egress_policy(url) == url
|
|
|
|
filtered = _build_manager(_snapshot("private_only"))
|
|
assert filtered._filter_urls_by_egress_policy(url) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F. send_notification: the filter decision actually gates real dispatch.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_send_notification_blocks_dispatch_when_policy_filters_all():
|
|
snap_deny = _snapshot("private_only", service_url="discord://id/token")
|
|
deny_mgr = _build_manager(snap_deny, outbound=True)
|
|
# force=True bypasses per-user toggles + rate limit, NOT the egress filter.
|
|
sent = deny_mgr.send_notification(EventType.TEST, {}, force=True)
|
|
assert sent is False
|
|
deny_mgr.service.send_event.assert_not_called()
|
|
|
|
snap_allow = _snapshot("both", service_url="discord://id/token")
|
|
allow_mgr = _build_manager(snap_allow, outbound=True)
|
|
sent = allow_mgr.send_notification(EventType.TEST, {}, force=True)
|
|
assert sent is True
|
|
allow_mgr.service.send_event.assert_called_once()
|
|
# The allowed subset (the discord url) is what gets handed to dispatch.
|
|
_, kwargs = allow_mgr.service.send_event.call_args
|
|
assert kwargs["service_urls"] == "discord://id/token"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# G. Env master switch gates dispatch independently of the policy decision.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_outbound_master_switch_blocks_even_policy_allowed_url():
|
|
snap = _snapshot("both", service_url="discord://id/token")
|
|
|
|
# Outbound OFF: a BOTH-scope, policy-allowed url is still not dispatched.
|
|
off_mgr = _build_manager(snap, outbound=False)
|
|
assert off_mgr.send_notification(EventType.TEST, {}, force=True) is False
|
|
off_mgr.service.send_event.assert_not_called()
|
|
|
|
# Flipping ONLY the master switch on lets the same url through.
|
|
on_mgr = _build_manager(snap, outbound=True)
|
|
assert on_mgr.send_notification(EventType.TEST, {}, force=True) is True
|
|
on_mgr.service.send_event.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# H. test_service (the /api/notifications/test-url PEP) honors the policy.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_test_service_endpoint_respects_private_only():
|
|
url = "discord://webhook_id/token"
|
|
|
|
deny_mgr = _build_manager(_snapshot("private_only"))
|
|
result = deny_mgr.test_service(url)
|
|
assert result["status"] == "error"
|
|
assert "egress policy" in result["message"].lower()
|
|
deny_mgr.service.test_service.assert_not_called()
|
|
|
|
allow_mgr = _build_manager(_snapshot("both"))
|
|
allow_mgr.test_service(url)
|
|
allow_mgr.service.test_service.assert_called_once_with(url)
|