refuse tls hellos that carry encrypted client hello before squid sees them
This commit is contained in:
parent
de43dac33c
commit
dff662c6b3
1 changed files with 1537 additions and 43 deletions
|
|
@ -216,6 +216,7 @@ pkgs.testers.runNixOSTest {
|
|||
"L+ /run/hive-rootfs-check - - - - ${pkgs.writeShellScript "hive-priv-stub" ''
|
||||
[ "$1" = egress-load ] && exec ${pkgs.nftables}/bin/nft -f "$2"
|
||||
[ "$1" = egress-whois ] && exec ${hivePriv}/bin/hive-priv "$@"
|
||||
[ "$1" = egress-ech-relay ] && exec ${hivePriv}/bin/hive-priv "$@"
|
||||
exit 0
|
||||
''}"
|
||||
];
|
||||
|
|
@ -319,7 +320,10 @@ pkgs.testers.runNixOSTest {
|
|||
"agentonly.t.local"
|
||||
"api.t.local"
|
||||
];
|
||||
environment.systemPackages = [ pkgs.openssl ];
|
||||
environment.systemPackages = [
|
||||
pkgs.openssl
|
||||
pkgs.python3
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
||||
{
|
||||
address = "192.168.1.3";
|
||||
|
|
@ -957,7 +961,7 @@ pkgs.testers.runNixOSTest {
|
|||
def agent_lines():
|
||||
# Log lines of requests from the agent node's addresses. The self-check
|
||||
# connects from the bridge address, so its lines are not counted.
|
||||
return len(re.findall(r" 192\.168\.1\.[347] [A-Z_]+/\d+ ", journal()))
|
||||
return len(re.findall(r" (?:192\.168\.1|198\.18\.0)\.[347] [A-Z_]+/\d+ ", journal()))
|
||||
|
||||
seeded = proxy.succeed(f"cat {ALLOWED}")
|
||||
bind("atlas", "logan")
|
||||
|
|
@ -980,7 +984,7 @@ pkgs.testers.runNixOSTest {
|
|||
with checked("a restricted agent's allowed name is tunnelled without the proxy variables, and the log names the agent"):
|
||||
wait_icpt("https://forge.t.local/", "200")
|
||||
assert re.search(
|
||||
r"192\.168\.1\.3 TCP_TUNNEL/200 .* CONNECT forge\.t\.local:443 atlas ORIGINAL_DST/203\.0\.113\.2 ", journal()
|
||||
r"198\.18\.0\.3 TCP_TUNNEL/200 .* CONNECT forge\.t\.local:443 atlas ORIGINAL_DST/203\.0\.113\.2 ", journal()
|
||||
), "the HTTPS flow, as atlas"
|
||||
assert icpt("http://forge.t.local/") == "200", "plain HTTP"
|
||||
assert re.search(r"192\.168\.1\.3 TCP_\w+/200 .* GET http://forge\.t\.local/ atlas ", journal()), "the HTTP flow, as atlas"
|
||||
|
|
@ -992,7 +996,7 @@ pkgs.testers.runNixOSTest {
|
|||
|
||||
with checked("a restricted agent's unlisted name is refused, intercepted, and logged as the agent's"):
|
||||
assert icpt("https://other.t.local/") == "000", "HTTPS"
|
||||
assert re.search(r"192\.168\.1\.3 TCP_DENIED/403 .* CONNECT other\.t\.local:443 atlas ", journal()), "logged as atlas"
|
||||
assert re.search(r"198\.18\.0\.3 TCP_DENIED/403 .* CONNECT other\.t\.local:443 atlas ", journal()), "logged as atlas"
|
||||
assert icpt("http://other.t.local/") == "403", "plain HTTP"
|
||||
assert re.search(r"192\.168\.1\.3 TCP_DENIED/403 .* GET http://other\.t\.local/ atlas ", journal()), "logged as atlas"
|
||||
# Control: the same address reaches the listed name, so the refusal is the policy's.
|
||||
|
|
@ -1027,7 +1031,7 @@ pkgs.testers.runNixOSTest {
|
|||
|
||||
with checked("a source the bindings do not name is refused, and so is every source when they are gone"):
|
||||
assert icpt("https://forge.t.local/", NOBODY_IP) == "000", "no lease for the address"
|
||||
assert re.search(r"192\.168\.1\.7 TCP_DENIED/403 .* CONNECT 203\.0\.113\.2:443 - ", journal()), "logged without a user"
|
||||
assert re.search(r"198\.18\.0\.7 TCP_DENIED/403 .* CONNECT 203\.0\.113\.2:443 - ", journal()), "logged without a user"
|
||||
assert icpt("http://forge.t.local/", NOBODY_IP) == "403"
|
||||
assert icpt("https://forge.t.local/") == "200", "control: the bound address, the same name"
|
||||
# A hive whose pin file is missing: the lease names a MAC, no port holds it.
|
||||
|
|
@ -1056,6 +1060,90 @@ pkgs.testers.runNixOSTest {
|
|||
)
|
||||
assert out.strip() == "", "bytes that are no ClientHello get no answer"
|
||||
|
||||
# The ClientHello a client sends, built by hand: SNI, groups, signature
|
||||
# algorithms and supported versions, and with `ech` the encrypted_client_hello
|
||||
# extension (0xfe0d) with the layout a real one has. It prints how the
|
||||
# connection ended: the first byte of what came back, or `closed`.
|
||||
agent.succeed(
|
||||
"cat > /tmp/hello.py <<'PY'\n"
|
||||
"import os, socket, struct, sys\n"
|
||||
"mode, name, dst, src = sys.argv[1:5]\n"
|
||||
"def ext(kind, data):\n"
|
||||
" return struct.pack('>HH', kind, len(data)) + data\n"
|
||||
"host = name.encode()\n"
|
||||
"exts = ext(0, struct.pack('>HBH', len(host) + 3, 0, len(host)) + host)\n"
|
||||
"exts += ext(10, struct.pack('>HHH', 4, 29, 23)) + ext(13, struct.pack('>HHH', 4, 0x0804, 0x0403))\n"
|
||||
"exts += ext(43, bytes([2, 3, 4])) + ext(51, struct.pack('>H', 0))\n"
|
||||
"if mode == 'ech':\n"
|
||||
" exts += ext(0xFE0D, b'\\x00' + struct.pack('>HHBH', 1, 1, 7, 32) + os.urandom(32) + struct.pack('>H', 144) + os.urandom(144))\n"
|
||||
"body = b'\\x03\\x03' + os.urandom(32) + b'\\x20' + os.urandom(32) + struct.pack('>H', 2) + b'\\x13\\x01' + b'\\x01\\x00'\n"
|
||||
"body += struct.pack('>H', len(exts)) + exts\n"
|
||||
"hello = b'\\x01' + struct.pack('>I', len(body))[1:] + body\n"
|
||||
"s = socket.socket()\n"
|
||||
"s.settimeout(10)\n"
|
||||
"s.bind((src, 0))\n"
|
||||
"s.connect((dst, 443))\n"
|
||||
"s.sendall(b'\\x16\\x03\\x01' + struct.pack('>H', len(hello)) + hello)\n"
|
||||
"try:\n"
|
||||
" first = s.recv(1)\n"
|
||||
"except OSError:\n"
|
||||
" first = b'''\n"
|
||||
"print(first.hex() or 'closed')\n"
|
||||
"PY"
|
||||
)
|
||||
|
||||
def send_hello(mode, src=ATLAS_IP):
|
||||
return agent.succeed(f"python3 /tmp/hello.py {mode} forge.t.local 203.0.113.2 {src}").strip()
|
||||
|
||||
with checked("a ClientHello with the ECH extension is refused for a restricted agent and observed for a log agent, and the same hello without it is let through"):
|
||||
seen = agent_lines()
|
||||
# Control: the origin answers a hello without the extension (a
|
||||
# handshake record, 0x16), and squid logs the connection as atlas.
|
||||
assert send_hello("plain") == "16", "the origin answers the hello without ECH"
|
||||
time.sleep(2)
|
||||
assert agent_lines() > seen, "squid saw the connection"
|
||||
seen = agent_lines()
|
||||
assert send_hello("ech") == "closed", "a hello with an ECH extension gets no answer"
|
||||
proxy.wait_until_succeeds(
|
||||
"journalctl -u hive-egress-ech-relay.service -o cat | "
|
||||
"grep -E '^hive-egress-ech-refused agent=atlas dst=203\\.0\\.113\\.2$'"
|
||||
)
|
||||
time.sleep(2)
|
||||
assert agent_lines() == seen, "squid saw nothing of it"
|
||||
# A `log` agent is observed, not refused: its ECH hello goes on to squid
|
||||
# and the origin, and the relay logs what it would have done. The mode is
|
||||
# what the squid include says (`acl agent_log_logan proxy_auth logan`).
|
||||
seen = agent_lines()
|
||||
assert send_hello("ech", LOGAN_IP) == "16", "logan's hello with ECH is let through"
|
||||
proxy.wait_until_succeeds(
|
||||
"journalctl -u hive-egress-ech-relay.service -o cat | "
|
||||
"grep -E '^hive-egress-ech-would-refuse agent=logan dst=203\\.0\\.113\\.2$'"
|
||||
)
|
||||
time.sleep(2)
|
||||
assert agent_lines() > seen, "squid saw logan's connection"
|
||||
assert "hive-egress-ech-refused agent=logan" not in proxy.succeed(
|
||||
"journalctl -u hive-egress-ech-relay.service -o cat --no-pager"
|
||||
), "logan was not refused"
|
||||
assert "ech-would-refuse agent=atlas" not in proxy.succeed(
|
||||
"journalctl -u hive-egress-ech-relay.service -o cat --no-pager"
|
||||
), "control: the restricted agent got the refusal, not the observation"
|
||||
assert send_hello("plain", LOGAN_IP) == "16", "control: logan's hello without ECH"
|
||||
# A source the bindings do not name is refused too, logged without a name.
|
||||
assert send_hello("ech", NOBODY_IP) == "closed"
|
||||
proxy.wait_until_succeeds(
|
||||
"journalctl -u hive-egress-ech-relay.service -o cat | "
|
||||
"grep -E '^hive-egress-ech-refused agent=- dst=203\\.0\\.113\\.2$'"
|
||||
)
|
||||
# Bytes that are no ClientHello: refused by the relay, with a line of their own.
|
||||
out = agent.succeed(
|
||||
"timeout 5 bash -c 'exec 3<>/dev/tcp/203.0.113.2/443; echo hello >&3; cat <&3' 2>/dev/null || true"
|
||||
)
|
||||
assert out.strip() == "", "bytes that are no ClientHello get no answer"
|
||||
proxy.wait_until_succeeds(
|
||||
"journalctl -u hive-egress-ech-relay.service -o cat | "
|
||||
"grep -E '^hive-egress-hello-refused agent=atlas dst=203\\.0\\.113\\.2 reason=not a TLS handshake record$'"
|
||||
)
|
||||
|
||||
with checked("the traffic that stays on the bridge or is the unrestricted agents' is not intercepted"):
|
||||
seen = agent_lines()
|
||||
# The gateway on the bridge address, in the stand-in's port 443.
|
||||
|
|
@ -1068,6 +1156,7 @@ pkgs.testers.runNixOSTest {
|
|||
assert icpt("https://other.t.local/") == "200", "an unlisted name, directly"
|
||||
assert icpt("https://203.0.113.2/") == "200", "an IP literal, directly"
|
||||
assert agent_lines() == seen, "squid saw nothing of it"
|
||||
assert send_hello("ech") == "16", "a hello with ECH, directly: the relay is not in the path"
|
||||
proxy.wait_until_succeeds(
|
||||
"journalctl -k -o cat --no-pager | grep -E "
|
||||
"'hive-egress-would-drop(-unmetered)? IN=eth1 OUT=eth2 .*SRC=192\\.168\\.1\\.3 DST=203\\.0\\.113\\.2 .*PROTO=TCP .*DPT=443 '"
|
||||
|
|
|
|||
Loading…
Reference in a new issue