Files
shadowsocks-libev/tests/test_half_close.py
Max LvandClaude 90ea095efe 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>
2026-10-27 16:30:19 +08:00

64 lines
2.4 KiB
Python

#!/usr/bin/env python3
"""TCP half-close through ss-local and ss-server: a client that sends its
request and then shuts down its write side still gets the whole response,
and the origin sees the EOF."""
import argparse
import hashlib
import os
from pathlib import Path
import socket
import socketserver
import threading
from interop import free_port, peers, socks
class ReplyAfterEOF(socketserver.BaseRequestHandler):
"""Reads to EOF, then answers with the digest and a large body."""
def handle(self):
self.request.settimeout(15)
digest = hashlib.sha256()
while chunk := self.request.recv(65536):
digest.update(chunk)
body = digest.digest() * 32768 # 1 MiB
self.request.sendall(body)
class Origin(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = True
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bin", required=True, type=Path)
args = parser.parse_args()
bins = args.bin.resolve()
with Origin(("127.0.0.1", 0), ReplyAfterEOF) as origin:
threading.Thread(target=origin.serve_forever, daemon=True).start()
for method in ("aes-256-gcm", "2022-blake3-aes-128-gcm"):
key = ["-k", "pw"] if not method.startswith("2022") else ["-k", "AAAAAAAAAAAAAAAAAAAAAA=="]
sport, lport = free_port(), free_port()
server = [str(bins / "ss-server"), "-s", "127.0.0.1", "-p", str(sport), "-m", method] + key
local = [str(bins / "ss-local"), "-s", "127.0.0.1", "-p", str(sport), "-l", str(lport), "-m",
method] + key
with peers([server, local], [sport, lport]):
sock, _ = socks(lport, 1, ("127.0.0.1", origin.server_address[1]))
with sock:
sock.settimeout(15)
request = os.urandom(200000)
sock.sendall(request)
sock.shutdown(socket.SHUT_WR)
received = bytearray()
while chunk := sock.recv(65536):
received += chunk
expected = hashlib.sha256(request).digest() * 32768
assert bytes(received) == expected, \
f"{method}: got {len(received)} of {len(expected)} bytes after a half-close"
print(f"PASS half-close: {method}")
if __name__ == "__main__":
main()