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
279 lines
9.9 KiB
Python
279 lines
9.9 KiB
Python
"""Integration tests for the settings-save egress validators.
|
|
|
|
These drive the REAL cross-field policy validators in
|
|
``security/egress/validators.py`` exactly as the settings write routes invoke
|
|
them (``web/routes/settings_routes.py``: ``save_all_settings`` /
|
|
``save_settings`` / ``api_update_setting``):
|
|
|
|
- ``validate_allowed_local_hostnames``: a PUBLIC hostname may not be smuggled
|
|
into ``llm.allowed_local_hostnames`` (the host classifier would then trust
|
|
external hosts as "local"); private / loopback hosts are accepted.
|
|
|
|
Each guarded property is asserted with an allow+deny pair so the test fails if
|
|
the rule were reverted. Direct-validator tests use realistic
|
|
``form_data`` / ``all_db_settings`` inputs; route-level tests drive the real
|
|
``api_update_setting`` PUT endpoint with only the settings/DB backend mocked,
|
|
proving the validation error is surfaced (not silently dropped).
|
|
|
|
All host classification uses literal IPs (8.8.8.8 public, 10.0.0.1 / 127.0.0.1
|
|
private), which the real classifier resolves OFFLINE via getaddrinfo on a
|
|
literal — no network round-trip, fully deterministic.
|
|
"""
|
|
|
|
from contextlib import contextmanager
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
from local_deep_research.security.egress import validators
|
|
from local_deep_research.security.egress.validators import (
|
|
validate_allowed_local_hostnames,
|
|
)
|
|
from local_deep_research.web.routes.settings_routes import settings_bp
|
|
|
|
HOSTS_KEY = "llm.allowed_local_hostnames"
|
|
SCOPE_KEY = "policy.egress_scope"
|
|
ENGINE_KEY = "search.tool"
|
|
MODULE = "local_deep_research.web.routes.settings_routes"
|
|
DECORATOR_MODULE = "local_deep_research.web.utils.route_decorators"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Removed validator regression guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_strict_meta_combo_is_gone():
|
|
"""The meta-picker engines were removed, so the STRICT+meta-picker
|
|
save-time validator must no longer exist (stray meta names are denied at
|
|
runtime by ``evaluate_engine`` as ``engine_unknown`` instead)."""
|
|
assert not hasattr(validators, "validate_strict_meta_combo")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_allowed_local_hostnames (no PUBLIC host in the local allowlist)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_public_host_in_local_allowlist_is_rejected():
|
|
"""A public IP would let the policy treat an external host as local."""
|
|
err = validate_allowed_local_hostnames({HOSTS_KEY: ["8.8.8.8"]}, {})
|
|
assert err is not None
|
|
assert err["key"] == HOSTS_KEY
|
|
assert "8.8.8.8" in err["error"]
|
|
|
|
|
|
def test_private_host_in_local_allowlist_is_accepted():
|
|
"""Allow side: an RFC1918 private address is a legitimate local host."""
|
|
assert (
|
|
validate_allowed_local_hostnames({HOSTS_KEY: ["10.0.0.1"]}, {}) is None
|
|
)
|
|
|
|
|
|
def test_loopback_host_in_local_allowlist_is_accepted():
|
|
"""Loopback is local and must be accepted."""
|
|
assert (
|
|
validate_allowed_local_hostnames({HOSTS_KEY: ["127.0.0.1"]}, {}) is None
|
|
)
|
|
|
|
|
|
def test_mixed_list_rejects_only_the_public_entries():
|
|
"""A list mixing private + public is rejected, naming only the public host."""
|
|
err = validate_allowed_local_hostnames(
|
|
{HOSTS_KEY: ["10.0.0.1", "8.8.8.8"]}, {}
|
|
)
|
|
assert err is not None
|
|
assert "8.8.8.8" in err["error"]
|
|
assert "10.0.0.1" not in err["error"]
|
|
|
|
|
|
def test_unresolvable_host_is_accepted_fail_open():
|
|
"""A name that does not resolve (DNS down / split-horizon) is accepted so a
|
|
flaky-network user can still save; runtime classification still gates it.
|
|
Resolution is stubbed to None to keep this deterministic and offline."""
|
|
with patch.object(validators, "_resolve_with_timeout", return_value=None):
|
|
assert (
|
|
validate_allowed_local_hostnames(
|
|
{HOSTS_KEY: ["intranet.corp.example"]}, {}
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_public_host_still_rejected_when_resolution_succeeds():
|
|
"""Deny counterpart to the fail-open test: when resolution returns an
|
|
address, a public host is rejected (the stub must not blanket-accept)."""
|
|
addrinfo = [(2, 1, 6, "", ("8.8.8.8", 0))]
|
|
with patch.object(
|
|
validators, "_resolve_with_timeout", return_value=addrinfo
|
|
):
|
|
err = validate_allowed_local_hostnames(
|
|
{HOSTS_KEY: ["resolves-public.example"]}, {}
|
|
)
|
|
assert err is not None
|
|
assert "resolves-public.example" in err["error"]
|
|
|
|
|
|
def test_json_string_list_of_private_host_is_accepted():
|
|
"""The save pipeline may hand the JSON-typed value as a JSON string."""
|
|
assert (
|
|
validate_allowed_local_hostnames({HOSTS_KEY: '["10.0.0.1"]'}, {})
|
|
is None
|
|
)
|
|
|
|
|
|
def test_json_string_list_of_public_host_is_rejected():
|
|
"""Deny counterpart for the JSON-string decode path."""
|
|
err = validate_allowed_local_hostnames({HOSTS_KEY: '["8.8.8.8"]'}, {})
|
|
assert err is not None
|
|
assert "8.8.8.8" in err["error"]
|
|
|
|
|
|
def test_malformed_json_string_is_rejected():
|
|
"""A non-JSON string for a JSON-typed setting is a hard validation error."""
|
|
err = validate_allowed_local_hostnames({HOSTS_KEY: "not json"}, {})
|
|
assert err is not None
|
|
assert err["key"] == HOSTS_KEY
|
|
|
|
|
|
def test_non_list_value_is_rejected():
|
|
"""A scalar (non-list) value is rejected — must be a list of hostnames."""
|
|
err = validate_allowed_local_hostnames({HOSTS_KEY: 5}, {})
|
|
assert err is not None
|
|
|
|
|
|
def test_hosts_key_absent_returns_none():
|
|
"""The guard is inert when its key is not part of the save."""
|
|
assert validate_allowed_local_hostnames({"other.key": "x"}, {}) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route-level wiring: api_update_setting PUT surfaces the validator errors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_setting(key, value, ui_element="text"):
|
|
s = MagicMock()
|
|
s.key = key
|
|
s.value = value
|
|
s.ui_element = ui_element
|
|
s.editable = True
|
|
return s
|
|
|
|
|
|
@contextmanager
|
|
def _routed_client(existing_settings):
|
|
"""Drive api_update_setting with the auth + DB backend mocked.
|
|
|
|
``existing_settings`` is a list of mock Setting rows returned by
|
|
``db_session.query(Setting).all()``; the per-key lookup
|
|
``.filter(...).first()`` returns the row whose key matches the request.
|
|
"""
|
|
app = Flask(__name__)
|
|
app.config["SECRET_KEY"] = "test-secret"
|
|
app.config["WTF_CSRF_ENABLED"] = False
|
|
app.register_blueprint(settings_bp)
|
|
|
|
by_key = {s.key: s for s in existing_settings}
|
|
|
|
db_session = MagicMock()
|
|
|
|
def _query(_model):
|
|
q = MagicMock()
|
|
q.all.return_value = list(existing_settings)
|
|
|
|
def _filter(*_a, **_k):
|
|
fq = MagicMock()
|
|
# api_update_setting filters on Setting.key == <key>; the key is
|
|
# bound in the route, so resolve it from the live request.
|
|
from flask import request as _req
|
|
|
|
requested = _req.view_args.get("key")
|
|
fq.first.return_value = by_key.get(requested)
|
|
return fq
|
|
|
|
q.filter.side_effect = _filter
|
|
return q
|
|
|
|
db_session.query.side_effect = _query
|
|
|
|
@contextmanager
|
|
def _fake_user_session(_username):
|
|
yield db_session
|
|
|
|
fake_db_manager = MagicMock()
|
|
fake_db_manager.is_user_connected.return_value = True
|
|
|
|
patches = [
|
|
patch(
|
|
"local_deep_research.web.auth.decorators.db_manager",
|
|
fake_db_manager,
|
|
),
|
|
patch(
|
|
f"{DECORATOR_MODULE}.get_user_db_session",
|
|
side_effect=_fake_user_session,
|
|
),
|
|
patch(f"{MODULE}.settings_limit", lambda f: f),
|
|
# Isolate the egress decision from adjacent type/coercion concerns.
|
|
patch(f"{MODULE}.validate_setting", return_value=(True, None)),
|
|
patch(
|
|
f"{MODULE}.coerce_setting_for_write",
|
|
side_effect=lambda **kw: kw["value"],
|
|
),
|
|
patch(f"{MODULE}.set_setting", return_value=True),
|
|
patch(f"{MODULE}.calculate_warnings", return_value=[]),
|
|
patch(f"{MODULE}.invalidate_settings_caches", return_value=None),
|
|
]
|
|
for p in patches:
|
|
p.start()
|
|
try:
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess["username"] = "testuser"
|
|
sess["session_id"] = "sid"
|
|
yield client
|
|
finally:
|
|
for p in reversed(patches):
|
|
p.stop()
|
|
|
|
|
|
def test_route_rejects_public_local_hostname():
|
|
"""PUT llm.allowed_local_hostnames=[public IP] -> 400 with the policy error."""
|
|
settings = [_make_setting(HOSTS_KEY, [], ui_element="json")]
|
|
with _routed_client(settings) as client:
|
|
resp = client.put(
|
|
f"/settings/api/{HOSTS_KEY}", json={"value": ["8.8.8.8"]}
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "8.8.8.8" in resp.get_json()["error"]
|
|
|
|
|
|
def test_route_accepts_private_local_hostname():
|
|
"""Allow counterpart: a private IP passes the validator and is saved (200)."""
|
|
settings = [_make_setting(HOSTS_KEY, [], ui_element="json")]
|
|
with _routed_client(settings) as client:
|
|
resp = client.put(
|
|
f"/settings/api/{HOSTS_KEY}", json={"value": ["10.0.0.1"]}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "error" not in resp.get_json()
|
|
|
|
|
|
def test_route_accepts_strict_scope_with_db_concrete_engine():
|
|
"""Allow counterpart: STRICT scope with a concrete DB engine saves (200)."""
|
|
settings = [
|
|
_make_setting(SCOPE_KEY, "both"),
|
|
_make_setting(ENGINE_KEY, "arxiv"),
|
|
]
|
|
with _routed_client(settings) as client:
|
|
resp = client.put(
|
|
f"/settings/api/{SCOPE_KEY}", json={"value": "strict"}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "error" not in resp.get_json()
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
raise SystemExit(pytest.main([__file__, "-q"]))
|