#!/usr/bin/env python3 """Exercise public application checks against an isolated local TLS server.""" import base64 import hashlib import http.server import importlib.util from pathlib import Path import socket import ssl import subprocess import tempfile import threading import unittest from unittest.mock import patch spec = importlib.util.spec_from_file_location("public_app", Path(__file__).with_name("check-public-tunnel-app.py")) check = importlib.util.module_from_spec(spec) spec.loader.exec_module(check) class Handler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_GET(self): self.server.requests.append((self.path, dict(self.headers))) if self.path == "/app": body = b"fixture-a" self.send_response(self.server.http_status) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) return key = self.headers["Sec-WebSocket-Key"] accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode() if self.server.bad_accept: accept = "wrong" self.send_response(101) self.send_header("Connection", "Upgrade") self.send_header("Upgrade", "websocket") self.send_header("Sec-WebSocket-Accept", accept) self.end_headers() if self.server.bad_accept: return first, size = check.read_exact(self.rfile, 2) if first != 0x81 or not size & 0x80: return mask = check.read_exact(self.rfile, 4) payload = check.read_exact(self.rfile, size & 0x7f) message = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) if self.server.bad_echo: message = b"x" * len(message) self.wfile.write(bytes((0x81, len(message))) + message) self.close_connection = True def log_message(self, *args): pass class PublicAppCheck(unittest.TestCase): @classmethod def setUpClass(cls): cls.directory = tempfile.TemporaryDirectory() cert = Path(cls.directory.name) / "cert.pem" key = Path(cls.directory.name) / "key.pem" subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", str(key), "-out", str(cert), "-days", "1", "-subj", "/CN=qa-a.firecrab.dev", "-addext", "subjectAltName=DNS:qa-a.firecrab.dev"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) cls.context = ssl.create_default_context(cafile=str(cert)) server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) server_context.load_cert_chain(cert, key) cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) cls.server.socket = server_context.wrap_socket(cls.server.socket, server_side=True) cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) cls.thread.start() @classmethod def tearDownClass(cls): cls.server.shutdown() cls.server.server_close() cls.thread.join() cls.directory.cleanup() def setUp(self): self.server.requests = [] self.server.http_status = 200 self.server.bad_accept = False self.server.bad_echo = False original_connect = socket.create_connection def connect(address, *args, **kwargs): self.assertEqual(address[1], 443) return original_connect(("127.0.0.1", self.server.server_port), *args, **kwargs) self.connection_patch = patch.object(socket, "create_connection", side_effect=connect) self.connection_patch.start() self.addCleanup(self.connection_patch.stop) def test_http_and_websocket_round_trip_over_verified_tls(self): check.check_https("qa-a.firecrab.dev", "/app", b"fixture-a", self.context) check.check_websocket("qa-a.firecrab.dev", "/ws", self.context) self.assertEqual([item[0] for item in self.server.requests], ["/app", "/ws"]) self.assertTrue(all("Authorization" not in headers for _, headers in self.server.requests)) def test_unexpected_status_body_and_certificate_fail(self): self.server.http_status = 302 with self.assertRaises(check.CheckError): check.check_https("qa-a.firecrab.dev", "/app", b"fixture-a", self.context) self.server.http_status = 200 with self.assertRaises(check.CheckError): check.check_https("qa-a.firecrab.dev", "/app", b"fixture-b", self.context) with self.assertRaises(check.CheckError): check.check_https("qa-b.firecrab.dev", "/app", b"fixture-a", self.context) def test_invalid_upgrade_and_wrong_echo_fail(self): self.server.bad_accept = True with self.assertRaises(check.CheckError): check.check_websocket("qa-a.firecrab.dev", "/ws", self.context) self.server.bad_accept = False self.server.bad_echo = True with self.assertRaises(check.CheckError): check.check_websocket("qa-a.firecrab.dev", "/ws", self.context) def test_rejects_unowned_address_shapes_and_unsafe_paths(self): for label in ["console", "qa-", "qa-a.firecrab.dev", "qa-a\r\nX: b", "qa-A", "qa-" + "a" * 61]: with self.subTest(label=label), self.assertRaises(check.CheckError): check.validate_host(label) for path in ["//evil?token=a", "/ws#x", "/ws\r\nX: b", "ws"]: with self.subTest(path=path), self.assertRaises(check.CheckError): check.validate_path(path) self.assertEqual(check.validate_host("qa-test-1"), "qa-test-1.firecrab.dev") if __name__ == "__main__": unittest.main()