| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- """Security boundary tests for the P3-WP04 pull-only edge runtime."""
- from __future__ import annotations
- import json
- from datetime import UTC, datetime, timedelta
- from pathlib import Path
- from urllib.parse import quote
- import pytest
- from cryptography import x509
- from cryptography.hazmat.primitives import hashes, serialization
- from cryptography.hazmat.primitives.asymmetric import rsa
- from cryptography.x509.oid import NameOID
- from flask import Flask
- from app.api.data_source.edge_routes import _tls_certificate_sha256
- from app.core.edge_gateway.contracts import EdgeContractError, EdgeTaskContract
- from app.core.edge_gateway.policy import EdgePolicyError
- from app.core.edge_gateway.service import EdgeGatewayAuthenticationError
- from app.edge_gateway.agent import EdgeRunnerAdapter
- from app.edge_gateway.bootstrap import EdgeBootstrapConfig
- from app.edge_gateway.transport import (
- EdgeAuthenticationStopped,
- EdgeTransport,
- EdgeTransportError,
- )
- from app.runner.api import execute_edge_adapter
- from app.runner.nodes import NodeExecutionError
- POLICY_DIGEST = "a" * 64
- def _escaped_client_certificate():
- key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
- name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "edge-gateway-test")])
- now = datetime.now(UTC)
- certificate = (
- x509.CertificateBuilder()
- .subject_name(name)
- .issuer_name(name)
- .public_key(key.public_key())
- .serial_number(x509.random_serial_number())
- .not_valid_before(now - timedelta(minutes=1))
- .not_valid_after(now + timedelta(minutes=5))
- .sign(key, hashes.SHA256())
- )
- pem = certificate.public_bytes(serialization.Encoding.PEM).decode()
- return quote(pem, safe=""), certificate.fingerprint(hashes.SHA256()).hex()
- def test_backend_accepts_certificate_only_from_explicit_trusted_mtls_proxy():
- app = Flask(__name__)
- app.config["EDGE_MTLS_TRUSTED_PROXY_IPS"] = ("172.31.0.10",)
- escaped, fingerprint = _escaped_client_certificate()
- headers = {
- "X-DataOps-Edge-Client-Cert": escaped,
- "X-DataOps-Edge-Client-Verify": "SUCCESS",
- "X-Edge-Certificate-SHA256": fingerprint,
- }
- with app.test_request_context(
- "/", headers=headers, environ_overrides={"REMOTE_ADDR": "172.31.0.10"}
- ):
- assert _tls_certificate_sha256() == fingerprint
- with app.test_request_context(
- "/", headers=headers, environ_overrides={"REMOTE_ADDR": "203.0.113.7"}
- ), pytest.raises(EdgeGatewayAuthenticationError, match="trusted mTLS proxy"):
- _tls_certificate_sha256()
- with app.test_request_context(
- "/",
- headers={**headers, "X-DataOps-Edge-Client-Cert": "forged"},
- environ_overrides={"REMOTE_ADDR": "172.31.0.10"},
- ), pytest.raises(EdgeGatewayAuthenticationError, match="certificate"):
- _tls_certificate_sha256()
- def task(**changes):
- value = {
- "task_id": "task-1",
- "gateway_id": "gateway-1",
- "environment": "production",
- "network_zone": "zone-a",
- "purpose": "governed-inventory",
- "classification": "statistics",
- "task_type": "profile",
- "contract_version": 1,
- "deadline_at": (datetime.now(UTC) + timedelta(minutes=5)).isoformat().replace("+00:00", "Z"),
- "attempt": 1,
- "idempotency_key": "idem-1",
- "policy_digest": POLICY_DIGEST,
- }
- value.update(changes)
- return value
- class NeverNetwork:
- def request(self, *args, **kwargs): # pragma: no cover - must not run
- raise AssertionError("network request must not be attempted")
- @pytest.mark.parametrize(
- "url,proxy",
- [
- ("http://control.enterprise.test", None),
- ("https://user@control.enterprise.test", None),
- ("https://control.enterprise.test.evil.test", None),
- ("https://control.enterprise.test", "https://evil.test"),
- ],
- )
- def test_transport_rejects_unapproved_destinations_before_network(url, proxy):
- with pytest.raises(EdgePolicyError):
- EdgeTransport(
- base_url=url,
- proxy_url=proxy,
- allowed_control_hosts={"control.enterprise.test"},
- allowed_proxy_hosts={"proxy.enterprise.test"},
- gateway_id="gateway-1",
- environment="production",
- network_zone="zone-a",
- generation=1,
- credential="dopg_example-credential",
- certificate_sha256="b" * 64,
- client=NeverNetwork(),
- client_certificate_path="/secure/edge-cert.pem",
- client_private_key_path="/secure/edge-key.pem",
- ca_bundle_path="/secure/ca.pem",
- )
- @pytest.mark.parametrize(
- "operation",
- ["shell", "python", "sql", "raw_export", "http", "../../bin/sh"],
- )
- def test_runner_adapter_rejects_unknown_or_malicious_task_types(operation):
- adapter = EdgeRunnerAdapter(lambda _node, _request, _cancel: {})
- value = task(task_type=operation)
- with pytest.raises((EdgeContractError, EdgePolicyError)):
- adapter.execute(EdgeTaskContract.from_mapping(value), lambda: False)
- @pytest.mark.parametrize(
- "result",
- [
- {"classification": "raw", "payload": {"count": 1}},
- {"classification": "statistics", "payload": {"sql": "SELECT * FROM secret"}},
- {"classification": "statistics", "payload": {"password": "hidden"}},
- {"classification": "statistics", "payload": {"rows": [{"id": 1}]}},
- {"classification": "statistics", "payload": {"count": 1}, "command": "sh"},
- ],
- )
- def test_runner_adapter_rejects_raw_secret_script_and_unknown_result_fields(result):
- adapter = EdgeRunnerAdapter(lambda _node, _request, _cancel: result)
- with pytest.raises(EdgePolicyError):
- adapter.execute(EdgeTaskContract.from_mapping(task()), lambda: False)
- def test_runner_adapter_rejects_recursive_and_cyclic_results():
- cyclic = {}
- cyclic["child"] = cyclic
- adapter = EdgeRunnerAdapter(
- lambda _node, _request, _cancel: {
- "classification": "statistics",
- "payload": cyclic,
- }
- )
- with pytest.raises(EdgePolicyError):
- adapter.execute(EdgeTaskContract.from_mapping(task()), lambda: False)
- def test_runner_adapter_rejects_independently_oversized_result():
- adapter = EdgeRunnerAdapter(
- lambda _node, _request, _cancel: {
- "classification": "statistics",
- "payload": {"summary": "x" * 33_000},
- }
- )
- with pytest.raises(EdgePolicyError):
- adapter.execute(EdgeTaskContract.from_mapping(task()), lambda: False)
- def test_runner_adapter_rejects_unallowlisted_purpose_before_local_handler():
- called = False
- def execute(_node, _request, _cancel):
- nonlocal called
- called = True
- return {"classification": "statistics", "payload": {"metric_count": 1}}
- adapter = EdgeRunnerAdapter(execute)
- with pytest.raises(EdgePolicyError, match="purpose"):
- adapter.execute(
- EdgeTaskContract.from_mapping(task(purpose="arbitrary-action")),
- lambda: False,
- )
- assert not called
- class Response:
- def __init__(self, status_code, content):
- self.status_code = status_code
- self.headers = {"Content-Length": str(len(content))}
- self._content = content
- def iter_content(self, chunk_size):
- yield self._content
- class RecordingClient:
- def __init__(self, response):
- self.response = response
- self.calls = []
- def request(self, *args, **kwargs):
- self.calls.append((args, kwargs))
- return self.response
- class SequencedClient:
- def __init__(self, responses):
- self.responses = list(responses)
- self.calls = []
- def request(self, *args, **kwargs):
- self.calls.append((args, kwargs))
- return self.responses.pop(0)
- def _transport(client):
- return EdgeTransport(
- base_url="https://control.enterprise.test",
- allowed_control_hosts={"control.enterprise.test"},
- gateway_id="gateway-1",
- environment="production",
- network_zone="zone-a",
- generation=1,
- credential="dopg_example-credential",
- certificate_sha256="b" * 64,
- client=client,
- client_certificate_path="/secure/edge-cert.pem",
- client_private_key_path="/secure/edge-key.pem",
- ca_bundle_path="/secure/ca.pem",
- )
- def test_transport_uses_exact_bound_headers_and_forbids_redirect_following():
- client = RecordingClient(Response(200, b'{"code":200,"message":"ok","data":{"task":null}}'))
- assert _transport(client).pull_task() is None
- args, kwargs = client.calls[0]
- assert args == (
- "POST",
- "https://control.enterprise.test/api/datasource/edge/gateways/gateway-1/tasks/pull",
- )
- assert kwargs["allow_redirects"] is False
- assert kwargs["headers"] == {
- "Accept": "application/json",
- "Content-Type": "application/json",
- "X-Edge-Credential": "dopg_example-credential",
- "X-Edge-Certificate-SHA256": "b" * 64,
- }
- @pytest.mark.parametrize("status", [301, 302, 307, 308])
- def test_transport_rejects_all_redirects(status):
- with pytest.raises(EdgeTransportError, match="redirects"):
- _transport(RecordingClient(Response(status, b""))).pull_task()
- @pytest.mark.parametrize("status", [401, 403])
- def test_transport_stops_on_revoked_or_expired_binding(status):
- with pytest.raises(EdgeAuthenticationStopped):
- _transport(RecordingClient(Response(status, b'{}'))).reconcile()
- def test_transport_rejects_oversized_response_before_json_decode():
- with pytest.raises(EdgeTransportError, match="byte limit"):
- _transport(
- RecordingClient(Response(200, b"x" * (EdgeTransport.MAX_RESPONSE_BYTES + 1)))
- ).reconcile()
- def test_transport_cancel_probe_uses_bounded_reconcile_contract():
- client = RecordingClient(
- Response(
- 200,
- b'{"code":200,"message":"ok","data":{"cancelled_task_ids":["task-1"],"cancel_next_cursor":null,"release_offers":[],"release_next_cursor":null,"release_baseline":null}}',
- )
- )
- assert _transport(client).cancel_requested("task-1") is True
- def test_transport_reconcile_drains_all_stable_cursor_pages():
- first_ids = [f"task-{index:02d}" for index in range(50)]
- first = {
- "code": 200,
- "message": "ok",
- "data": {
- "cancelled_task_ids": first_ids,
- "cancel_next_cursor": first_ids[-1],
- "release_offers": [],
- "release_next_cursor": None,
- "release_baseline": None,
- },
- }
- second = {
- "code": 200,
- "message": "ok",
- "data": {
- "cancelled_task_ids": ["task-50"],
- "cancel_next_cursor": None,
- "release_offers": [],
- "release_next_cursor": None,
- "release_baseline": None,
- },
- }
- client = SequencedClient(
- [
- Response(200, json.dumps(first, separators=(",", ":")).encode()),
- Response(200, json.dumps(second, separators=(",", ":")).encode()),
- ]
- )
- reconciled = _transport(client).reconcile()
- assert reconciled == {
- "cancelled_task_ids": [*first_ids, "task-50"],
- "release_offers": [],
- }
- second_payload = json.loads(client.calls[1][1]["data"])
- assert second_payload["cancel_cursor"] == first_ids[-1]
- def _mtls_files(tmp_path: Path):
- key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
- name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "edge-gateway-1")])
- now = datetime.now(UTC)
- cert = (
- x509.CertificateBuilder()
- .subject_name(name)
- .issuer_name(name)
- .public_key(key.public_key())
- .serial_number(x509.random_serial_number())
- .not_valid_before(now - timedelta(minutes=1))
- .not_valid_after(now + timedelta(days=1))
- .sign(key, hashes.SHA256())
- )
- cert_path = tmp_path / "edge-cert.pem"
- key_path = tmp_path / "edge-key.pem"
- ca_path = tmp_path / "enterprise-ca.pem"
- cert_pem = cert.public_bytes(serialization.Encoding.PEM)
- cert_path.write_bytes(cert_pem)
- ca_path.write_bytes(cert_pem)
- key_path.write_bytes(
- key.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption(),
- )
- )
- key_path.chmod(0o600)
- return cert_path, key_path, ca_path, cert.fingerprint(hashes.SHA256()).hex()
- def test_bootstrap_requires_real_mtls_material_and_matching_fingerprint(tmp_path):
- cert, key, ca, fingerprint = _mtls_files(tmp_path)
- value = {
- "queue_path": str(tmp_path / "edge.sqlite3"),
- "artifact_root": str(tmp_path),
- "gateway_id": "gateway-1",
- "credential": "dopg_local-test-credential",
- "certificate_sha256": fingerprint,
- "client_certificate_path": str(cert),
- "client_private_key_path": str(key),
- "ca_bundle_path": str(ca),
- "generation": 1,
- "environment": "production",
- "network_zone": "zone-a",
- "policy_digest": "a" * 64,
- "control_url": "https://control.enterprise.test",
- "proxy_url": None,
- "allowed_control_hosts": ["control.enterprise.test"],
- "allowed_proxy_hosts": [],
- "trusted_release_keys": {"release-key-1": "01" * 32},
- "trusted_task_keys": {"task-key-1": "02" * 32},
- "task_authority_clock_skew_seconds": 60,
- "version": "3.0.0",
- }
- config = EdgeBootstrapConfig.from_mapping(value)
- assert config.certificate_sha256 == fingerprint
- with pytest.raises(ValueError, match="fingerprint"):
- EdgeBootstrapConfig.from_mapping({**value, "certificate_sha256": "f" * 64})
- @pytest.mark.parametrize(
- "url,host",
- [
- ("https://control.enterprise.test:444", "control.enterprise.test"),
- ("https://127.0.0.1", "127.0.0.1"),
- ("https://éxample.test", "éxample.test"),
- ],
- )
- def test_transport_rejects_unapproved_port_ip_literal_and_unicode_host(url, host):
- with pytest.raises((ValueError, EdgePolicyError)):
- EdgeTransport(
- base_url=url,
- allowed_control_hosts={host},
- gateway_id="gateway-1",
- environment="production",
- network_zone="zone-a",
- generation=1,
- credential="dopg_example-credential",
- certificate_sha256="b" * 64,
- client=NeverNetwork(),
- client_certificate_path="/secure/edge-cert.pem",
- client_private_key_path="/secure/edge-key.pem",
- ca_bundle_path="/secure/ca.pem",
- )
- class StreamingResponse:
- def __init__(self, chunks, *, content_length=None):
- self.status_code = 200
- self.headers = {}
- if content_length is not None:
- self.headers["Content-Length"] = str(content_length)
- self._chunks = chunks
- @property
- def content(self): # pragma: no cover - accessing this is the bug
- raise AssertionError("streaming transport must not buffer response.content")
- def iter_content(self, chunk_size):
- assert chunk_size <= 65_536
- yield from self._chunks
- class CloseTrackingResponse(StreamingResponse):
- def __init__(self, chunks, *, status_code=200, content_length=None, read_error=None):
- super().__init__(chunks, content_length=content_length)
- self.status_code = status_code
- self.read_error = read_error
- self.close_count = 0
- def iter_content(self, chunk_size):
- if self.read_error is not None:
- raise self.read_error
- yield from super().iter_content(chunk_size)
- def close(self):
- self.close_count += 1
- @pytest.mark.parametrize(
- "response,expected_exception",
- [
- (
- CloseTrackingResponse(
- [b'{"code":200,"message":"ok","data":{"task":null}}']
- ),
- None,
- ),
- (CloseTrackingResponse([], status_code=302), EdgeTransportError),
- (CloseTrackingResponse([b"{}"], status_code=401), EdgeAuthenticationStopped),
- (CloseTrackingResponse([b"{}"], status_code=500), EdgeTransportError),
- (
- CloseTrackingResponse([], content_length=EdgeTransport.MAX_RESPONSE_BYTES + 1),
- EdgeTransportError,
- ),
- (
- CloseTrackingResponse(
- [b"x" * (EdgeTransport.MAX_RESPONSE_BYTES + 1)], content_length=1
- ),
- EdgeTransportError,
- ),
- (
- CloseTrackingResponse([], read_error=TimeoutError("slow stream")),
- EdgeTransportError,
- ),
- ],
- )
- def test_transport_closes_streamed_response_exactly_once_on_every_exit(
- response, expected_exception
- ):
- transport = _transport(RecordingClient(response))
- if expected_exception is None:
- assert transport.pull_task() is None
- else:
- with pytest.raises(expected_exception):
- transport.pull_task()
- assert response.close_count == 1
- def test_transport_streams_with_mtls_and_rejects_deceptive_chunk_overflow():
- body = b'{"code":200,"message":"ok","data":{"task":null}}'
- client = RecordingClient(StreamingResponse([body], content_length=len(body)))
- transport = EdgeTransport(
- base_url="https://control.enterprise.test",
- allowed_control_hosts={"control.enterprise.test"},
- gateway_id="gateway-1",
- environment="production",
- network_zone="zone-a",
- generation=1,
- credential="dopg_example-credential",
- certificate_sha256="b" * 64,
- client=client,
- client_certificate_path="/secure/edge-cert.pem",
- client_private_key_path="/secure/edge-key.pem",
- ca_bundle_path="/secure/ca.pem",
- )
- assert transport.pull_task() is None
- _, kwargs = client.calls[0]
- assert kwargs["stream"] is True
- assert kwargs["cert"] == ("/secure/edge-cert.pem", "/secure/edge-key.pem")
- assert kwargs["verify"] == "/secure/ca.pem"
- assert kwargs["timeout"] == (5, 20)
- overflow = RecordingClient(
- StreamingResponse(
- [b"x" * 700_000, b"y" * 700_000],
- content_length=1,
- )
- )
- bad = EdgeTransport(
- base_url="https://control.enterprise.test",
- allowed_control_hosts={"control.enterprise.test"},
- gateway_id="gateway-1",
- environment="production",
- network_zone="zone-a",
- generation=1,
- credential="dopg_example-credential",
- certificate_sha256="b" * 64,
- client=overflow,
- client_certificate_path="/secure/edge-cert.pem",
- client_private_key_path="/secure/edge-key.pem",
- ca_bundle_path="/secure/ca.pem",
- )
- with pytest.raises(EdgeTransportError, match="byte limit"):
- bad.pull_task()
- def test_runner_bridge_has_exact_local_only_schema():
- request = {
- "task_id": "task-1",
- "operation": "profile",
- "purpose": "inventory",
- "classification": "statistics",
- "environment": "production",
- "network_zone": "zone-a",
- "idempotency_key": "idem-1",
- "deadline_at": "2030-01-01T00:00:00Z",
- }
- handlers = {"edge.profile": lambda value, _cancel: {"seen": sorted(value)}}
- assert execute_edge_adapter(handlers, "edge.profile", request, lambda: False) == {
- "seen": sorted(request)
- }
- with pytest.raises(NodeExecutionError):
- execute_edge_adapter(handlers, "python", request, lambda: False)
- with pytest.raises(NodeExecutionError):
- execute_edge_adapter(
- handlers,
- "edge.profile",
- {**request, "sql": "SELECT 1"},
- lambda: False,
- )
|