mirror of
https://github.com/3proxy/3proxy.git
synced 2026-09-02 12:55:49 +08:00
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:
parent
7011e78ece
commit
facc35e287
@ -527,7 +527,10 @@ If not specified, nserver is used. The syntax is the same as for nserver.
|
||||
.BR nsrecord
|
||||
\fI<hostname>\fR \fI<hostaddr>\fR
|
||||
.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
|
||||
blacklist something or together with
|
||||
.B dialer
|
||||
|
||||
@ -26,8 +26,11 @@ def _windows():
|
||||
|
||||
(LOW, HIGH), (ILOW, IHIGH) = _windows()
|
||||
|
||||
# below the Linux window on purpose: the kernel ignores such a range
|
||||
UNHONOURED = (21400, 21449)
|
||||
# Privileged ports: the kernel ignores such a range on Linux, since it is
|
||||
# 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):
|
||||
|
||||
@ -129,6 +129,9 @@ def run(t):
|
||||
plain = t.free_port()
|
||||
proxies = t.start("ssl_mitm", f"""
|
||||
log
|
||||
nserver 127.0.0.1
|
||||
nscache 1024
|
||||
nsrecord intercepted.test 127.0.0.1
|
||||
ssl_server_ca_file {certs.ca}
|
||||
ssl_server_ca_key {certs.ca_key}
|
||||
ssl_certcache {certs.cache}
|
||||
@ -146,12 +149,18 @@ def run(t):
|
||||
proxy -p{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.
|
||||
# Verification is not strict: 3proxy issues those certificates without an
|
||||
# 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.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")
|
||||
t.contains(log, "/secret/page", "MITM puts the request URI in the log")
|
||||
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
|
||||
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")
|
||||
|
||||
# Without interception the same request is opaque: the proxy logs the
|
||||
# CONNECT target and nothing from inside the tunnel.
|
||||
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")
|
||||
tunnelled = t.wait_output(proxies, str(origin), since=before)
|
||||
t.contains(tunnelled, str(origin), "the tunnel logs the CONNECT target")
|
||||
tunnelled = t.wait_output(proxies, "intercepted.test", since=before)
|
||||
t.contains(tunnelled, "intercepted.test", "the tunnel logs the CONNECT target")
|
||||
t.not_contains(tunnelled, "/secret/page",
|
||||
"a tunnelled request keeps its URI out of the log")
|
||||
|
||||
@ -122,6 +122,7 @@ class Tester:
|
||||
self.timeout = 10
|
||||
self._skipped = 0
|
||||
self._certs = None
|
||||
self.logs = []
|
||||
|
||||
# ---- servers -----------------------------------------------------
|
||||
|
||||
@ -201,8 +202,10 @@ class Tester:
|
||||
time.sleep(0.05)
|
||||
|
||||
def stop_all(self):
|
||||
"""Stop the servers, keeping what they printed for the report."""
|
||||
for server in self.servers:
|
||||
server.stop()
|
||||
self.logs.append((server.name, server.output()))
|
||||
self.servers = []
|
||||
|
||||
# ---- requests ----------------------------------------------------
|
||||
@ -464,14 +467,21 @@ class Tester:
|
||||
self._certs = c
|
||||
return c
|
||||
|
||||
def _context(self, ca=None, strict=True):
|
||||
"""A client context. strict=False drops the RFC 5280 checks Python
|
||||
turns on by default from 3.13, which reject a certificate with no
|
||||
Authority Key Identifier."""
|
||||
def _context(self, ca=None, strict=True, verify_name=True):
|
||||
"""A client context.
|
||||
|
||||
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:
|
||||
context = ssl.create_default_context(cafile=ca)
|
||||
if not strict:
|
||||
context.verify_flags &= ~getattr(ssl, "VERIFY_X509_STRICT", 0)
|
||||
if not verify_name:
|
||||
context.check_hostname = False
|
||||
return context
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
@ -503,11 +513,11 @@ class Tester:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def https(self, url, proxy=None, ca=None, strict=True, method="GET",
|
||||
headers=None):
|
||||
def https(self, url, proxy=None, ca=None, strict=True, verify_name=True,
|
||||
method="GET", headers=None):
|
||||
"""An https:// request, optionally tunnelled through a proxy."""
|
||||
host, port, path = self._split(url, default_port=443)
|
||||
context = self._context(ca, strict)
|
||||
context = self._context(ca, strict, verify_name)
|
||||
try:
|
||||
if proxy:
|
||||
phost, pport = self._hostport(proxy)
|
||||
|
||||
@ -113,6 +113,15 @@ def main():
|
||||
if actual is not None:
|
||||
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:
|
||||
failed += 1
|
||||
failures.append(f"{name}: case aborted")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user