test_phase3_wp04_data_boundary.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. """Security boundary tests for the P3-WP04 pull-only edge runtime."""
  2. from __future__ import annotations
  3. import json
  4. from datetime import UTC, datetime, timedelta
  5. from pathlib import Path
  6. from urllib.parse import quote
  7. import pytest
  8. from cryptography import x509
  9. from cryptography.hazmat.primitives import hashes, serialization
  10. from cryptography.hazmat.primitives.asymmetric import rsa
  11. from cryptography.x509.oid import NameOID
  12. from flask import Flask
  13. from app.api.data_source.edge_routes import _tls_certificate_sha256
  14. from app.core.edge_gateway.contracts import EdgeContractError, EdgeTaskContract
  15. from app.core.edge_gateway.policy import EdgePolicyError
  16. from app.core.edge_gateway.service import EdgeGatewayAuthenticationError
  17. from app.edge_gateway.agent import EdgeRunnerAdapter
  18. from app.edge_gateway.bootstrap import EdgeBootstrapConfig
  19. from app.edge_gateway.transport import (
  20. EdgeAuthenticationStopped,
  21. EdgeTransport,
  22. EdgeTransportError,
  23. )
  24. from app.runner.api import execute_edge_adapter
  25. from app.runner.nodes import NodeExecutionError
  26. POLICY_DIGEST = "a" * 64
  27. def _escaped_client_certificate():
  28. key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  29. name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "edge-gateway-test")])
  30. now = datetime.now(UTC)
  31. certificate = (
  32. x509.CertificateBuilder()
  33. .subject_name(name)
  34. .issuer_name(name)
  35. .public_key(key.public_key())
  36. .serial_number(x509.random_serial_number())
  37. .not_valid_before(now - timedelta(minutes=1))
  38. .not_valid_after(now + timedelta(minutes=5))
  39. .sign(key, hashes.SHA256())
  40. )
  41. pem = certificate.public_bytes(serialization.Encoding.PEM).decode()
  42. return quote(pem, safe=""), certificate.fingerprint(hashes.SHA256()).hex()
  43. def test_backend_accepts_certificate_only_from_explicit_trusted_mtls_proxy():
  44. app = Flask(__name__)
  45. app.config["EDGE_MTLS_TRUSTED_PROXY_IPS"] = ("172.31.0.10",)
  46. escaped, fingerprint = _escaped_client_certificate()
  47. headers = {
  48. "X-DataOps-Edge-Client-Cert": escaped,
  49. "X-DataOps-Edge-Client-Verify": "SUCCESS",
  50. "X-Edge-Certificate-SHA256": fingerprint,
  51. }
  52. with app.test_request_context(
  53. "/", headers=headers, environ_overrides={"REMOTE_ADDR": "172.31.0.10"}
  54. ):
  55. assert _tls_certificate_sha256() == fingerprint
  56. with app.test_request_context(
  57. "/", headers=headers, environ_overrides={"REMOTE_ADDR": "203.0.113.7"}
  58. ), pytest.raises(EdgeGatewayAuthenticationError, match="trusted mTLS proxy"):
  59. _tls_certificate_sha256()
  60. with app.test_request_context(
  61. "/",
  62. headers={**headers, "X-DataOps-Edge-Client-Cert": "forged"},
  63. environ_overrides={"REMOTE_ADDR": "172.31.0.10"},
  64. ), pytest.raises(EdgeGatewayAuthenticationError, match="certificate"):
  65. _tls_certificate_sha256()
  66. def task(**changes):
  67. value = {
  68. "task_id": "task-1",
  69. "gateway_id": "gateway-1",
  70. "environment": "production",
  71. "network_zone": "zone-a",
  72. "purpose": "governed-inventory",
  73. "classification": "statistics",
  74. "task_type": "profile",
  75. "contract_version": 1,
  76. "deadline_at": (datetime.now(UTC) + timedelta(minutes=5)).isoformat().replace("+00:00", "Z"),
  77. "attempt": 1,
  78. "idempotency_key": "idem-1",
  79. "policy_digest": POLICY_DIGEST,
  80. }
  81. value.update(changes)
  82. return value
  83. class NeverNetwork:
  84. def request(self, *args, **kwargs): # pragma: no cover - must not run
  85. raise AssertionError("network request must not be attempted")
  86. @pytest.mark.parametrize(
  87. "url,proxy",
  88. [
  89. ("http://control.enterprise.test", None),
  90. ("https://user@control.enterprise.test", None),
  91. ("https://control.enterprise.test.evil.test", None),
  92. ("https://control.enterprise.test", "https://evil.test"),
  93. ],
  94. )
  95. def test_transport_rejects_unapproved_destinations_before_network(url, proxy):
  96. with pytest.raises(EdgePolicyError):
  97. EdgeTransport(
  98. base_url=url,
  99. proxy_url=proxy,
  100. allowed_control_hosts={"control.enterprise.test"},
  101. allowed_proxy_hosts={"proxy.enterprise.test"},
  102. gateway_id="gateway-1",
  103. environment="production",
  104. network_zone="zone-a",
  105. generation=1,
  106. credential="dopg_example-credential",
  107. certificate_sha256="b" * 64,
  108. client=NeverNetwork(),
  109. client_certificate_path="/secure/edge-cert.pem",
  110. client_private_key_path="/secure/edge-key.pem",
  111. ca_bundle_path="/secure/ca.pem",
  112. )
  113. @pytest.mark.parametrize(
  114. "operation",
  115. ["shell", "python", "sql", "raw_export", "http", "../../bin/sh"],
  116. )
  117. def test_runner_adapter_rejects_unknown_or_malicious_task_types(operation):
  118. adapter = EdgeRunnerAdapter(lambda _node, _request, _cancel: {})
  119. value = task(task_type=operation)
  120. with pytest.raises((EdgeContractError, EdgePolicyError)):
  121. adapter.execute(EdgeTaskContract.from_mapping(value), lambda: False)
  122. @pytest.mark.parametrize(
  123. "result",
  124. [
  125. {"classification": "raw", "payload": {"count": 1}},
  126. {"classification": "statistics", "payload": {"sql": "SELECT * FROM secret"}},
  127. {"classification": "statistics", "payload": {"password": "hidden"}},
  128. {"classification": "statistics", "payload": {"rows": [{"id": 1}]}},
  129. {"classification": "statistics", "payload": {"count": 1}, "command": "sh"},
  130. ],
  131. )
  132. def test_runner_adapter_rejects_raw_secret_script_and_unknown_result_fields(result):
  133. adapter = EdgeRunnerAdapter(lambda _node, _request, _cancel: result)
  134. with pytest.raises(EdgePolicyError):
  135. adapter.execute(EdgeTaskContract.from_mapping(task()), lambda: False)
  136. def test_runner_adapter_rejects_recursive_and_cyclic_results():
  137. cyclic = {}
  138. cyclic["child"] = cyclic
  139. adapter = EdgeRunnerAdapter(
  140. lambda _node, _request, _cancel: {
  141. "classification": "statistics",
  142. "payload": cyclic,
  143. }
  144. )
  145. with pytest.raises(EdgePolicyError):
  146. adapter.execute(EdgeTaskContract.from_mapping(task()), lambda: False)
  147. def test_runner_adapter_rejects_independently_oversized_result():
  148. adapter = EdgeRunnerAdapter(
  149. lambda _node, _request, _cancel: {
  150. "classification": "statistics",
  151. "payload": {"summary": "x" * 33_000},
  152. }
  153. )
  154. with pytest.raises(EdgePolicyError):
  155. adapter.execute(EdgeTaskContract.from_mapping(task()), lambda: False)
  156. def test_runner_adapter_rejects_unallowlisted_purpose_before_local_handler():
  157. called = False
  158. def execute(_node, _request, _cancel):
  159. nonlocal called
  160. called = True
  161. return {"classification": "statistics", "payload": {"metric_count": 1}}
  162. adapter = EdgeRunnerAdapter(execute)
  163. with pytest.raises(EdgePolicyError, match="purpose"):
  164. adapter.execute(
  165. EdgeTaskContract.from_mapping(task(purpose="arbitrary-action")),
  166. lambda: False,
  167. )
  168. assert not called
  169. class Response:
  170. def __init__(self, status_code, content):
  171. self.status_code = status_code
  172. self.headers = {"Content-Length": str(len(content))}
  173. self._content = content
  174. def iter_content(self, chunk_size):
  175. yield self._content
  176. class RecordingClient:
  177. def __init__(self, response):
  178. self.response = response
  179. self.calls = []
  180. def request(self, *args, **kwargs):
  181. self.calls.append((args, kwargs))
  182. return self.response
  183. class SequencedClient:
  184. def __init__(self, responses):
  185. self.responses = list(responses)
  186. self.calls = []
  187. def request(self, *args, **kwargs):
  188. self.calls.append((args, kwargs))
  189. return self.responses.pop(0)
  190. def _transport(client):
  191. return EdgeTransport(
  192. base_url="https://control.enterprise.test",
  193. allowed_control_hosts={"control.enterprise.test"},
  194. gateway_id="gateway-1",
  195. environment="production",
  196. network_zone="zone-a",
  197. generation=1,
  198. credential="dopg_example-credential",
  199. certificate_sha256="b" * 64,
  200. client=client,
  201. client_certificate_path="/secure/edge-cert.pem",
  202. client_private_key_path="/secure/edge-key.pem",
  203. ca_bundle_path="/secure/ca.pem",
  204. )
  205. def test_transport_uses_exact_bound_headers_and_forbids_redirect_following():
  206. client = RecordingClient(Response(200, b'{"code":200,"message":"ok","data":{"task":null}}'))
  207. assert _transport(client).pull_task() is None
  208. args, kwargs = client.calls[0]
  209. assert args == (
  210. "POST",
  211. "https://control.enterprise.test/api/datasource/edge/gateways/gateway-1/tasks/pull",
  212. )
  213. assert kwargs["allow_redirects"] is False
  214. assert kwargs["headers"] == {
  215. "Accept": "application/json",
  216. "Content-Type": "application/json",
  217. "X-Edge-Credential": "dopg_example-credential",
  218. "X-Edge-Certificate-SHA256": "b" * 64,
  219. }
  220. @pytest.mark.parametrize("status", [301, 302, 307, 308])
  221. def test_transport_rejects_all_redirects(status):
  222. with pytest.raises(EdgeTransportError, match="redirects"):
  223. _transport(RecordingClient(Response(status, b""))).pull_task()
  224. @pytest.mark.parametrize("status", [401, 403])
  225. def test_transport_stops_on_revoked_or_expired_binding(status):
  226. with pytest.raises(EdgeAuthenticationStopped):
  227. _transport(RecordingClient(Response(status, b'{}'))).reconcile()
  228. def test_transport_rejects_oversized_response_before_json_decode():
  229. with pytest.raises(EdgeTransportError, match="byte limit"):
  230. _transport(
  231. RecordingClient(Response(200, b"x" * (EdgeTransport.MAX_RESPONSE_BYTES + 1)))
  232. ).reconcile()
  233. def test_transport_cancel_probe_uses_bounded_reconcile_contract():
  234. client = RecordingClient(
  235. Response(
  236. 200,
  237. b'{"code":200,"message":"ok","data":{"cancelled_task_ids":["task-1"],"cancel_next_cursor":null,"release_offers":[],"release_next_cursor":null,"release_baseline":null}}',
  238. )
  239. )
  240. assert _transport(client).cancel_requested("task-1") is True
  241. def test_transport_reconcile_drains_all_stable_cursor_pages():
  242. first_ids = [f"task-{index:02d}" for index in range(50)]
  243. first = {
  244. "code": 200,
  245. "message": "ok",
  246. "data": {
  247. "cancelled_task_ids": first_ids,
  248. "cancel_next_cursor": first_ids[-1],
  249. "release_offers": [],
  250. "release_next_cursor": None,
  251. "release_baseline": None,
  252. },
  253. }
  254. second = {
  255. "code": 200,
  256. "message": "ok",
  257. "data": {
  258. "cancelled_task_ids": ["task-50"],
  259. "cancel_next_cursor": None,
  260. "release_offers": [],
  261. "release_next_cursor": None,
  262. "release_baseline": None,
  263. },
  264. }
  265. client = SequencedClient(
  266. [
  267. Response(200, json.dumps(first, separators=(",", ":")).encode()),
  268. Response(200, json.dumps(second, separators=(",", ":")).encode()),
  269. ]
  270. )
  271. reconciled = _transport(client).reconcile()
  272. assert reconciled == {
  273. "cancelled_task_ids": [*first_ids, "task-50"],
  274. "release_offers": [],
  275. }
  276. second_payload = json.loads(client.calls[1][1]["data"])
  277. assert second_payload["cancel_cursor"] == first_ids[-1]
  278. def _mtls_files(tmp_path: Path):
  279. key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  280. name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "edge-gateway-1")])
  281. now = datetime.now(UTC)
  282. cert = (
  283. x509.CertificateBuilder()
  284. .subject_name(name)
  285. .issuer_name(name)
  286. .public_key(key.public_key())
  287. .serial_number(x509.random_serial_number())
  288. .not_valid_before(now - timedelta(minutes=1))
  289. .not_valid_after(now + timedelta(days=1))
  290. .sign(key, hashes.SHA256())
  291. )
  292. cert_path = tmp_path / "edge-cert.pem"
  293. key_path = tmp_path / "edge-key.pem"
  294. ca_path = tmp_path / "enterprise-ca.pem"
  295. cert_pem = cert.public_bytes(serialization.Encoding.PEM)
  296. cert_path.write_bytes(cert_pem)
  297. ca_path.write_bytes(cert_pem)
  298. key_path.write_bytes(
  299. key.private_bytes(
  300. serialization.Encoding.PEM,
  301. serialization.PrivateFormat.PKCS8,
  302. serialization.NoEncryption(),
  303. )
  304. )
  305. key_path.chmod(0o600)
  306. return cert_path, key_path, ca_path, cert.fingerprint(hashes.SHA256()).hex()
  307. def test_bootstrap_requires_real_mtls_material_and_matching_fingerprint(tmp_path):
  308. cert, key, ca, fingerprint = _mtls_files(tmp_path)
  309. value = {
  310. "queue_path": str(tmp_path / "edge.sqlite3"),
  311. "artifact_root": str(tmp_path),
  312. "gateway_id": "gateway-1",
  313. "credential": "dopg_local-test-credential",
  314. "certificate_sha256": fingerprint,
  315. "client_certificate_path": str(cert),
  316. "client_private_key_path": str(key),
  317. "ca_bundle_path": str(ca),
  318. "generation": 1,
  319. "environment": "production",
  320. "network_zone": "zone-a",
  321. "policy_digest": "a" * 64,
  322. "control_url": "https://control.enterprise.test",
  323. "proxy_url": None,
  324. "allowed_control_hosts": ["control.enterprise.test"],
  325. "allowed_proxy_hosts": [],
  326. "trusted_release_keys": {"release-key-1": "01" * 32},
  327. "trusted_task_keys": {"task-key-1": "02" * 32},
  328. "task_authority_clock_skew_seconds": 60,
  329. "version": "3.0.0",
  330. }
  331. config = EdgeBootstrapConfig.from_mapping(value)
  332. assert config.certificate_sha256 == fingerprint
  333. with pytest.raises(ValueError, match="fingerprint"):
  334. EdgeBootstrapConfig.from_mapping({**value, "certificate_sha256": "f" * 64})
  335. @pytest.mark.parametrize(
  336. "url,host",
  337. [
  338. ("https://control.enterprise.test:444", "control.enterprise.test"),
  339. ("https://127.0.0.1", "127.0.0.1"),
  340. ("https://éxample.test", "éxample.test"),
  341. ],
  342. )
  343. def test_transport_rejects_unapproved_port_ip_literal_and_unicode_host(url, host):
  344. with pytest.raises((ValueError, EdgePolicyError)):
  345. EdgeTransport(
  346. base_url=url,
  347. allowed_control_hosts={host},
  348. gateway_id="gateway-1",
  349. environment="production",
  350. network_zone="zone-a",
  351. generation=1,
  352. credential="dopg_example-credential",
  353. certificate_sha256="b" * 64,
  354. client=NeverNetwork(),
  355. client_certificate_path="/secure/edge-cert.pem",
  356. client_private_key_path="/secure/edge-key.pem",
  357. ca_bundle_path="/secure/ca.pem",
  358. )
  359. class StreamingResponse:
  360. def __init__(self, chunks, *, content_length=None):
  361. self.status_code = 200
  362. self.headers = {}
  363. if content_length is not None:
  364. self.headers["Content-Length"] = str(content_length)
  365. self._chunks = chunks
  366. @property
  367. def content(self): # pragma: no cover - accessing this is the bug
  368. raise AssertionError("streaming transport must not buffer response.content")
  369. def iter_content(self, chunk_size):
  370. assert chunk_size <= 65_536
  371. yield from self._chunks
  372. class CloseTrackingResponse(StreamingResponse):
  373. def __init__(self, chunks, *, status_code=200, content_length=None, read_error=None):
  374. super().__init__(chunks, content_length=content_length)
  375. self.status_code = status_code
  376. self.read_error = read_error
  377. self.close_count = 0
  378. def iter_content(self, chunk_size):
  379. if self.read_error is not None:
  380. raise self.read_error
  381. yield from super().iter_content(chunk_size)
  382. def close(self):
  383. self.close_count += 1
  384. @pytest.mark.parametrize(
  385. "response,expected_exception",
  386. [
  387. (
  388. CloseTrackingResponse(
  389. [b'{"code":200,"message":"ok","data":{"task":null}}']
  390. ),
  391. None,
  392. ),
  393. (CloseTrackingResponse([], status_code=302), EdgeTransportError),
  394. (CloseTrackingResponse([b"{}"], status_code=401), EdgeAuthenticationStopped),
  395. (CloseTrackingResponse([b"{}"], status_code=500), EdgeTransportError),
  396. (
  397. CloseTrackingResponse([], content_length=EdgeTransport.MAX_RESPONSE_BYTES + 1),
  398. EdgeTransportError,
  399. ),
  400. (
  401. CloseTrackingResponse(
  402. [b"x" * (EdgeTransport.MAX_RESPONSE_BYTES + 1)], content_length=1
  403. ),
  404. EdgeTransportError,
  405. ),
  406. (
  407. CloseTrackingResponse([], read_error=TimeoutError("slow stream")),
  408. EdgeTransportError,
  409. ),
  410. ],
  411. )
  412. def test_transport_closes_streamed_response_exactly_once_on_every_exit(
  413. response, expected_exception
  414. ):
  415. transport = _transport(RecordingClient(response))
  416. if expected_exception is None:
  417. assert transport.pull_task() is None
  418. else:
  419. with pytest.raises(expected_exception):
  420. transport.pull_task()
  421. assert response.close_count == 1
  422. def test_transport_streams_with_mtls_and_rejects_deceptive_chunk_overflow():
  423. body = b'{"code":200,"message":"ok","data":{"task":null}}'
  424. client = RecordingClient(StreamingResponse([body], content_length=len(body)))
  425. transport = EdgeTransport(
  426. base_url="https://control.enterprise.test",
  427. allowed_control_hosts={"control.enterprise.test"},
  428. gateway_id="gateway-1",
  429. environment="production",
  430. network_zone="zone-a",
  431. generation=1,
  432. credential="dopg_example-credential",
  433. certificate_sha256="b" * 64,
  434. client=client,
  435. client_certificate_path="/secure/edge-cert.pem",
  436. client_private_key_path="/secure/edge-key.pem",
  437. ca_bundle_path="/secure/ca.pem",
  438. )
  439. assert transport.pull_task() is None
  440. _, kwargs = client.calls[0]
  441. assert kwargs["stream"] is True
  442. assert kwargs["cert"] == ("/secure/edge-cert.pem", "/secure/edge-key.pem")
  443. assert kwargs["verify"] == "/secure/ca.pem"
  444. assert kwargs["timeout"] == (5, 20)
  445. overflow = RecordingClient(
  446. StreamingResponse(
  447. [b"x" * 700_000, b"y" * 700_000],
  448. content_length=1,
  449. )
  450. )
  451. bad = EdgeTransport(
  452. base_url="https://control.enterprise.test",
  453. allowed_control_hosts={"control.enterprise.test"},
  454. gateway_id="gateway-1",
  455. environment="production",
  456. network_zone="zone-a",
  457. generation=1,
  458. credential="dopg_example-credential",
  459. certificate_sha256="b" * 64,
  460. client=overflow,
  461. client_certificate_path="/secure/edge-cert.pem",
  462. client_private_key_path="/secure/edge-key.pem",
  463. ca_bundle_path="/secure/ca.pem",
  464. )
  465. with pytest.raises(EdgeTransportError, match="byte limit"):
  466. bad.pull_task()
  467. def test_runner_bridge_has_exact_local_only_schema():
  468. request = {
  469. "task_id": "task-1",
  470. "operation": "profile",
  471. "purpose": "inventory",
  472. "classification": "statistics",
  473. "environment": "production",
  474. "network_zone": "zone-a",
  475. "idempotency_key": "idem-1",
  476. "deadline_at": "2030-01-01T00:00:00Z",
  477. }
  478. handlers = {"edge.profile": lambda value, _cancel: {"seen": sorted(value)}}
  479. assert execute_edge_adapter(handlers, "edge.profile", request, lambda: False) == {
  480. "seen": sorted(request)
  481. }
  482. with pytest.raises(NodeExecutionError):
  483. execute_edge_adapter(handlers, "python", request, lambda: False)
  484. with pytest.raises(NodeExecutionError):
  485. execute_edge_adapter(
  486. handlers,
  487. "edge.profile",
  488. {**request, "sql": "SELECT 1"},
  489. lambda: False,
  490. )