mirror of
https://github.com/3proxy/3proxy.git
synced 2026-09-02 12:55:49 +08:00
Fix MitM for MacOS clients (openssl only)
This commit is contained in:
parent
8582b33f8e
commit
c0c51357d9
19
SECURITY.md
19
SECURITY.md
@ -7,6 +7,25 @@
|
|||||||
| 0.9.8 | :white_check_mark: |
|
| 0.9.8 | :white_check_mark: |
|
||||||
| < 0.9.8 | :x: |
|
| < 0.9.8 | :x: |
|
||||||
|
|
||||||
|
## Hardening a deployment
|
||||||
|
|
||||||
|
Configuration is where most of the risk lives. The security recommendations are
|
||||||
|
kept in [doc/html/securityen.html](doc/html/securityen.html), published at
|
||||||
|
<https://3proxy.org/securityen.html>: how to run the service, what the
|
||||||
|
ACLs have to cover, and the settings whose defaults are safe only until
|
||||||
|
something else is enabled alongside them.
|
||||||
|
|
||||||
|
Read it before exposing a service. Recurring points from it:
|
||||||
|
|
||||||
|
- Run unprivileged, never suid, and chroot where the platform allows.
|
||||||
|
- Name the internal and external interfaces explicitly, and limit sources and
|
||||||
|
destinations with ACLs rather than relying on defaults.
|
||||||
|
- Enabling IPv6 makes ACLs written in IPv4 incomplete: the same host is
|
||||||
|
reachable through an IPv4-mapped address, and the IPv6 loopback is an
|
||||||
|
address of its own.
|
||||||
|
- Anything that terminates or intercepts TLS holds key material and sees full
|
||||||
|
request URLs; both the key and the logs need protecting.
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Report to 3proxy@3proxy.org or via [GitHub security reporting](https://github.com/3proxy/3proxy/security)
|
Report to 3proxy@3proxy.org or via [GitHub security reporting](https://github.com/3proxy/3proxy/security)
|
||||||
|
|||||||
@ -19,6 +19,45 @@ authentication is currently available.
|
|||||||
<li>Always limit connections to the internal network and localhost (to 127.0.0.1 and
|
<li>Always limit connections to the internal network and localhost (to 127.0.0.1 and
|
||||||
all interfaces) with ACLs. Be careful, because the BIND command in SOCKS requires the
|
all interfaces) with ACLs. Be careful, because the BIND command in SOCKS requires the
|
||||||
BIND method with the external interface IP address to be allowed.
|
BIND method with the external interface IP address to be allowed.
|
||||||
|
<li>Services resolve IPv4 only unless told otherwise ('-4' is the default). Enabling
|
||||||
|
IPv6 with '-6', '-46' or '-64' makes every ACL written in IPv4 incomplete, because the
|
||||||
|
same host can be asked for in another way. A proxy that denies 127.0.0.1 but has IPv6
|
||||||
|
enabled still reaches that host as '::ffff:127.0.0.1', and reaches the machine again as
|
||||||
|
'::1', which is a different address the IPv4 rule never mentioned. When IPv6 is enabled,
|
||||||
|
deny the mapped form '::ffff:0:0/96' as well unless it is needed, and deny the IPv6
|
||||||
|
addresses that correspond to whatever the IPv4 rules protect: '::1' and '::' for the
|
||||||
|
local machine, 'fe80::/10' for link-local and 'fc00::/7' for unique local addresses.
|
||||||
|
Denying the IPv4 spelling alone is not enough.
|
||||||
|
<li>With '-46' or '-64' a name resolves to either family, so a target ACL that names
|
||||||
|
only one of a host's addresses does not limit that host. Names are resolved into
|
||||||
|
separate caches, and a name that resolves to an IPv6 address is only cached when
|
||||||
|
'nscache6' is configured.
|
||||||
|
<li>The 'admin' service hands out counters, the list of running services and a way to
|
||||||
|
trigger a configuration reload. Bind it to an internal interface, and put
|
||||||
|
authentication and an ACL in front of it. The '-s' option limits what the pages offer
|
||||||
|
but is not authentication.
|
||||||
|
<li>The 'echo' and 'data' operations of the 'http' command exist for testing. 'data'
|
||||||
|
returns a response of whatever size the request asks for, so a listener offering it to
|
||||||
|
anyone is a traffic amplifier. Do not configure them on a public service.
|
||||||
|
<li>'ssl_server_ca_key' is the private key of a certificate authority that clients have
|
||||||
|
been told to trust. Anyone who obtains it can impersonate any site to those clients, so
|
||||||
|
protect it as a signing key and use a CA created for this purpose only, never one that
|
||||||
|
is trusted for anything else. Restrict the 'ssl_certcache' directory as well: it holds
|
||||||
|
the certificates generated from that key.
|
||||||
|
<li>Interception ('ssl_mitm') ends the guarantee the client believes it has. The full
|
||||||
|
URL of every request inside the tunnel, query string included, becomes visible to the
|
||||||
|
proxy and reaches the log, where a plain CONNECT would have shown only a host and a
|
||||||
|
port. Treat those logs accordingly.
|
||||||
|
<li>Certificates generated for interception by a build against wolfSSL carry no key
|
||||||
|
identifiers, because that library cannot generate certificate extensions, and a client
|
||||||
|
verifying strictly (OpenSSL 'x509_strict', which recent Python enables by default)
|
||||||
|
rejects them. Builds against OpenSSL generate them. Where they are missing, turning
|
||||||
|
verification off in the client removes the protection interception was supposed to
|
||||||
|
preserve; use an OpenSSL build instead.
|
||||||
|
<li>Regular expression rules ('pcre', 'pcre_rewrite') are matched without
|
||||||
|
authentication and do not replace ACLs. A rewrite that would change the method or the
|
||||||
|
destination of a request is ignored, because the destination was already authorized;
|
||||||
|
do not rely on one to redirect traffic.
|
||||||
<li>Before 3proxy 0.8, always use nserver and nscache under Unix; otherwise, a DoS attack is possible
|
<li>Before 3proxy 0.8, always use nserver and nscache under Unix; otherwise, a DoS attack is possible
|
||||||
with an unreachable DNS server (because gethostbyname will block other threads).
|
with an unreachable DNS server (because gethostbyname will block other threads).
|
||||||
<li>Keep logs in a secure location, because some confidential information from
|
<li>Keep logs in a secure location, because some confidential information from
|
||||||
|
|||||||
@ -132,7 +132,8 @@ change default server port to NUMBER
|
|||||||
Only resolve IPv6 addresses. IPv4 addresses are packed in IPv6 in IPV6_V6ONLY compatible way.
|
Only resolve IPv6 addresses. IPv4 addresses are packed in IPv6 in IPV6_V6ONLY compatible way.
|
||||||
.br
|
.br
|
||||||
.B -4
|
.B -4
|
||||||
Only resolve IPv4 addresses
|
Only resolve IPv4 addresses. This is the default: a service reaches an IPv6
|
||||||
|
address only when told to with \fB-6\fR, \fB-46\fR or \fB-64\fR.
|
||||||
.br
|
.br
|
||||||
.B -46
|
.B -46
|
||||||
Prefer IPv4. Resolve IPv6 addresses if IPv4 address is not resolvable
|
Prefer IPv4. Resolve IPv6 addresses if IPv4 address is not resolvable
|
||||||
@ -521,14 +522,18 @@ If not specified, nserver is used. The syntax is the same as for nserver.
|
|||||||
.br
|
.br
|
||||||
Cache \fI<cachesize>\fR records for name resolution (\fBnscache\fR for IPv4,
|
Cache \fI<cachesize>\fR records for name resolution (\fBnscache\fR for IPv4,
|
||||||
\fBnscache6\fR for IPv6). The cache size should usually be large enough
|
\fBnscache6\fR for IPv6). The cache size should usually be large enough
|
||||||
(for example, 65536).
|
(for example, 65536). The two are separate: a name that resolves to an IPv6
|
||||||
|
address, including one given with \fBnsrecord\fR, is only held when
|
||||||
|
\fBnscache6\fR is configured, and \fBnscache\fR does nothing for it. Both
|
||||||
|
caches are global rather than per-service.
|
||||||
|
|
||||||
.br
|
.br
|
||||||
.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 and must come
|
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
|
first, because the record is placed in the table it allocates - \fBnscache6\fR
|
||||||
|
for a record naming an IPv6 address - and
|
||||||
\fBnserver\fR must be set as well: without it the system resolver is used 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
|
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
|
||||||
|
|||||||
28
src/ssllib.c
28
src/ssllib.c
@ -84,7 +84,11 @@ static int copy_ext(X509 *dst_cert, X509 *src_cert, int nid)
|
|||||||
}
|
}
|
||||||
|
|
||||||
#ifndef WITH_WOLFSSL
|
#ifndef WITH_WOLFSSL
|
||||||
static int add_ext(X509 *cert, int nid, const char *value)
|
/* issuer is the certificate the extension should describe as the issuer,
|
||||||
|
* which matters for an authority key identifier: it names the key that
|
||||||
|
* signs, not the key being signed.
|
||||||
|
*/
|
||||||
|
static int add_ext_issuer(X509 *cert, X509 *issuer, int nid, const char *value)
|
||||||
{
|
{
|
||||||
X509_EXTENSION *ex;
|
X509_EXTENSION *ex;
|
||||||
X509V3_CTX ctx;
|
X509V3_CTX ctx;
|
||||||
@ -92,10 +96,8 @@ static int add_ext(X509 *cert, int nid, const char *value)
|
|||||||
/* This sets the 'context' of the extensions. */
|
/* This sets the 'context' of the extensions. */
|
||||||
/* No configuration database */
|
/* No configuration database */
|
||||||
X509V3_set_ctx_nodb(&ctx);
|
X509V3_set_ctx_nodb(&ctx);
|
||||||
/* Issuer and subject certs: both the target since it is self signed,
|
/* No request and no CRL */
|
||||||
* no request and no CRL
|
X509V3_set_ctx(&ctx, issuer, cert, NULL, NULL, 0);
|
||||||
*/
|
|
||||||
X509V3_set_ctx(&ctx, cert, cert, NULL, NULL, 0);
|
|
||||||
/* value is char * prior to OpenSSL 1.1.0 */
|
/* value is char * prior to OpenSSL 1.1.0 */
|
||||||
ex = X509V3_EXT_conf_nid(NULL, &ctx, nid, (char *)value);
|
ex = X509V3_EXT_conf_nid(NULL, &ctx, nid, (char *)value);
|
||||||
if (!ex)
|
if (!ex)
|
||||||
@ -105,6 +107,12 @@ static int add_ext(X509 *cert, int nid, const char *value)
|
|||||||
X509_EXTENSION_free(ex);
|
X509_EXTENSION_free(ex);
|
||||||
return err > 0;
|
return err > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int add_ext(X509 *cert, int nid, const char *value)
|
||||||
|
{
|
||||||
|
/* Issuer and subject: both the target, for a self signed certificate */
|
||||||
|
return add_ext_issuer(cert, cert, nid, value);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
SSL_CERT ssl_copy_cert(SSL_CERT cert, SSL_CONFIG *config)
|
SSL_CERT ssl_copy_cert(SSL_CERT cert, SSL_CONFIG *config)
|
||||||
@ -199,6 +207,16 @@ SSL_CERT ssl_copy_cert(SSL_CERT cert, SSL_CONFIG *config)
|
|||||||
add_ext(dst_cert, NID_basic_constraints, "critical,CA:FALSE");
|
add_ext(dst_cert, NID_basic_constraints, "critical,CA:FALSE");
|
||||||
if(!copy_ext(dst_cert, src_cert, NID_ext_key_usage))
|
if(!copy_ext(dst_cert, src_cert, NID_ext_key_usage))
|
||||||
add_ext(dst_cert, NID_ext_key_usage, "serverAuth");
|
add_ext(dst_cert, NID_ext_key_usage, "serverAuth");
|
||||||
|
/* A verifier following RFC 5280 strictly looks for the issuer through a
|
||||||
|
* key identifier and refuses a certificate carrying none: OpenSSL does
|
||||||
|
* with x509_strict, and Python has since 3.13. The identifiers are
|
||||||
|
* generated rather than copied, so they name the CA signing here
|
||||||
|
* instead of the one that signed upstream. keyid,issuer keeps working
|
||||||
|
* when the CA certificate has no subject key identifier of its own.
|
||||||
|
*/
|
||||||
|
add_ext(dst_cert, NID_subject_key_identifier, "hash");
|
||||||
|
add_ext_issuer(dst_cert, config->CA_cert, NID_authority_key_identifier,
|
||||||
|
"keyid,issuer");
|
||||||
#else
|
#else
|
||||||
copy_ext(dst_cert, src_cert, NID_basic_constraints);
|
copy_ext(dst_cert, src_cert, NID_basic_constraints);
|
||||||
copy_ext(dst_cert, src_cert, NID_ext_key_usage);
|
copy_ext(dst_cert, src_cert, NID_ext_key_usage);
|
||||||
|
|||||||
@ -69,7 +69,9 @@ means to stand on its own should start with one - otherwise an earlier
|
|||||||
|
|
||||||
## What is not covered yet
|
## What is not covered yet
|
||||||
|
|
||||||
41 of the 112 configuration commands appear in a test. What follows is
|
41 of the 112 configuration commands appear in a test, and the count says
|
||||||
|
nothing about service options: the IPv6 case, for instance, exercises -4,
|
||||||
|
-6, -46, -64 and -i without adding a command to it. What follows is
|
||||||
roughly the order worth working through: how much of the product a gap
|
roughly the order worth working through: how much of the product a gap
|
||||||
covers, and how much of a fixture it needs.
|
covers, and how much of a fixture it needs.
|
||||||
|
|
||||||
@ -103,11 +105,12 @@ ranges, time and weekday fields, and operation lists beyond the single
|
|||||||
`HTTP_CONNECT` used today. `weight` needs several parents and enough
|
`HTTP_CONNECT` used today. `weight` needs several parents and enough
|
||||||
requests to see the split.
|
requests to see the split.
|
||||||
|
|
||||||
### IPv6
|
### IPv6, what is left of it
|
||||||
|
|
||||||
Not one test binds or connects over `::1`, though the tree is full of
|
`tests/cases/ipv6.py` covers listening on `::1`, proxying to and from it,
|
||||||
`#ifndef NOIPV6` and `extip` has an IPv6 CIDR-randomisation path of its own.
|
SOCKS with an IPv6 destination, rules naming an IPv6 address, and which
|
||||||
Most existing cases would work over IPv6 with the address parameterised.
|
family each of `-4 -6 -46 -64` will use. Still open: `extip` with an IPv6 CIDR, whose
|
||||||
|
randomisation path has no coverage.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
@ -156,7 +159,6 @@ takes effect, or that services come back.
|
|||||||
|
|
||||||
A request rewrite that changes the method or the authority is ignored, and
|
A request rewrite that changes the method or the authority is ignored, and
|
||||||
the manual says so; a test that pinned the current behaviour would have to
|
the manual says so; a test that pinned the current behaviour would have to
|
||||||
change when that does. Certificates 3proxy generates for MITM carry no
|
change when that does. An intercepted certificate is verified strictly where the build can
|
||||||
Authority Key Identifier, so `tests/cases/ssl.py` verifies the chain without
|
generate the key identifiers, and the case skips that one check on a wolfSSL
|
||||||
strict checking - if that is fixed, the test should tighten rather than stay
|
build, which cannot. If wolfSSL gains the ability, the skip should go.
|
||||||
as it is.
|
|
||||||
|
|||||||
224
tests/cases/ipv6.py
Normal file
224
tests/cases/ipv6.py
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
"""IPv6: listening on it, reaching it, and the rules that mention it.
|
||||||
|
|
||||||
|
A service resolves IPv4 only unless told otherwise, so the proxies that are
|
||||||
|
meant to reach IPv6 carry a family flag. Names resolving to IPv6 need
|
||||||
|
nscache6: nscache holds the IPv4 side and nothing else.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
if not t.has_ipv6():
|
||||||
|
t.skip("IPv6 (this machine has no IPv6 loopback)")
|
||||||
|
return
|
||||||
|
|
||||||
|
origin = t.free_port()
|
||||||
|
v6proxy = t.free_port()
|
||||||
|
mixed = t.free_port()
|
||||||
|
v4only = t.free_port()
|
||||||
|
socks6 = t.free_port()
|
||||||
|
|
||||||
|
t.start("ipv6", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo* echo
|
||||||
|
http * /data data
|
||||||
|
httpsrv -p{origin} -i::1
|
||||||
|
|
||||||
|
# reached over IPv6, and allowed to reach IPv6
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
proxy -p{v6proxy} -i::1 -6
|
||||||
|
|
||||||
|
# reached over IPv4, still able to reach IPv6
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
proxy -p{mixed} -6
|
||||||
|
|
||||||
|
# asked for IPv4 only, so an IPv6 destination is not for it
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
proxy -p{v4only} -4
|
||||||
|
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
socks -p{socks6} -6
|
||||||
|
""", ports=[("::1", origin), ("::1", v6proxy), mixed, v4only, socks6])
|
||||||
|
|
||||||
|
url = f"http://[::1]:{origin}/echo"
|
||||||
|
|
||||||
|
# --- listening on IPv6 -------------------------------------------------
|
||||||
|
r = t.http(url)
|
||||||
|
t.eq(200, r.status, "a service bound to ::1 answers over IPv6")
|
||||||
|
t.contains(r, "peer.addr=::1", "the client is seen as an IPv6 address")
|
||||||
|
t.contains(r, "path=/echo", "and the request arrives intact")
|
||||||
|
|
||||||
|
# the Host header carries the address in brackets, and a rule matching
|
||||||
|
# any host still matches it
|
||||||
|
t.contains(r, "host=[::1]", "the host header keeps its brackets")
|
||||||
|
|
||||||
|
# --- proxying over IPv6 -------------------------------------------------
|
||||||
|
r = t.http(url, proxy=f"[::1]:{v6proxy}")
|
||||||
|
t.eq(200, r.status, "a proxy reached over IPv6 serves an IPv6 destination")
|
||||||
|
t.contains(r, "peer.addr=::1", "the proxy connects from IPv6 as well")
|
||||||
|
|
||||||
|
t.eq(20000, t.http(f"http://[::1]:{origin}/data?size=20000",
|
||||||
|
proxy=f"[::1]:{v6proxy}").length,
|
||||||
|
"a body passes over IPv6")
|
||||||
|
|
||||||
|
t.eq(200, t.http(url, proxy=f"[::1]:{v6proxy}", tunnel=True).status,
|
||||||
|
"CONNECT works over IPv6")
|
||||||
|
|
||||||
|
# --- across the two families --------------------------------------------
|
||||||
|
r = t.http(url, proxy=f"127.0.0.1:{mixed}")
|
||||||
|
t.eq(200, r.status, "a client on IPv4 can be given an IPv6 destination")
|
||||||
|
t.contains(r, "peer.addr=::1", "and the far side is still reached over IPv6")
|
||||||
|
|
||||||
|
# a service told to use one family stays in it
|
||||||
|
t.ne(200, t.http(url, proxy=f"127.0.0.1:{v4only}").status,
|
||||||
|
"a service asked for IPv4 only refuses an IPv6 destination")
|
||||||
|
|
||||||
|
# --- SOCKS with an IPv6 destination -------------------------------------
|
||||||
|
r = t.socks_http(f"127.0.0.1:{socks6}", url)
|
||||||
|
t.eq(200, r.status, "SOCKS5 carries an IPv6 destination address")
|
||||||
|
t.contains(r, "peer.addr=::1", "which is reached over IPv6")
|
||||||
|
|
||||||
|
# --- which family a service will use --------------------------------------
|
||||||
|
# -46 and -64 both reach either family; -4 and -6 are each restricted to
|
||||||
|
# one; and nothing said means -46.
|
||||||
|
v4origin = t.free_port()
|
||||||
|
flags = {"nothing said": "", "-4": "-4", "-6": "-6", "-46": "-46", "-64": "-64"}
|
||||||
|
family_ports = {name: t.free_port() for name in flags}
|
||||||
|
sections = [f"""
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
proxy -p{family_ports[name]} {flag}""" for name, flag in flags.items()]
|
||||||
|
t.start("ipv6_family", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo* echo
|
||||||
|
httpsrv -p{v4origin}
|
||||||
|
{"".join(sections)}
|
||||||
|
""", ports=[v4origin] + list(family_ports.values()))
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"nothing said": (200, None), # -4 is the default
|
||||||
|
"-4": (200, None),
|
||||||
|
"-6": (None, 200),
|
||||||
|
"-46": (200, 200),
|
||||||
|
"-64": (200, 200),
|
||||||
|
}
|
||||||
|
for name, port in family_ports.items():
|
||||||
|
want4, want6 = expected[name]
|
||||||
|
got4 = t.http(f"http://127.0.0.1:{v4origin}/echo", proxy=f"127.0.0.1:{port}").status
|
||||||
|
got6 = t.http(url, proxy=f"127.0.0.1:{port}").status
|
||||||
|
if want4 == 200:
|
||||||
|
t.eq(200, got4, f"{name}: an IPv4 destination is reached")
|
||||||
|
else:
|
||||||
|
t.ne(200, got4, f"{name}: an IPv4 destination is refused")
|
||||||
|
if want6 == 200:
|
||||||
|
t.eq(200, got6, f"{name}: an IPv6 destination is reached")
|
||||||
|
else:
|
||||||
|
t.ne(200, got6, f"{name}: an IPv6 destination is refused")
|
||||||
|
|
||||||
|
# --- a name that resolves to an IPv6 address ------------------------------
|
||||||
|
# The two caches are separate, and the record is only kept in the one
|
||||||
|
# that matches the address family.
|
||||||
|
# separate processes: the caches belong to the process, not the service,
|
||||||
|
# so one section configuring nscache6 would answer for the other too
|
||||||
|
with_cache6 = t.free_port()
|
||||||
|
without = t.free_port()
|
||||||
|
t.start("ipv6_names", f"""
|
||||||
|
log
|
||||||
|
flush
|
||||||
|
nserver 127.0.0.1
|
||||||
|
nscache6 1024
|
||||||
|
nsrecord v6.test ::1
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
proxy -p{with_cache6} -6
|
||||||
|
""", ports=[with_cache6])
|
||||||
|
t.start("ipv6_names_nocache", f"""
|
||||||
|
log
|
||||||
|
flush
|
||||||
|
nserver 127.0.0.1
|
||||||
|
nsrecord v6.test ::1
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
proxy -p{without} -6
|
||||||
|
""", ports=[without])
|
||||||
|
|
||||||
|
t.eq(200, t.http(f"http://v6.test:{origin}/echo",
|
||||||
|
proxy=f"127.0.0.1:{with_cache6}").status,
|
||||||
|
"a name kept in nscache6 resolves to its IPv6 address")
|
||||||
|
t.ne(200, t.http(f"http://v6.test:{origin}/echo",
|
||||||
|
proxy=f"127.0.0.1:{without}").status,
|
||||||
|
"the same record without nscache6 is not there to be found")
|
||||||
|
|
||||||
|
# --- an address has more than one spelling --------------------------------
|
||||||
|
# Denying the IPv4 form does not deny the same host asked for as an
|
||||||
|
# IPv4-mapped address, nor the IPv6 loopback, which is why the security
|
||||||
|
# notes say to deny all of them. Both halves are checked so a change in
|
||||||
|
# either direction is noticed.
|
||||||
|
partial = t.free_port()
|
||||||
|
complete = t.free_port()
|
||||||
|
t.start("ipv6_deny", f"""
|
||||||
|
log
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
deny * * 127.0.0.1
|
||||||
|
allow *
|
||||||
|
proxy -p{partial} -46
|
||||||
|
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
deny * * 127.0.0.1
|
||||||
|
deny * * ::1
|
||||||
|
deny * * ::ffff:127.0.0.1
|
||||||
|
allow *
|
||||||
|
proxy -p{complete} -46
|
||||||
|
""", ports=[partial, complete])
|
||||||
|
|
||||||
|
v4url = f"http://127.0.0.1:{v4origin}/echo"
|
||||||
|
mapped = f"http://[::ffff:127.0.0.1]:{v4origin}/echo"
|
||||||
|
|
||||||
|
t.ne(200, t.http(v4url, proxy=f"127.0.0.1:{partial}").status,
|
||||||
|
"denying 127.0.0.1 denies the address as written")
|
||||||
|
t.eq(200, t.http(mapped, proxy=f"127.0.0.1:{partial}").status,
|
||||||
|
"but the same host asked for as ::ffff:127.0.0.1 is still reached")
|
||||||
|
t.eq(200, t.http(url, proxy=f"127.0.0.1:{partial}").status,
|
||||||
|
"and so is ::1, which the rule never mentioned")
|
||||||
|
|
||||||
|
t.ne(200, t.http(v4url, proxy=f"127.0.0.1:{complete}").status,
|
||||||
|
"naming every spelling denies the plain address")
|
||||||
|
t.ne(200, t.http(mapped, proxy=f"127.0.0.1:{complete}").status,
|
||||||
|
"and the mapped one")
|
||||||
|
t.ne(200, t.http(url, proxy=f"127.0.0.1:{complete}").status,
|
||||||
|
"and the IPv6 loopback")
|
||||||
|
|
||||||
|
# --- rules that name addresses ------------------------------------------
|
||||||
|
allowed = t.free_port()
|
||||||
|
refused = t.free_port()
|
||||||
|
t.start("ipv6_rules", f"""
|
||||||
|
log
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow * ::1
|
||||||
|
proxy -p{allowed} -i::1 -6
|
||||||
|
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow * 127.0.0.1
|
||||||
|
proxy -p{refused} -i::1 -6
|
||||||
|
""", ports=[("::1", allowed), ("::1", refused)])
|
||||||
|
|
||||||
|
t.eq(200, t.http(url, proxy=f"[::1]:{allowed}").status,
|
||||||
|
"a rule naming ::1 admits an IPv6 client")
|
||||||
|
t.ne(200, t.http(url, proxy=f"[::1]:{refused}").status,
|
||||||
|
"a rule naming only an IPv4 address does not")
|
||||||
@ -162,13 +162,21 @@ def run(t):
|
|||||||
# what the machine running the tests puts in its hosts file.
|
# what the machine running the tests puts in its hosts file.
|
||||||
target = f"https://intercepted.test:{origin}/secret/page"
|
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
|
# and checks it the way a current client does. The certificate names the
|
||||||
# Authority Key Identifier, which Python rejects under its 3.13 defaults.
|
# upstream host rather than the one asked for, so the chain is verified
|
||||||
# The spoofed certificate names the upstream host rather than the one
|
# but the name is not.
|
||||||
# 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,
|
||||||
r = t.https(target, proxy=f"127.0.0.1:{mitm}", ca=certs.ca, strict=False,
|
|
||||||
verify_name=False)
|
verify_name=False)
|
||||||
|
if r.status is None and "Authority Key Identifier" in (r.error or ""):
|
||||||
|
# A build against wolfSSL cannot generate certificate extensions,
|
||||||
|
# so the identifiers a strict verifier looks for are absent there.
|
||||||
|
t.skip("strict verification of an intercepted certificate "
|
||||||
|
"(this build cannot generate the key identifiers)")
|
||||||
|
r = t.https(target, proxy=f"127.0.0.1:{mitm}", ca=certs.ca,
|
||||||
|
strict=False, verify_name=False)
|
||||||
|
else:
|
||||||
|
t.ok("the intercepted certificate satisfies a strict verifier")
|
||||||
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")
|
||||||
|
|
||||||
@ -186,7 +194,7 @@ def run(t):
|
|||||||
# 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,
|
||||||
verify_name=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, "intercepted.test", since=before)
|
tunnelled = t.wait_output(proxies, "intercepted.test", since=before)
|
||||||
|
|||||||
@ -130,6 +130,20 @@ class Tester:
|
|||||||
|
|
||||||
# ---- servers -----------------------------------------------------
|
# ---- servers -----------------------------------------------------
|
||||||
|
|
||||||
|
def has_ipv6(self):
|
||||||
|
"""Whether this machine can use the IPv6 loopback at all."""
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
sock.bind(("::1", 0))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
def free_port(self):
|
def free_port(self):
|
||||||
"""A port nothing is listening on. Closed again before it is used,
|
"""A port nothing is listening on. Closed again before it is used,
|
||||||
which is racy in principle and reliable enough in practice."""
|
which is racy in principle and reliable enough in practice."""
|
||||||
@ -150,7 +164,11 @@ class Tester:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
def start(self, name, config, ports=()):
|
def start(self, name, config, ports=()):
|
||||||
"""Write a configuration, run it, and wait for its ports to open."""
|
"""Write a configuration, run it, and wait for its ports to open.
|
||||||
|
|
||||||
|
A port may be given as a number, or as (address, port) for a service
|
||||||
|
bound somewhere other than 127.0.0.1.
|
||||||
|
"""
|
||||||
path = self.write_config(name, config)
|
path = self.write_config(name, config)
|
||||||
logfile = os.path.join(self.tmpdir, name + ".out")
|
logfile = os.path.join(self.tmpdir, name + ".out")
|
||||||
with open(logfile, "wb") as out:
|
with open(logfile, "wb") as out:
|
||||||
@ -159,8 +177,9 @@ class Tester:
|
|||||||
server = Server(name, path, proc, logfile)
|
server = Server(name, path, proc, logfile)
|
||||||
self.servers.append(server)
|
self.servers.append(server)
|
||||||
|
|
||||||
for port in ports:
|
for entry in ports:
|
||||||
if not self.wait_port(port):
|
host, port = entry if isinstance(entry, tuple) else ("127.0.0.1", entry)
|
||||||
|
if not self.wait_port(port, host=host):
|
||||||
code = proc.poll()
|
code = proc.poll()
|
||||||
if code is None:
|
if code is None:
|
||||||
died = "the process is still running"
|
died = "the process is still running"
|
||||||
@ -181,11 +200,11 @@ class Tester:
|
|||||||
stderr=subprocess.STDOUT, timeout=15)
|
stderr=subprocess.STDOUT, timeout=15)
|
||||||
return done.stdout.decode("utf-8", "replace")
|
return done.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
def wait_port(self, port, timeout=5.0):
|
def wait_port(self, port, timeout=5.0, host="127.0.0.1"):
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
with socket.create_connection(("127.0.0.1", port), 0.25):
|
with socket.create_connection((host, port), 0.25):
|
||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError:
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
@ -242,7 +261,10 @@ class Tester:
|
|||||||
tunnel=tunnel)
|
tunnel=tunnel)
|
||||||
target = path
|
target = path
|
||||||
if proxy and not tunnel:
|
if proxy and not tunnel:
|
||||||
target = f"http://{host}:{port}{path}"
|
# an address with colons goes back in brackets, or the
|
||||||
|
# absolute URI cannot be read
|
||||||
|
authority = f"[{host}]" if ":" in host else host
|
||||||
|
target = f"http://{authority}:{port}{path}"
|
||||||
if body is not None and not isinstance(body, bytes):
|
if body is not None and not isinstance(body, bytes):
|
||||||
body = body.encode()
|
body = body.encode()
|
||||||
conn.request(method, target, body=body, headers=headers)
|
conn.request(method, target, body=body, headers=headers)
|
||||||
@ -479,6 +501,8 @@ class Tester:
|
|||||||
|
|
||||||
if remote_dns:
|
if remote_dns:
|
||||||
target = b"\x03" + bytes([len(host)]) + host.encode()
|
target = b"\x03" + bytes([len(host)]) + host.encode()
|
||||||
|
elif ":" in host:
|
||||||
|
target = b"\x04" + socket.inet_pton(socket.AF_INET6, host)
|
||||||
else:
|
else:
|
||||||
target = b"\x01" + socket.inet_aton(socket.gethostbyname(host))
|
target = b"\x01" + socket.inet_aton(socket.gethostbyname(host))
|
||||||
sock.sendall(b"\x05\x01\x00" + target + struct.pack("!H", port))
|
sock.sendall(b"\x05\x01\x00" + target + struct.pack("!H", port))
|
||||||
@ -681,7 +705,8 @@ class Tester:
|
|||||||
try:
|
try:
|
||||||
if body is not None and not isinstance(body, bytes):
|
if body is not None and not isinstance(body, bytes):
|
||||||
body = body.encode()
|
body = body.encode()
|
||||||
conn.request(method, f"http://{host}:{port}{path}", body=body,
|
authority = f"[{host}]" if ":" in host else host
|
||||||
|
conn.request(method, f"http://{authority}:{port}{path}", body=body,
|
||||||
headers=headers or {})
|
headers=headers or {})
|
||||||
reply = conn.getresponse()
|
reply = conn.getresponse()
|
||||||
return Response(reply.status, reply.read(), dict(reply.getheaders()))
|
return Response(reply.status, reply.read(), dict(reply.getheaders()))
|
||||||
@ -740,17 +765,28 @@ class Tester:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _hostport(value):
|
def _hostport(value):
|
||||||
|
if value.startswith("["):
|
||||||
|
host, _, rest = value[1:].partition("]")
|
||||||
|
return host, int(rest[1:])
|
||||||
host, _, port = value.rpartition(":")
|
host, _, port = value.rpartition(":")
|
||||||
return host or "127.0.0.1", int(port)
|
return host or "127.0.0.1", int(port)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _split(url, default_port=80):
|
def _split(url, default_port=80):
|
||||||
|
"""Split a URL, understanding an address in brackets.
|
||||||
|
|
||||||
|
The brackets are dropped: they belong to the URL, not to the address
|
||||||
|
a socket call or a certificate check wants.
|
||||||
|
"""
|
||||||
for prefix in ("http://", "https://"):
|
for prefix in ("http://", "https://"):
|
||||||
if url.startswith(prefix):
|
if url.startswith(prefix):
|
||||||
url = url[len(prefix):]
|
url = url[len(prefix):]
|
||||||
break
|
break
|
||||||
authority, _, path = url.partition("/")
|
authority, _, path = url.partition("/")
|
||||||
if ":" in authority:
|
if authority.startswith("["):
|
||||||
|
host, _, rest = authority[1:].partition("]")
|
||||||
|
port = rest[1:] if rest.startswith(":") else default_port
|
||||||
|
elif ":" in authority:
|
||||||
host, _, port = authority.rpartition(":")
|
host, _, port = authority.rpartition(":")
|
||||||
else:
|
else:
|
||||||
host, port = authority, default_port
|
host, port = authority, default_port
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user