diff --git a/mitmproxy/addons/__init__.py b/mitmproxy/addons/__init__.py index 074072872..c09c92298 100644 --- a/mitmproxy/addons/__init__.py +++ b/mitmproxy/addons/__init__.py @@ -25,7 +25,7 @@ from mitmproxy.addons import script from mitmproxy.addons import serverplayback from mitmproxy.addons import stickyauth from mitmproxy.addons import stickycookie -from mitmproxy.addons import strip_ech +from mitmproxy.addons import strip_dns_https_records from mitmproxy.addons import tlsconfig from mitmproxy.addons import update_alt_svc from mitmproxy.addons import upstream_auth @@ -36,7 +36,7 @@ def default_addons(): core.Core(), browser.Browser(), block.Block(), - strip_ech.StripECH(), + strip_dns_https_records.StripDnsHttpsRecords(), blocklist.BlockList(), anticache.AntiCache(), anticomp.AntiComp(), diff --git a/mitmproxy/addons/strip_dns_https_records.py b/mitmproxy/addons/strip_dns_https_records.py new file mode 100644 index 000000000..b43383426 --- /dev/null +++ b/mitmproxy/addons/strip_dns_https_records.py @@ -0,0 +1,37 @@ +from mitmproxy import ctx +from mitmproxy import dns +from mitmproxy.net.dns import types + + +class StripDnsHttpsRecords: + def load(self, loader): + loader.add_option( + "strip_ech", + bool, + True, + "Strip Encrypted ClientHello (ECH) data from DNS HTTPS records so that mitmproxy can generate matching certificates.", + ) + + def dns_response(self, flow: dns.DNSFlow): + assert flow.response + if ctx.options.strip_ech: + for answer in flow.response.answers: + if answer.type == types.HTTPS: + answer.https_ech = None + if not ctx.options.http3: + for answer in flow.response.answers: + if ( + answer.type == types.HTTPS + and answer.https_alpn is not None + and any( + # HTTP/3 or any of the spec drafts (h3-...)? + a == b"h3" or a.startswith(b"h3-") + for a in answer.https_alpn + ) + ): + alpns = tuple( + a + for a in answer.https_alpn + if a != b"h3" and not a.startswith(b"h3-") + ) + answer.https_alpn = alpns or None diff --git a/mitmproxy/addons/strip_ech.py b/mitmproxy/addons/strip_ech.py deleted file mode 100644 index 7360a170c..000000000 --- a/mitmproxy/addons/strip_ech.py +++ /dev/null @@ -1,20 +0,0 @@ -from mitmproxy import ctx -from mitmproxy import dns -from mitmproxy.net.dns import types - - -class StripECH: - def load(self, loader): - loader.add_option( - "strip_ech", - bool, - True, - "Strip DNS HTTPS records to prevent clients from sending Encrypted ClientHello (ECH) messages", - ) - - def dns_response(self, flow: dns.DNSFlow): - assert flow.response - if ctx.options.strip_ech: - for answer in flow.response.answers: - if answer.type == types.HTTPS: - answer.https_ech = None diff --git a/mitmproxy/dns.py b/mitmproxy/dns.py index 05452630e..cd79cdcb2 100644 --- a/mitmproxy/dns.py +++ b/mitmproxy/dns.py @@ -5,6 +5,7 @@ import itertools import random import struct import time +from collections.abc import Iterable from dataclasses import dataclass from ipaddress import IPv4Address from ipaddress import IPv6Address @@ -106,6 +107,31 @@ class ResourceRecord(serializable.SerializableDataclass): def domain_name(self, name: str) -> None: self.data = domain_names.pack(name) + @property + def https_alpn(self) -> tuple[bytes, ...] | None: + record = https_records.unpack(self.data) + alpn_bytes = record.params.get(SVCParamKeys.ALPN.value, None) + if alpn_bytes is not None: + i = 0 + ret = [] + while i < len(alpn_bytes): + token_len = alpn_bytes[i] + ret.append(alpn_bytes[i + 1 : i + 1 + token_len]) + i += token_len + 1 + return tuple(ret) + else: + return None + + @https_alpn.setter + def https_alpn(self, alpn: Iterable[bytes] | None) -> None: + record = https_records.unpack(self.data) + if alpn is None: + record.params.pop(SVCParamKeys.ALPN.value, None) + else: + alpn_bytes = b"".join(bytes([len(a)]) + a for a in alpn) + record.params[SVCParamKeys.ALPN.value] = alpn_bytes + self.data = https_records.pack(record) + @property def https_ech(self) -> str | None: record = https_records.unpack(self.data) diff --git a/test/mitmproxy/addons/test_strip_ech.py b/test/mitmproxy/addons/test_strip_dns_https_records.py similarity index 59% rename from test/mitmproxy/addons/test_strip_ech.py rename to test/mitmproxy/addons/test_strip_dns_https_records.py index a27c91f1f..1c80f78dc 100644 --- a/test/mitmproxy/addons/test_strip_ech.py +++ b/test/mitmproxy/addons/test_strip_dns_https_records.py @@ -1,5 +1,5 @@ from mitmproxy import dns -from mitmproxy.addons import strip_ech +from mitmproxy.addons import strip_dns_https_records from mitmproxy.net.dns import https_records from mitmproxy.net.dns import types from mitmproxy.net.dns.https_records import SVCParamKeys @@ -9,8 +9,8 @@ from mitmproxy.test import tutils class TestStripECH: - def test_simple(self): - se = strip_ech.StripECH() + def test_strip_ech(self): + se = strip_dns_https_records.StripDnsHttpsRecords() with taddons.context(se) as tctx: params1 = { SVCParamKeys.PORT.value: b"\x01\xbb", @@ -51,3 +51,35 @@ class TestStripECH: for answer in f.response.answers if answer.type == types.HTTPS ) + + def test_strip_alpn(self): + se = strip_dns_https_records.StripDnsHttpsRecords() + with taddons.context(se) as tctx: + record2 = https_records.HTTPSRecord( + 1, + "example.com", + { + SVCParamKeys.ALPN.value: b"\x02h2\x02h3", + }, + ) + answers = [ + dns.ResourceRecord( + "dns.google", + dns.types.HTTPS, + dns.classes.IN, + 32, + https_records.pack(record2), + ) + ] + f = tflow.tdnsflow(resp=tutils.tdnsresp(answers=answers)) + + se.dns_response(f) + assert f.response.answers[0].https_alpn == (b"h2", b"h3") + + tctx.configure(se, http3=False) + se.dns_response(f) + assert f.response.answers[0].https_alpn == (b"h2",) + + f.response.answers[0].https_alpn = [b"h3"] + se.dns_response(f) + assert f.response.answers[0].https_alpn is None diff --git a/test/mitmproxy/test_dns.py b/test/mitmproxy/test_dns.py index 8bd625113..b6fa7c7b2 100644 --- a/test/mitmproxy/test_dns.py +++ b/test/mitmproxy/test_dns.py @@ -79,6 +79,11 @@ class TestResourceRecord: assert rr.domain_name == "www.example.org" rr.text = "sample text" assert rr.text == "sample text" + + def test_https_record_ech(self): + rr = dns.ResourceRecord( + "test", dns.types.ANY, dns.classes.IN, dns.ResourceRecord.DEFAULT_TTL, b"" + ) params = {3: b"\x01\xbb"} record = dns.https_records.HTTPSRecord(1, "example.org", params) rr.data = dns.https_records.pack(record) @@ -88,6 +93,24 @@ class TestResourceRecord: rr.https_ech = None assert rr.https_ech is None + def test_https_record_alpn(self): + rr = dns.ResourceRecord( + "test", dns.types.ANY, dns.classes.IN, dns.ResourceRecord.DEFAULT_TTL, b"" + ) + record = dns.https_records.HTTPSRecord(1, "example.org", {}) + rr.data = dns.https_records.pack(record) + + assert rr.https_alpn is None + assert rr.data == b"\x00\x01\x07example\x03org\x00" + + rr.https_alpn = [b"h2", b"h3"] + assert rr.https_alpn == (b"h2", b"h3") + assert rr.data == b"\x00\x01\x07example\x03org\x00\x00\x01\x00\x06\x02h2\x02h3" + + rr.https_alpn = None + assert rr.https_alpn is None + assert rr.data == b"\x00\x01\x07example\x03org\x00" + class TestMessage: def test_json(self):