test_phase3_wp04_nginx_mtls.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. """Actual Nginx mTLS handshake test using only temporary certificates."""
  2. from __future__ import annotations
  3. import ipaddress
  4. import shutil
  5. import socket
  6. import subprocess
  7. import time
  8. import uuid
  9. from datetime import UTC, datetime, timedelta
  10. from pathlib import Path
  11. import pytest
  12. from cryptography import x509
  13. from cryptography.hazmat.primitives import hashes, serialization
  14. from cryptography.hazmat.primitives.asymmetric import rsa
  15. from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
  16. ROOT = Path(__file__).resolve().parents[2]
  17. def _certificate(
  18. common_name: str,
  19. *,
  20. issuer_certificate=None,
  21. issuer_key=None,
  22. client: bool = False,
  23. ):
  24. key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  25. name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
  26. issuer_certificate = issuer_certificate or None
  27. issuer_key = issuer_key or key
  28. now = datetime.now(UTC)
  29. builder = (
  30. x509.CertificateBuilder()
  31. .subject_name(name)
  32. .issuer_name(issuer_certificate.subject if issuer_certificate else name)
  33. .public_key(key.public_key())
  34. .serial_number(x509.random_serial_number())
  35. .not_valid_before(now - timedelta(minutes=1))
  36. .not_valid_after(now + timedelta(minutes=10))
  37. .add_extension(x509.BasicConstraints(ca=issuer_certificate is None, path_length=None), critical=True)
  38. )
  39. if issuer_certificate is not None:
  40. usage = ExtendedKeyUsageOID.CLIENT_AUTH if client else ExtendedKeyUsageOID.SERVER_AUTH
  41. builder = builder.add_extension(x509.ExtendedKeyUsage([usage]), critical=False)
  42. if not client:
  43. builder = builder.add_extension(
  44. x509.SubjectAlternativeName(
  45. [
  46. x509.DNSName("localhost"),
  47. x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
  48. ]
  49. ),
  50. critical=False,
  51. )
  52. return key, builder.sign(issuer_key, hashes.SHA256())
  53. def _write_material(path: Path, name: str, key, certificate):
  54. key_path = path / f"{name}.key"
  55. certificate_path = path / f"{name}.crt"
  56. key_path.write_bytes(
  57. key.private_bytes(
  58. serialization.Encoding.PEM,
  59. serialization.PrivateFormat.PKCS8,
  60. serialization.NoEncryption(),
  61. )
  62. )
  63. certificate_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
  64. return certificate_path, key_path
  65. @pytest.mark.integration
  66. def test_actual_nginx_rejects_missing_wrong_and_forged_client_certificate(tmp_path):
  67. if shutil.which("docker") is None or shutil.which("curl") is None:
  68. pytest.skip("Docker and curl are required for the actual Nginx mTLS test")
  69. image = subprocess.run(
  70. ["docker", "image", "inspect", "nginx:alpine"],
  71. capture_output=True,
  72. check=False,
  73. )
  74. if image.returncode != 0:
  75. pytest.skip("the local nginx:alpine image is required; this test never pulls")
  76. ca_key, ca_certificate = _certificate("DataOps test client CA")
  77. server_key, server_certificate = _certificate(
  78. "localhost", issuer_certificate=ca_certificate, issuer_key=ca_key
  79. )
  80. client_key, client_certificate = _certificate(
  81. "gateway-valid",
  82. issuer_certificate=ca_certificate,
  83. issuer_key=ca_key,
  84. client=True,
  85. )
  86. revoked_key, revoked_certificate = _certificate(
  87. "gateway-revoked",
  88. issuer_certificate=ca_certificate,
  89. issuer_key=ca_key,
  90. client=True,
  91. )
  92. wrong_ca_key, wrong_ca_certificate = _certificate("Wrong client CA")
  93. wrong_key, wrong_certificate = _certificate(
  94. "gateway-wrong",
  95. issuer_certificate=wrong_ca_certificate,
  96. issuer_key=wrong_ca_key,
  97. client=True,
  98. )
  99. ca_path, _ = _write_material(tmp_path, "client-ca", ca_key, ca_certificate)
  100. server_path, server_key_path = _write_material(
  101. tmp_path, "server", server_key, server_certificate
  102. )
  103. client_path, client_key_path = _write_material(
  104. tmp_path, "client", client_key, client_certificate
  105. )
  106. wrong_path, wrong_key_path = _write_material(
  107. tmp_path, "wrong", wrong_key, wrong_certificate
  108. )
  109. revoked_path, revoked_key_path = _write_material(
  110. tmp_path, "revoked", revoked_key, revoked_certificate
  111. )
  112. now = datetime.now(UTC)
  113. revoked_entry = (
  114. x509.RevokedCertificateBuilder()
  115. .serial_number(revoked_certificate.serial_number)
  116. .revocation_date(now - timedelta(seconds=30))
  117. .build()
  118. )
  119. crl = (
  120. x509.CertificateRevocationListBuilder()
  121. .issuer_name(ca_certificate.subject)
  122. .last_update(now - timedelta(minutes=1))
  123. .next_update(now + timedelta(minutes=10))
  124. .add_revoked_certificate(revoked_entry)
  125. .sign(private_key=ca_key, algorithm=hashes.SHA256())
  126. )
  127. production = (ROOT / "deploy/docker/edge-mtls-nginx.conf").read_text()
  128. production = production.replace("http://backend:5500", "http://127.0.0.1:8080")
  129. upstream = """
  130. server {
  131. listen 8080;
  132. location / {
  133. add_header X-Seen-Verify $http_x_dataops_edge_client_verify always;
  134. add_header X-Seen-Cert $http_x_dataops_edge_client_cert always;
  135. return 204;
  136. }
  137. }
  138. """
  139. (tmp_path / "nginx.conf").write_text(production + upstream)
  140. (tmp_path / "server.crt").write_bytes(server_path.read_bytes())
  141. (tmp_path / "server.key").write_bytes(server_key_path.read_bytes())
  142. (tmp_path / "client-ca.pem").write_bytes(ca_path.read_bytes())
  143. (tmp_path / "client-ca.crl").write_bytes(
  144. crl.public_bytes(serialization.Encoding.PEM)
  145. )
  146. valid_crl = (tmp_path / "client-ca.crl").read_bytes()
  147. for invalid_crl in (None, b"not-a-crl"):
  148. if invalid_crl is None:
  149. (tmp_path / "client-ca.crl").unlink()
  150. else:
  151. (tmp_path / "client-ca.crl").write_bytes(invalid_crl)
  152. invalid = subprocess.run(
  153. [
  154. "docker", "run", "--rm",
  155. "--volume", f"{tmp_path / 'nginx.conf'}:/etc/nginx/conf.d/default.conf:ro",
  156. "--volume", f"{tmp_path}:/etc/nginx/edge-mtls:ro",
  157. "nginx:alpine", "nginx", "-t",
  158. ],
  159. capture_output=True,
  160. check=False,
  161. )
  162. assert invalid.returncode != 0
  163. (tmp_path / "client-ca.crl").write_bytes(valid_crl)
  164. expired_crl = (
  165. x509.CertificateRevocationListBuilder()
  166. .issuer_name(ca_certificate.subject)
  167. .last_update(now - timedelta(minutes=10))
  168. .next_update(now - timedelta(minutes=1))
  169. .sign(private_key=ca_key, algorithm=hashes.SHA256())
  170. )
  171. (tmp_path / "client-ca.crl").write_bytes(
  172. expired_crl.public_bytes(serialization.Encoding.PEM)
  173. )
  174. with socket.socket() as expired_probe:
  175. expired_probe.bind(("127.0.0.1", 0))
  176. expired_port = expired_probe.getsockname()[1]
  177. expired_name = f"dataops-edge-expired-crl-{uuid.uuid4().hex[:10]}"
  178. subprocess.run(
  179. [
  180. "docker", "run", "--detach", "--rm", "--name", expired_name,
  181. "--publish", f"127.0.0.1:{expired_port}:8443",
  182. "--volume", f"{tmp_path / 'nginx.conf'}:/etc/nginx/conf.d/default.conf:ro",
  183. "--volume", f"{tmp_path}:/etc/nginx/edge-mtls:ro",
  184. "nginx:alpine",
  185. ],
  186. check=True,
  187. capture_output=True,
  188. )
  189. try:
  190. time.sleep(0.2)
  191. expired = subprocess.run(
  192. [
  193. "curl", "--silent", "--show-error", "--max-time", "3",
  194. "--cacert", str(ca_path), "--cert", str(client_path),
  195. "--key", str(client_key_path), "--output", "/dev/null",
  196. "--write-out", "%{http_code}",
  197. f"https://127.0.0.1:{expired_port}/api/datasource/edge/test",
  198. ],
  199. capture_output=True,
  200. text=True,
  201. check=False,
  202. )
  203. assert expired.returncode != 0 or expired.stdout in {"400", "495", "496"}
  204. finally:
  205. subprocess.run(
  206. ["docker", "stop", expired_name], check=False, capture_output=True
  207. )
  208. (tmp_path / "client-ca.crl").write_bytes(valid_crl)
  209. with socket.socket() as probe:
  210. probe.bind(("127.0.0.1", 0))
  211. port = probe.getsockname()[1]
  212. name = f"dataops-edge-mtls-{uuid.uuid4().hex[:12]}"
  213. subprocess.run(
  214. [
  215. "docker", "run", "--detach", "--rm", "--name", name,
  216. "--publish", f"127.0.0.1:{port}:8443",
  217. "--volume", f"{tmp_path / 'nginx.conf'}:/etc/nginx/conf.d/default.conf:ro",
  218. "--volume", f"{tmp_path}:/etc/nginx/edge-mtls:ro",
  219. "nginx:alpine",
  220. ],
  221. check=True,
  222. capture_output=True,
  223. )
  224. try:
  225. for _ in range(40):
  226. try:
  227. with socket.create_connection(("127.0.0.1", port), timeout=0.2):
  228. break
  229. except OSError:
  230. time.sleep(0.05)
  231. else:
  232. raise AssertionError("temporary Nginx did not start")
  233. endpoint = f"https://127.0.0.1:{port}/api/datasource/edge/test"
  234. for certificate_path, key_path in (
  235. (None, None),
  236. (wrong_path, wrong_key_path),
  237. (revoked_path, revoked_key_path),
  238. ):
  239. command = [
  240. "curl", "--silent", "--show-error", "--max-time", "3",
  241. "--cacert", str(ca_path), "--output", "/dev/null",
  242. "--write-out", "%{http_code}", endpoint,
  243. ]
  244. if certificate_path is not None:
  245. command[1:1] = [
  246. "--cert", str(certificate_path), "--key", str(key_path)
  247. ]
  248. rejected = subprocess.run(command, capture_output=True, text=True, check=False)
  249. assert rejected.returncode != 0 or rejected.stdout in {"400", "495", "496"}
  250. valid = subprocess.run(
  251. [
  252. "curl", "--silent", "--show-error", "--max-time", "3",
  253. "--cacert", str(ca_path), "--cert", str(client_path),
  254. "--key", str(client_key_path), "--dump-header", "-",
  255. "--output", "/dev/null",
  256. "--header", "X-DataOps-Edge-Client-Verify: FORGED",
  257. "--header", "X-DataOps-Edge-Client-Cert: forged",
  258. endpoint,
  259. ],
  260. capture_output=True,
  261. text=True,
  262. check=False,
  263. )
  264. assert valid.returncode == 0, valid.stderr
  265. assert "HTTP/1.1 204" in valid.stdout
  266. assert "X-Seen-Verify: SUCCESS" in valid.stdout
  267. assert "X-Seen-Cert: -----BEGIN%20CERTIFICATE-----" in valid.stdout
  268. assert "X-Seen-Cert: forged" not in valid.stdout
  269. finally:
  270. subprocess.run(
  271. ["docker", "stop", name],
  272. check=False,
  273. capture_output=True,
  274. )