"""A QUIC service for firegex to protect, and a client that talks to it through firegex. Deliberately HTTP/3 rather than some invented protocol over QUIC. That is what a QUIC service is in practice, it is the one the engine terminates rather than relays, and it is the only shape in which "the filters saw the request" means anything: on the wire a path is a QPACK-compressed header block, and what the chain is shown is the HTTP/1.1 that exchange would have been. `aioquic` is the only way to speak QUIC from Python, and it is not a dependency anybody should need to have installed to run the rest of the suite — so it is imported here and the tests that need it skip with a reason when it is missing, like every other thing this machine may decline to do. """ import asyncio import ssl import threading import pytest try: from aioquic.asyncio import serve from aioquic.asyncio.client import connect from aioquic.asyncio.protocol import QuicConnectionProtocol from aioquic.h3.connection import H3_ALPN, H3Connection from aioquic.h3.events import DataReceived, HeadersReceived from aioquic.quic.configuration import QuicConfiguration from aioquic.quic.events import ConnectionTerminated, StreamDataReceived HAVE_AIOQUIC = True except ImportError: # pragma: no cover - depends on what is installed HAVE_AIOQUIC = False needs_quic = pytest.mark.skipif( not HAVE_AIOQUIC, reason="aioquic is not installed, so nothing here can speak QUIC", ) #: What the service answers on this path, and nowhere else. The point of having it is #: that a test of the *answer* has to use bytes that appear nowhere in the request. SECRET_PATH = "/secret" SECRET_ANSWER = "here is FLAG{only-in-the-answer}" class QuicEcho: """An HTTP/3 service that answers with what it was asked. A thread running an event loop of its own, because aioquic is asyncio and the rest of the suite is not. Stopping it closes the loop, which is what ends the server. """ def __init__(self, port: int, cert: str, key: str, ipv6: bool = False, alpn: list[str] | None = None): self.port = port self.cert = cert self.key = key self.ipv6 = ipv6 #: What it negotiates. HTTP/3 unless told otherwise; anything else is a service #: that echoes each stream back as it is — QUIC carrying a protocol of its own, #: which is what a CTF's QUIC service usually is when it is not a web one. self.alpn = alpn #: Every address a connection arrived from. What the *service* saw, which is the #: only way to check that the client's own address survived the relay. self.seen_peers: list[tuple] = [] self._loop: asyncio.AbstractEventLoop | None = None self._thread: threading.Thread | None = None self._ready = threading.Event() @property def host(self) -> str: return "::1" if self.ipv6 else "127.0.0.1" def start(self): self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() if not self._ready.wait(timeout=10): raise RuntimeError("the QUIC stand-in did not come up") def _run(self): self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) self._loop.run_until_complete(self._serve()) self._loop.run_forever() async def _serve(self): import tempfile import os # aioquic reads them from disk, and a key written world-readable is a key # somebody else on the machine can have. paths = [] for material in (self.cert, self.key): fd, path = tempfile.mkstemp() with os.fdopen(fd, "w") as handle: handle.write(material) os.chmod(path, 0o600) paths.append(path) config = QuicConfiguration(is_client=False, alpn_protocols=self.alpn or H3_ALPN) config.load_cert_chain(paths[0], paths[1]) seen = self.seen_peers protocol = _EchoProtocol if not self.alpn else _RawEchoProtocol await serve(self.host, self.port, configuration=config, create_protocol=lambda *a, **kw: protocol(seen, *a, **kw)) for path in paths: os.unlink(path) self._ready.set() def stop(self): if self._loop is not None: self._loop.call_soon_threadsafe(self._loop.stop) if HAVE_AIOQUIC: class _EchoProtocol(QuicConnectionProtocol): def __init__(self, seen_peers, *args, **kwargs): super().__init__(*args, **kwargs) self._seen = seen_peers self._http = H3Connection(self._quic) self._bodies: dict[int, bytes] = {} self._paths: dict[int, str] = {} def quic_event_received(self, event): for http_event in self._http.handle_event(event): if isinstance(http_event, HeadersReceived): headers = dict(http_event.headers) self._paths[http_event.stream_id] = \ headers.get(b":path", b"/").decode() self._bodies[http_event.stream_id] = b"" if http_event.stream_ended: self._answer(http_event.stream_id) elif isinstance(http_event, DataReceived): self._bodies[http_event.stream_id] = \ self._bodies.get(http_event.stream_id, b"") + http_event.data if http_event.stream_ended: self._answer(http_event.stream_id) def _answer(self, stream_id: int): path = self._paths.get(stream_id, "/") body = self._bodies.get(stream_id, b"") peer = getattr(self._quic, "_network_paths", None) if peer: self._seen.append(peer[0].addr) answer = (SECRET_ANSWER if path == SECRET_PATH else f"you asked for {path} and sent {len(body)} bytes: " f"{body.decode(errors='replace')}").encode() self._http.send_headers(stream_id, [ (b":status", b"200"), (b"content-length", str(len(answer)).encode()), ]) self._http.send_data(stream_id, answer, end_stream=True) self.transmit() class _RawEchoProtocol(QuicConnectionProtocol): """Each stream back as it came, once the client has finished sending it.""" def __init__(self, seen_peers, *args, **kwargs): super().__init__(*args, **kwargs) self._streams: dict[int, bytes] = {} def quic_event_received(self, event): if isinstance(event, StreamDataReceived): self._streams[event.stream_id] = \ self._streams.get(event.stream_id, b"") + event.data if event.end_stream: self._quic.send_stream_data( event.stream_id, self._streams.pop(event.stream_id), end_stream=True) self.transmit() class _RequestingProtocol(QuicConnectionProtocol): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._http = H3Connection(self._quic) self._waiters: dict[int, asyncio.Future] = {} self._bodies: dict[int, bytes] = {} async def ask(self, method: str, authority: str, path: str, body: bytes | None = None, declare_length: bool = False) -> str: stream_id = self._quic.get_next_available_stream_id() headers = [ (b":method", method.encode()), (b":scheme", b"https"), (b":authority", authority.encode()), (b":path", path.encode()), ] if declare_length: headers.append((b"content-length", str(len(body or b"")).encode())) self._http.send_headers(stream_id, headers, end_stream=body is None) if body is not None: self._http.send_data(stream_id, body, end_stream=True) waiter = asyncio.get_event_loop().create_future() self._waiters[stream_id] = waiter self._bodies[stream_id] = b"" self.transmit() return await asyncio.shield(waiter) def quic_event_received(self, event): if isinstance(event, ConnectionTerminated): # What a blocked exchange looks like from here: the connection carrying # it is closed, with firegex's reason on it. for waiter in self._waiters.values(): if not waiter.done(): waiter.set_exception( ConnectionError(f"closed: {event.reason_phrase or event.error_code}") ) self._waiters.clear() return for http_event in self._http.handle_event(event): stream_id = getattr(http_event, "stream_id", None) if stream_id is None or stream_id not in self._waiters: continue if isinstance(http_event, DataReceived): self._bodies[stream_id] += http_event.data ended = getattr(http_event, "stream_ended", False) if ended and not self._waiters[stream_id].done(): self._waiters.pop(stream_id).set_result( self._bodies.pop(stream_id).decode(errors="replace") ) async def _ask(host: str, port: int, method: str, path: str, body: bytes | None, declare_length: bool, timeout: float) -> str: config = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN) # The certificate is firegex's own, minted for the test: there is nothing to verify # it against and nothing this proves by trying. config.verify_mode = ssl.CERT_NONE async with connect(host, port, configuration=config, create_protocol=_RequestingProtocol) as client: return await asyncio.wait_for( client.ask(method, authority(host, port), path, body, declare_length), timeout ) async def _raw(host: str, port: int, alpn: str, payload: bytes, timeout: float) -> bytes: config = QuicConfiguration(is_client=True, alpn_protocols=[alpn]) config.verify_mode = ssl.CERT_NONE async with connect(host, port, configuration=config) as client: reader, writer = await client.create_stream() writer.write(payload) writer.write_eof() return await asyncio.wait_for(reader.read(), timeout) def quic_exchange(host: str, port: int, alpn: str, payload: bytes, timeout: float = 8.0) -> bytes | None: """One stream over a QUIC connection negotiating `alpn`: what came back, or `None` when no connection could be made or nothing came back in time.""" try: return asyncio.run(asyncio.wait_for(_raw(host, port, alpn, payload, timeout), timeout + 2)) except (ConnectionError, OSError, asyncio.TimeoutError, TimeoutError): return None def authority(host: str, port: int) -> str: """What goes in `:authority`, bracketed where the host needs it. An IPv6 literal without brackets is not an authority, and the far end is right to refuse it: h3 answers `H3_MESSAGE_ERROR`, which arrives here as a connection that closed — indistinguishable from a filter having refused the request unless somebody looks at the log. So this is the client's business to get right, not something to make the engine tolerant of. """ return f"[{host}]:{port}" if ":" in host else f"{host}:{port}" def h3_request(host: str, port: int, path: str = "/", method: str = "GET", body: bytes | None = None, declare_length: bool = False, timeout: float = 8.0) -> str: """One HTTP/3 exchange. Raises if the connection was closed before an answer.""" return asyncio.run(_ask(host, port, method, path, body, declare_length, timeout)) def h3_is_blocked(host: str, port: int, path: str = "/", **kwargs) -> bool: """Whether the exchange was refused rather than answered. Only a closed connection counts. A refusal on this layer closes the connection carrying the stream, and that is what reaches here as `ConnectionError` — while a malformed request, an unreachable port and a timeout are all failures with nothing to do with a filter. Catching those too would let "the filter blocked it" pass for reasons that are not a block, which is the one assertion in this file that has to be exact; so everything else is raised and the test says what actually happened. """ try: h3_request(host, port, path, **kwargs) return False except ConnectionError: return True