Make the TLS and port-range cases hold on every platform

The MITM case reached its origin by address, so it depended on the
certificate 3proxy spoofs carrying an IP alternative name. It does when the
upstream certificate is copied, which is what happens on Linux and macOS
but not on Windows, where the client then refused the connection. Point a
name at 127.0.0.1 with nsrecord instead, and check the chain rather than
the name: an intercepted certificate names the upstream host, not the one
that was asked for. The log assertions gain from it too, since the name is
better evidence than a port that the request was seen.

nsrecord needs nserver as well as nscache, and has to follow nscache, so
say that in the manual: the record goes into the table nscache allocates,
and the table is only consulted when nserver is set.

The port-range fallback was exercised with a range the same case had
already used, so on a busy machine it could fail to bind for the ordinary
reason rather than the one under test. Use privileged ports, which nothing
can take.

When a case fails, print what its servers wrote: the reason usually goes to
the server's stderr, which was captured and then thrown away.
This commit is contained in:
Vladimir Dubrovin 2026-08-26 09:11:56 +03:00
parent 7011e78ece
commit facc35e287
5 changed files with 52 additions and 17 deletions

View File

@ -527,7 +527,10 @@ If not specified, nserver is used. The syntax is the same as for nserver.
.BR nsrecord .BR nsrecord
\fI<hostname>\fR \fI<hostaddr>\fR \fI<hostname>\fR \fI<hostaddr>\fR
.br .br
Adds static record to nscache. \fBnscache\fR must be enabled. If 0.0.0.0 Adds static record to nscache. \fBnscache\fR must be enabled and must come
first, because the record is placed in the table it allocates, and
\fBnserver\fR must be set as well: without it the system resolver is used and
static records are never consulted. If 0.0.0.0
is used as a hostaddr host will never resolve, it can be used to is used as a hostaddr host will never resolve, it can be used to
blacklist something or together with blacklist something or together with
.B dialer .B dialer

View File

@ -26,8 +26,11 @@ def _windows():
(LOW, HIGH), (ILOW, IHIGH) = _windows() (LOW, HIGH), (ILOW, IHIGH) = _windows()
# below the Linux window on purpose: the kernel ignores such a range # Privileged ports: the kernel ignores such a range on Linux, since it is
UNHONOURED = (21400, 21449) # outside net.ipv4.ip_local_port_range, and binding them fails outright
# without privileges. Either way nothing in the range can be taken, which
# is the case the fallback exists for.
UNHONOURED = (1, 99)
def run(t): def run(t):

View File

@ -129,6 +129,9 @@ def run(t):
plain = t.free_port() plain = t.free_port()
proxies = t.start("ssl_mitm", f""" proxies = t.start("ssl_mitm", f"""
log log
nserver 127.0.0.1
nscache 1024
nsrecord intercepted.test 127.0.0.1
ssl_server_ca_file {certs.ca} ssl_server_ca_file {certs.ca}
ssl_server_ca_key {certs.ca_key} ssl_server_ca_key {certs.ca_key}
ssl_certcache {certs.cache} ssl_certcache {certs.cache}
@ -146,12 +149,18 @@ def run(t):
proxy -p{plain} proxy -p{plain}
""", ports=[mitm, plain]) """, ports=[mitm, plain])
target = f"https://127.0.0.1:{origin}/secret/page" # A name the proxy resolves itself through nsrecord, so the request
# carries a hostname the way a real one would, without depending on
# what the machine running the tests puts in its hosts file.
target = f"https://intercepted.test:{origin}/secret/page"
# The client trusts our CA, which is what signs the spoofed certificate. # The client trusts our CA, which is what signs the spoofed certificate.
# Verification is not strict: 3proxy issues those certificates without an # Verification is not strict: 3proxy issues those certificates without an
# Authority Key Identifier, which Python rejects under its 3.13 defaults. # Authority Key Identifier, which Python rejects under its 3.13 defaults.
r = t.https(target, proxy=f"127.0.0.1:{mitm}", ca=certs.ca, strict=False) # The spoofed certificate names the upstream host rather than the one
# asked for, so the chain is checked but the name is not.
r = t.https(target, proxy=f"127.0.0.1:{mitm}", ca=certs.ca, strict=False,
verify_name=False)
t.eq(200, r.status, "MITM passes the request through") t.eq(200, r.status, "MITM passes the request through")
t.contains(r, "path=/secret/page", "the intercepted request reaches the origin") t.contains(r, "path=/secret/page", "the intercepted request reaches the origin")
@ -159,19 +168,20 @@ def run(t):
log = t.wait_output(proxies, "/secret/page") log = t.wait_output(proxies, "/secret/page")
t.contains(log, "/secret/page", "MITM puts the request URI in the log") t.contains(log, "/secret/page", "MITM puts the request URI in the log")
t.contains(log, "GET", "MITM logs the method") t.contains(log, "GET", "MITM logs the method")
t.contains(log, str(origin), "MITM logs the destination") t.contains(log, "intercepted.test", "MITM logs the host that was asked for")
# a client that does not trust the CA sees the substitution # a client that does not trust the CA sees the substitution
refused = t.https(target, proxy=f"127.0.0.1:{mitm}", ca=certs.other, refused = t.https(target, proxy=f"127.0.0.1:{mitm}", ca=certs.other,
strict=False) strict=False, verify_name=False)
t.ne(200, refused.status, "MITM is visible to a client with another CA") t.ne(200, refused.status, "MITM is visible to a client with another CA")
# Without interception the same request is opaque: the proxy logs the # Without interception the same request is opaque: the proxy logs the
# CONNECT target and nothing from inside the tunnel. # CONNECT target and nothing from inside the tunnel.
before = len(proxies.output()) before = len(proxies.output())
r = t.https(target, proxy=f"127.0.0.1:{plain}", ca=certs.ca, strict=False) r = t.https(target, proxy=f"127.0.0.1:{plain}", ca=certs.ca, strict=False,
verify_name=False)
t.eq(200, r.status, "the plain proxy tunnels the same request") t.eq(200, r.status, "the plain proxy tunnels the same request")
tunnelled = t.wait_output(proxies, str(origin), since=before) tunnelled = t.wait_output(proxies, "intercepted.test", since=before)
t.contains(tunnelled, str(origin), "the tunnel logs the CONNECT target") t.contains(tunnelled, "intercepted.test", "the tunnel logs the CONNECT target")
t.not_contains(tunnelled, "/secret/page", t.not_contains(tunnelled, "/secret/page",
"a tunnelled request keeps its URI out of the log") "a tunnelled request keeps its URI out of the log")

View File

@ -122,6 +122,7 @@ class Tester:
self.timeout = 10 self.timeout = 10
self._skipped = 0 self._skipped = 0
self._certs = None self._certs = None
self.logs = []
# ---- servers ----------------------------------------------------- # ---- servers -----------------------------------------------------
@ -201,8 +202,10 @@ class Tester:
time.sleep(0.05) time.sleep(0.05)
def stop_all(self): def stop_all(self):
"""Stop the servers, keeping what they printed for the report."""
for server in self.servers: for server in self.servers:
server.stop() server.stop()
self.logs.append((server.name, server.output()))
self.servers = [] self.servers = []
# ---- requests ---------------------------------------------------- # ---- requests ----------------------------------------------------
@ -464,14 +467,21 @@ class Tester:
self._certs = c self._certs = c
return c return c
def _context(self, ca=None, strict=True): def _context(self, ca=None, strict=True, verify_name=True):
"""A client context. strict=False drops the RFC 5280 checks Python """A client context.
turns on by default from 3.13, which reject a certificate with no
Authority Key Identifier.""" strict=False drops the RFC 5280 checks Python turns on by default
from 3.13, which reject a certificate with no Authority Key
Identifier. verify_name=False keeps the chain check but ignores
which host the certificate names, for the intercepted connections
where that is the upstream identity rather than the one asked for.
"""
if ca: if ca:
context = ssl.create_default_context(cafile=ca) context = ssl.create_default_context(cafile=ca)
if not strict: if not strict:
context.verify_flags &= ~getattr(ssl, "VERIFY_X509_STRICT", 0) context.verify_flags &= ~getattr(ssl, "VERIFY_X509_STRICT", 0)
if not verify_name:
context.check_hostname = False
return context return context
context = ssl.create_default_context() context = ssl.create_default_context()
context.check_hostname = False context.check_hostname = False
@ -503,11 +513,11 @@ class Tester:
finally: finally:
conn.close() conn.close()
def https(self, url, proxy=None, ca=None, strict=True, method="GET", def https(self, url, proxy=None, ca=None, strict=True, verify_name=True,
headers=None): method="GET", headers=None):
"""An https:// request, optionally tunnelled through a proxy.""" """An https:// request, optionally tunnelled through a proxy."""
host, port, path = self._split(url, default_port=443) host, port, path = self._split(url, default_port=443)
context = self._context(ca, strict) context = self._context(ca, strict, verify_name)
try: try:
if proxy: if proxy:
phost, pport = self._hostport(proxy) phost, pport = self._hostport(proxy)

View File

@ -113,6 +113,15 @@ def main():
if actual is not None: if actual is not None:
print(f" actual: {actual}") print(f" actual: {actual}")
if tester.checks and any(status is False for status, _, _, _ in tester.checks):
for name, text in tester.logs:
lines = [line for line in text.splitlines() if line.strip()]
if not lines:
continue
print(f" --- {name} said ---")
for line in lines[-12:]:
print(f" {line}")
if error: if error:
failed += 1 failed += 1
failures.append(f"{name}: case aborted") failures.append(f"{name}: case aborted")