mirror of
https://github.com/shadowsocks/shadowsocks-libev.git
synced 2026-10-28 10:44:43 +08:00
* 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>
167 lines
7.7 KiB
Python
167 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""ss-manager end to end: add a server over the control socket, relay through
|
|
it with ss-local, see its traffic in ping, list it, and remove it; over UDP
|
|
and over a unix socket."""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import stat as statmode
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
from interop import TCPHandler, TCPOrigin, free_port, peers, receive, socks
|
|
|
|
|
|
def control(port, message, timeout=5):
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
sock.settimeout(timeout)
|
|
sock.sendto(message.encode(), ("127.0.0.1", port))
|
|
return sock.recvfrom(65535)[0].decode().rstrip("\0")
|
|
|
|
|
|
def unix_control(path, message, timeout=5):
|
|
with tempfile.TemporaryDirectory() as temp, socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
|
|
sock.bind(os.path.join(temp, "c"))
|
|
sock.settimeout(timeout)
|
|
sock.sendto(message.encode(), path)
|
|
return sock.recvfrom(65535)[0].decode().rstrip("\0")
|
|
|
|
|
|
def wait_closed(port, deadline=10):
|
|
end = time.monotonic() + deadline
|
|
while time.monotonic() < end:
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=.2):
|
|
time.sleep(.05)
|
|
except OSError:
|
|
return
|
|
raise AssertionError(f"managed server on {port} kept running")
|
|
|
|
|
|
def relay_and_count(bins, ask, server_port, local_port, origin):
|
|
local = [str(bins / "ss-local"), "-s", "127.0.0.1", "-p", str(server_port), "-l", str(local_port),
|
|
"-k", "secret", "-m", "aes-256-gcm"]
|
|
with peers([local], [local_port]):
|
|
sock, _ = socks(local_port, 1, ("127.0.0.1", origin.server_address[1]))
|
|
payload = os.urandom(65536)
|
|
with sock:
|
|
sock.sendall(payload)
|
|
assert receive(sock, len(payload)) == payload, "relay through the managed server failed"
|
|
# ss-server reports traffic to the manager every few seconds.
|
|
deadline = time.monotonic() + 15
|
|
traffic = 0
|
|
while time.monotonic() < deadline and traffic == 0:
|
|
stat = json.loads(ask("ping")[len("stat: "):])
|
|
traffic = stat.get(str(server_port), 0)
|
|
time.sleep(.5)
|
|
assert traffic > 0, "no traffic statistics from the managed server"
|
|
|
|
|
|
def unix_socket_case(bins, origin):
|
|
server_port, local_port = free_port(), free_port()
|
|
with tempfile.TemporaryDirectory() as workdir:
|
|
path = os.path.join(workdir, "m.sock")
|
|
manager = subprocess.Popen(
|
|
[str(bins / "ss-manager"), "--manager-address", path, "--executable", str(bins / "ss-server"),
|
|
"-D", workdir, "-s", "127.0.0.1", "-m", "aes-256-gcm"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
ask = lambda message: unix_control(path, message)
|
|
try:
|
|
for _ in range(50):
|
|
if os.path.exists(path):
|
|
break
|
|
time.sleep(.1)
|
|
# No authentication on the control socket: only its owner may use it.
|
|
assert statmode.S_IMODE(os.stat(path).st_mode) == 0o600, oct(os.stat(path).st_mode)
|
|
assert ask('add: {"server_port": %d, "password": "secret"}' % server_port) == "ok"
|
|
wait_listening(server_port)
|
|
relay_and_count(bins, ask, server_port, local_port, origin)
|
|
assert ask('remove: {"server_port": %d}' % server_port) == "ok"
|
|
finally:
|
|
manager.terminate()
|
|
out = manager.communicate(timeout=10)[0].decode(errors="replace")
|
|
assert manager.returncode == 0, f"ss-manager exited with {manager.returncode}:\n{out}"
|
|
|
|
|
|
def wait_listening(port, deadline=10):
|
|
end = time.monotonic() + deadline
|
|
while time.monotonic() < end:
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=.2):
|
|
return
|
|
except OSError:
|
|
time.sleep(.05)
|
|
raise AssertionError(f"managed server did not listen on {port}")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--bin", required=True, type=Path)
|
|
args = parser.parse_args()
|
|
bins = args.bin.resolve()
|
|
manager_port, server_port, local_port = free_port(), free_port(), free_port()
|
|
with TCPOrigin(("127.0.0.1", 0), TCPHandler) as origin, tempfile.TemporaryDirectory() as workdir:
|
|
threading.Thread(target=origin.serve_forever, daemon=True).start()
|
|
manager = subprocess.Popen(
|
|
[str(bins / "ss-manager"), "--manager-address", f"127.0.0.1:{manager_port}",
|
|
"--executable", str(bins / "ss-server"), "-D", workdir, "-s", "127.0.0.1",
|
|
"-m", "aes-256-gcm"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
try:
|
|
# The manager answers once its control socket is up.
|
|
for _ in range(50):
|
|
try:
|
|
reply = control(manager_port, "ping", timeout=.2)
|
|
break
|
|
except (TimeoutError, OSError):
|
|
time.sleep(.1)
|
|
else:
|
|
raise AssertionError("ss-manager did not answer")
|
|
assert reply.startswith("stat:"), reply
|
|
reply = control(manager_port, 'add: {"server_port": %d, "password": "secret"}' % server_port)
|
|
assert reply == "ok", reply
|
|
wait_listening(server_port)
|
|
conf = Path(workdir) / f".shadowsocks_{server_port}.conf"
|
|
# The config holds the password.
|
|
assert statmode.S_IMODE(conf.stat().st_mode) == 0o600, oct(conf.stat().st_mode)
|
|
relay_and_count(bins, lambda message: control(manager_port, message), server_port, local_port, origin)
|
|
listed = json.loads(control(manager_port, "list"))
|
|
assert any(entry.get("server_port") == str(server_port) for entry in listed), listed
|
|
assert control(manager_port, 'remove: {"server_port": %d}' % server_port) == "ok"
|
|
assert json.loads(control(manager_port, "list")) == [], "server still listed after remove"
|
|
assert not conf.exists(), "the config (and its password) outlived remove"
|
|
# "0<port>" names the same server as "<port>".
|
|
reply = control(manager_port, 'add: {"server_port": "0%d", "password": "secret"}' % server_port)
|
|
assert reply == "ok", reply
|
|
wait_listening(server_port)
|
|
listed = json.loads(control(manager_port, "list"))
|
|
assert [entry.get("server_port") for entry in listed] == [str(server_port)], listed
|
|
assert control(manager_port, 'remove: {"server_port": %d}' % server_port) == "ok"
|
|
wait_closed(server_port)
|
|
# A per-server plugin names a program to run: not over UDP.
|
|
reply = control(manager_port,
|
|
'add: {"server_port": %d, "password": "x", "plugin": "/bin/true"}' % server_port)
|
|
assert reply != "ok", "a plugin was accepted over the UDP control channel"
|
|
assert json.loads(control(manager_port, "list")) == []
|
|
finally:
|
|
manager.terminate()
|
|
out = manager.communicate(timeout=10)[0].decode(errors="replace")
|
|
assert manager.returncode == 0, f"ss-manager exited with {manager.returncode}:\n{out}"
|
|
unix_socket_case(bins, origin)
|
|
# Over IP the unauthenticated protocol is loopback-only.
|
|
refused = subprocess.run(
|
|
[str(bins / "ss-manager"), "--manager-address", f"0.0.0.0:{free_port()}",
|
|
"--executable", str(bins / "ss-server"), "-D", workdir, "-m", "aes-256-gcm"],
|
|
capture_output=True, timeout=10)
|
|
assert refused.returncode != 0, "ss-manager listened on a non-loopback address"
|
|
print("PASS ss-manager add/ping/list/remove with traffic statistics (UDP and unix socket)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|