Files
shadowsocks-libev/tests/test_server_acl.py
T
Max LvandClaude 50f1ae54f7 Fix findings from the security audit (#3083)
* Fix findings from the security audit

Remote:
- TCP: handshake/connect deadlines follow --timeout instead of a 24h
  minimum, so idle sockets can no longer fill the connection pool.
- ACL: IPv4-mapped IPv6 addresses are matched against the IPv4 lists.
- UDP server now enforces the peer ACL and the outbound block list.
- ACL loading fails closed on bad files or invalid entries.
- TCP half-close is relayed instead of tearing the connection down.
- AEAD-2022 UDP sessions are created only after timestamp/replay checks.

ss-manager:
- Owner-only conf files (deleted on remove), 0600 control socket,
  umask 022, safe working directory, loopback-only IP addresses,
  per-user sender check on Linux, plugins only over the UNIX socket.
- Port canonicalisation, list() overflow, add/remove waits for the
  server, stale pid files can't signal unrelated processes.

Also: plugins drop to --user, daemonize readiness/pid file handling,
resolver cancellation, replay-filter fail-closed, key wiping, spec
strictness in AEAD/2022 parsing, JSON surrogate validation, ss:// plugin
name checks, embedding API state reset, Android protect_socket replies.

Adds tests/test_server_acl.py and tests/test_half_close.py and extends
the manager, ACL, AEAD-2022 and ssurl tests.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix CI on Ubuntu 26.04 runners; use string_view over raw string pointers

- Define _GLIBCXX_NO_ASSERTIONS: libstdc++ 15 enables assertions at -O0,
  which pulls __glibcxx_assert_fail (C++ runtime) into the programs.
- test_redir_qemu: boot the guest with -cpu max so the runner's glibc
  loader runs under TCG.
- find_program: std::string_view PATH splitting into a fixed buffer, no
  heap and no null snprintf destination (GCC -Wformat-truncation).
- manager: parse /proc/net/udp numbers with strtoul (cert-err34-c);
  walk /proc/<pid>/cmdline with std::string_view.
- ssurl: sanitise the tag through std::span.

Co-Authored-By: Claude <noreply@anthropic.com>

* coverage: tolerate gcov 15 lambda inconsistencies in lcov 2

Co-Authored-By: Claude <noreply@anthropic.com>

* resolver: replace init's cleanup lambda with a member; lcov --list ignores inconsistent

gcov 15 marks the lambda as not hit while its lines are, which lcov 2
rejects.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-28 09:34:36 +08:00

117 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""ss-server --acl: the outbound block list stops TCP and UDP alike, also
for an IPv4-mapped IPv6 target, and an idle handshake does not hold a
connection slot past --timeout."""
import argparse
import subprocess
import os
from pathlib import Path
import socket
import struct
import tempfile
import threading
import time
from interop import TCPHandler, TCPOrigin, UDPHandler, UDPOrigin, free_port, peers, receive, socks
def tcp_reaches(lport, host, port):
try:
sock, _ = socks(lport, 1, (host, port))
except (OSError, AssertionError):
return False
with sock:
sock.settimeout(3)
try:
sock.sendall(b"ping")
return receive(sock, 4) == b"ping"
except (OSError, AssertionError):
return False
def udp_reaches(lport, port):
control, relay = socks(lport, 3, ("0.0.0.0", 0))
host, rport = relay
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
with control, socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(2)
payload = os.urandom(32)
header = b"\x00\x00\x00\x01" + socket.inet_aton("127.0.0.1") + struct.pack("!H", port)
sock.sendto(header + payload, (host, rport))
try:
reply, _ = sock.recvfrom(65535)
except (TimeoutError, socket.timeout):
return False
return reply.endswith(payload)
def run(bins, acl, check):
sport, lport = free_port(), free_port()
server = [str(bins / "ss-server"), "-s", "127.0.0.1", "-p", str(sport), "-k", "pw", "-m", "aes-256-gcm", "-u",
"-t", "2", "--max-connections", "4"]
if acl is not None:
server += ["--acl", acl]
local = [str(bins / "ss-local"), "-s", "127.0.0.1", "-p", str(sport), "-l", str(lport), "-k", "pw",
"-m", "aes-256-gcm", "-u"]
with peers([server, local], [sport, lport]):
check(sport, lport)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bin", required=True, type=Path)
args = parser.parse_args()
bins = args.bin.resolve()
with TCPOrigin(("127.0.0.1", 0), TCPHandler) as tcp_origin, UDPOrigin(("127.0.0.1", 0), UDPHandler) as udp_origin, \
tempfile.TemporaryDirectory(prefix="ss-acl-") as temp:
threading.Thread(target=tcp_origin.serve_forever, daemon=True).start()
threading.Thread(target=udp_origin.serve_forever, daemon=True).start()
tport, uport = tcp_origin.server_address[1], udp_origin.server_address[1]
acl = Path(temp) / "block.acl"
acl.write_text("[outbound_block_list]\n127.0.0.0/8\n")
mapped = {}
def open_relay(_, lport):
assert tcp_reaches(lport, "127.0.0.1", tport), "TCP relay failed without an ACL"
assert udp_reaches(lport, uport), "UDP relay failed without an ACL"
# Whether this host connects ::ffff:127.0.0.1 at all (dual stack).
mapped["works"] = tcp_reaches(lport, "::ffff:127.0.0.1", tport)
def blocked(sport, lport):
assert not tcp_reaches(lport, "127.0.0.1", tport), "TCP reached a blocked address"
if mapped["works"]:
assert not tcp_reaches(lport, "::ffff:127.0.0.1", tport), \
"TCP reached a blocked address through its IPv4-mapped form"
assert not udp_reaches(lport, uport), "UDP reached a blocked address"
# Sockets that never finish a handshake are closed after -t 2,
# so they cannot hold the four slots.
idle = [socket.create_connection(("127.0.0.1", sport)) for _ in range(4)]
for sock in idle:
sock.sendall(b"x")
time.sleep(4)
for sock in idle:
sock.settimeout(2)
try:
assert sock.recv(1) == b"", "an idle handshake outlived --timeout"
except ConnectionResetError:
pass
sock.close()
run(bins, None, open_relay)
run(bins, str(acl), blocked)
# An ACL that cannot be loaded stops the server instead of running
# it without the block list.
bad = Path(temp) / "bad.acl"
bad.write_text("[outbound_block_list]\n[unterminated\n")
result = subprocess.run([str(bins / "ss-server"), "-s", "127.0.0.1", "-p", str(free_port()), "-k", "pw",
"-m", "aes-256-gcm", "--acl", str(bad)], capture_output=True, timeout=10)
assert result.returncode != 0, "ss-server started with an invalid ACL"
print("PASS ss-server ACL (TCP, IPv4-mapped, UDP), handshake timeout, invalid ACL"
+ ("" if mapped.get("works") else " (no dual-stack: mapped case skipped)"))
if __name__ == "__main__":
main()