From 90ea095efe47662a3d3c34e92824f2028a88dae0 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 27 Oct 2026 16:30:19 +0800 Subject: [PATCH] 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 --- doc/ss-manager.md | 20 ++ src/acl.cpp | 79 +++++--- src/android.c | 20 +- src/core/buffer.hpp | 2 + src/crypto/aead.cpp | 14 +- src/crypto/aead.hpp | 12 ++ src/crypto/aead2022.cpp | 22 +- src/crypto/aead2022.hpp | 19 ++ src/crypto/keys.cpp | 7 +- src/event/program.hpp | 8 +- src/jconf.cpp | 29 +-- src/json.cpp | 23 ++- src/local.cpp | 100 ++++++--- src/manager.cpp | 314 +++++++++++++++++++++++++++-- src/netutils.cpp | 12 +- src/plugin.cpp | 12 ++ src/plugin.h | 3 + src/plugin_disabled.cpp | 1 + src/process.cpp | 183 +++++++++++++++++ src/redir.cpp | 2 + src/relay/codec.cpp | 9 +- src/relay/codec.hpp | 4 + src/relay/resolver.cpp | 20 +- src/relay/resolver.hpp | 1 + src/relay/tcp.cpp | 107 +++++++--- src/relay/tcp.hpp | 30 ++- src/relay/udp.cpp | 91 +++++++-- src/relay/udp.hpp | 7 +- src/rule.cpp | 23 ++- src/server.cpp | 54 ++--- src/shadowsocks/control.h | 6 +- src/ss_process.h | 3 + src/ssurl.cpp | 17 ++ src/tunnel.cpp | 2 + src/utils.cpp | 83 +++++++- src/utils.h | 4 + tests/CMakeLists.txt | 8 + tests/test_acl_cpp.cpp | 102 +++++++--- tests/test_crypto_aead2022_cpp.cpp | 42 +++- tests/test_half_close.py | 63 ++++++ tests/test_manager.py | 111 ++++++++-- tests/test_server_acl.py | 116 +++++++++++ tests/test_ssurl_cpp.cpp | 22 ++ 43 files changed, 1560 insertions(+), 247 deletions(-) create mode 100644 tests/test_half_close.py create mode 100644 tests/test_server_acl.py diff --git a/doc/ss-manager.md b/doc/ss-manager.md index 47fe9711..c0ef4967 100644 --- a/doc/ss-manager.md +++ b/doc/ss-manager.md @@ -62,6 +62,26 @@ stat: {"8001":11370} There is no way to reset the traffic statistics, unless you remove the port and add it again +\section ss_manager_security SECURITY + +The protocol carries no credentials, so ss-manager decides who may use it by +where commands come from: + +- A UNIX domain socket (recommended) is created with mode 0600, owned by the + user ss-manager runs as (or by the `-a` user its servers run as). +- An IP manager address must be a loopback one; any other is refused. On + Linux a datagram is obeyed only if its source socket belongs to the same + user, root, or the `-a` user; elsewhere any local user can reach a + loopback address, so prefer the UNIX socket there. +- A per-server `plugin` names a program to run and is accepted only over the + UNIX socket. + +The working directory (`-D`) holds each server's configuration, with its +password, in owner-only files that are deleted when the port is removed; it +must be owned by the user running ss-manager and writable by nobody else. +`add` answers once the new ss-server is serving (or has failed), within ten +seconds. + \section ss_manager_example EXAMPLE To use ss-manager(1), First start it and specify necessary information. diff --git a/src/acl.cpp b/src/acl.cpp index ab315a5c..ab7dbe90 100644 --- a/src/acl.cpp +++ b/src/acl.cpp @@ -50,6 +50,24 @@ static struct ss_ipset outbound_block_list_ipv4; static struct ss_ipset outbound_block_list_ipv6; static rule_list_t outbound_block_list_rules; +/* + * Parse an address for ACL lookups. An IPv4-mapped IPv6 address + * (::ffff:a.b.c.d) reaches the IPv4 host on a dual-stack socket, so it is + * matched against the IPv4 lists. + */ +static int +acl_ip_init(struct ss_ip *addr, const char *text) +{ + static const uint8_t mapped_prefix[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff }; + int err = ss_ip_init(addr, text); + if (!err && addr->version == 6 && memcmp(addr->bytes, mapped_prefix, sizeof(mapped_prefix)) == 0) { + memmove(addr->bytes, addr->bytes + 12, 4); + addr->version = 4; + return 1; + } + return err; +} + static int parse_addr_cidr(const char *str, char *host, size_t host_len, int *cidr) { @@ -116,14 +134,8 @@ init_acl(const char *path) return -1; } - // initialize ipset - - ss_ipset_clear(&white_list_ipv4); - ss_ipset_clear(&white_list_ipv6); - ss_ipset_clear(&black_list_ipv4); - ss_ipset_clear(&black_list_ipv6); - ss_ipset_clear(&outbound_block_list_ipv4); - ss_ipset_clear(&outbound_block_list_ipv6); + // Start from nothing: a reload must not keep the previous lists or mode. + free_acl(); struct ss_ipset *list_ipv4 = &black_list_ipv4; @@ -203,16 +215,33 @@ init_acl(const char *path) int cidr; if (parse_addr_cidr(line, host, sizeof(host), &cidr) == -1) { LOGE("invalid ACL entry: %s", line); - continue; + fclose(f); + free_acl(); + return -1; } struct ss_ip addr; - int err = ss_ip_init(&addr, host); + int err = acl_ip_init(&addr, host); + if (err == 1) { + // ::ffff:a.b.c.d/N is the IPv4 prefix a.b.c.d/(N-96) + if (cidr >= 0 && cidr < 96) { + LOGE("invalid ACL IPv4-mapped CIDR: %s", line); + fclose(f); + free_acl(); + return -1; + } + if (cidr >= 96) { + cidr -= 96; + } + err = 0; + } if (!err) { if (addr.version == 4) { if (cidr > 32 || cidr == -2) { LOGE("invalid ACL IPv4 CIDR: %s", line); - continue; + fclose(f); + free_acl(); + return -1; } if (cidr >= 0) { ss_ipset_assign(list_ipv4, addr.bytes, 32, (unsigned)cidr, true); @@ -222,7 +251,9 @@ init_acl(const char *path) } else if (addr.version == 6) { if (cidr > 128 || cidr == -2) { LOGE("invalid ACL IPv6 CIDR: %s", line); - continue; + fclose(f); + free_acl(); + return -1; } if (cidr >= 0) { ss_ipset_assign(list_ipv6, addr.bytes, 128, (unsigned)cidr, true); @@ -232,21 +263,17 @@ init_acl(const char *path) } } else { rule_t *rule = new_rule(); - if (rule == NULL) { - continue; - } - int status = accept_rule_arg(rule, line); + int status = rule != NULL ? accept_rule_arg(rule, line) : -1; if (status == 1) status = init_rule(rule); - if (status < 0) { + if (status != 1) { + // A rule that cannot be used must not silently drop + // out of a block list. + LOGE("invalid ACL rule: %s", line); free_rule(rule); fclose(f); free_acl(); return -1; } - if (status != 1) { - free_rule(rule); - continue; - } add_rule(rules, rule); } } @@ -281,6 +308,8 @@ free_acl(void) free_rules(&black_list_rules); free_rules(&white_list_rules); free_rules(&outbound_block_list_rules); + + acl_mode = BLACK_LIST; } int @@ -299,7 +328,7 @@ acl_match_host(const char *host) { struct ss_ip addr; int ret = 0; - int err = ss_ip_init(&addr, host); + int err = acl_ip_init(&addr, host) < 0 ? -1 : 0; if (err) { int host_len = strlen(host); @@ -329,7 +358,7 @@ int acl_add_ip(const char *ip) { struct ss_ip addr; - int err = ss_ip_init(&addr, ip); + int err = acl_ip_init(&addr, ip) < 0 ? -1 : 0; if (err) { return -1; } @@ -347,7 +376,7 @@ int acl_remove_ip(const char *ip) { struct ss_ip addr; - int err = ss_ip_init(&addr, ip); + int err = acl_ip_init(&addr, ip) < 0 ? -1 : 0; if (err) { return -1; } @@ -370,7 +399,7 @@ outbound_block_match_host(const char *host) { struct ss_ip addr; int ret = 0; - int err = ss_ip_init(&addr, host); + int err = acl_ip_init(&addr, host) < 0 ? -1 : 0; if (err) { int host_len = strlen(host); diff --git a/src/android.c b/src/android.c index 5b6db3dd..678611e9 100644 --- a/src/android.c +++ b/src/android.c @@ -80,16 +80,26 @@ protect_socket(int fd) return -1; } - char ret = 0; - - if (recv(sock, &ret, 1, 0) == -1) { - ERROR("[android] recv"); + // The VPN service answers one byte: 0 once the socket is protected. + // Anything else, or no answer, leaves the socket routed into the VPN. + char ret = 1; + ssize_t n = recv(sock, &ret, 1, 0); + if (n != 1) { + if (n == -1) { + ERROR("[android] recv"); + } else { + LOGE("[android] protect_path closed without an answer"); + } close(sock); return -1; } close(sock); - return ret; + if (ret != 0) { + LOGE("[android] failed to protect socket %d", fd); + return -1; + } + return 0; } extern char *stat_path; diff --git a/src/core/buffer.hpp b/src/core/buffer.hpp index b949b40b..44a612ec 100644 --- a/src/core/buffer.hpp +++ b/src/core/buffer.hpp @@ -39,6 +39,8 @@ public: constexpr std::size_t capacity() const noexcept { return N; } constexpr std::size_t size() const noexcept { return len_; } + // All of the storage, whatever is in use, for wiping secrets. + Bytes storage() noexcept { return { data_.data(), N }; } constexpr bool empty() const noexcept { return len_ == 0; } constexpr bool full() const noexcept { return idx_ + len_ == N; } diff --git a/src/crypto/aead.cpp b/src/crypto/aead.cpp index 515c2c4d..159cf7d7 100644 --- a/src/crypto/aead.cpp +++ b/src/crypto/aead.cpp @@ -91,7 +91,8 @@ private: bool ppbloom_seen(void *, std::span salt) noexcept { - return ppbloom_check(salt.data(), static_cast(salt.size())) == 1; + // An unusable filter (-1) counts as seen: replay protection fails closed. + return ppbloom_check(salt.data(), static_cast(salt.size())) != 0; } void ppbloom_remember(void *, std::span salt) noexcept @@ -177,6 +178,7 @@ Status hkdf_sha1(std::span salt, std::span= 238 bits of a 32-byte salt + // but only ~111 of a 16-byte one: not offered there. + return fail(Errc::config); + } if (printable_salt) { // Vary the constrained span without adding a delimiter or marker. std::size_t prefix = 6 + randombytes_uniform(7); @@ -418,8 +425,9 @@ Result AeadTcpDecryptor::decrypt(std::span kMaxChunk) { failed_ = true; return fail(Errc::protocol); } diff --git a/src/crypto/aead.hpp b/src/crypto/aead.hpp index 825fc7f5..4c4c2b09 100644 --- a/src/crypto/aead.hpp +++ b/src/crypto/aead.hpp @@ -68,6 +68,12 @@ struct MasterKey { AeadSpec spec; std::array bytes{}; + MasterKey() noexcept = default; + MasterKey(const AeadSpec &s, const std::array &b) noexcept : spec(s), bytes(b) {} + MasterKey(const MasterKey &) noexcept = default; + MasterKey &operator=(const MasterKey &) noexcept = default; + ~MasterKey() { sodium_memzero(bytes.data(), bytes.size()); } + std::span key() const noexcept { return { bytes.data(), spec.key_len }; } // EVP_BytesToKey(MD5) from a password, as the C implementation does. @@ -168,6 +174,12 @@ private: class AeadTcpDecryptor { public: + AeadTcpDecryptor() noexcept = default; + AeadTcpDecryptor(const AeadTcpDecryptor &) = delete; + AeadTcpDecryptor &operator=(const AeadTcpDecryptor &) = delete; + // The reassembly buffer held decrypted payload. + ~AeadTcpDecryptor() { sodium_memzero(buffer_.storage().data(), buffer_.storage().size()); } + Status init(const MasterKey &master, ReplayFilter filter) noexcept; struct Step { diff --git a/src/crypto/aead2022.cpp b/src/crypto/aead2022.cpp index 1b16db06..e009720c 100644 --- a/src/crypto/aead2022.cpp +++ b/src/crypto/aead2022.cpp @@ -83,7 +83,7 @@ std::size_t socks_addr_len(std::span buf) noexcept return 0; } std::size_t len = 0; - switch (buf[0] & 0x0F) { + switch (buf[0]) { // exactly 1, 3 or 4, as the SOCKS parser reads it case 1: len = 1 + 4; break; @@ -222,7 +222,9 @@ bool SaltPool::check_and_add(std::span salt, std::uint64_t n return false; } if (seen_.full()) { - (void)seen_.pop_oldest(); + // Every remembered salt is still inside the replay window: forgetting + // one would let its request be replayed, so refuse the new one. + return false; } return seen_.emplace(*key, now).has_value(); } @@ -672,12 +674,24 @@ Result udp2022_accept(Role role, UdpSession2022 &session, const Udp } std::size_t pad = load16_be(body + o); o += kLenSize; - if (o + pad > n) { + if (pad > k2022MaxPad || o + pad > n) { return fail(Errc::protocol); } o += pad; - if (!session.rx_ready || session.rx_id != packet.session_id) { + if (session.rx_ready && session.rx_id != packet.session_id) { + // Keep the current window as the previous one, or bring the previous + // one back for a late packet of that session. + bool back = session.prev_ready && session.prev_id == packet.session_id; + std::swap(session.rx_id, session.prev_id); + std::swap(session.rx_highest, session.prev_highest); + std::swap(session.rx_window, session.prev_window); + std::swap(session.rx_ready, session.prev_ready); + if (!back) { + session.rx_ready = false; + } + } + if (!session.rx_ready) { window_reset(session, packet.session_id, packet.packet_id); } else if (!window_check(session, packet.packet_id)) { return fail(Errc::crypto); // replayed diff --git a/src/crypto/aead2022.hpp b/src/crypto/aead2022.hpp index daa8d825..45dd8ad5 100644 --- a/src/crypto/aead2022.hpp +++ b/src/crypto/aead2022.hpp @@ -54,6 +54,12 @@ struct Psk { Aead2022Spec spec; std::array bytes{}; + Psk() noexcept = default; + Psk(const Aead2022Spec &s, const std::array &b) noexcept : spec(s), bytes(b) {} + Psk(const Psk &) noexcept = default; + Psk &operator=(const Psk &) noexcept = default; + ~Psk() { sodium_memzero(bytes.data(), bytes.size()); } + std::size_t size() const noexcept { return spec.aead.key_len; } std::span key() const noexcept { return { bytes.data(), size() }; } bool chacha() const noexcept { return spec.aead.kind == AeadKind::chacha20_poly1305; } @@ -112,6 +118,12 @@ private: class Aead2022TcpDecryptor { public: + Aead2022TcpDecryptor() noexcept = default; + Aead2022TcpDecryptor(const Aead2022TcpDecryptor &) = delete; + Aead2022TcpDecryptor &operator=(const Aead2022TcpDecryptor &) = delete; + // Chunks are decrypted in place: the buffer held plaintext. + ~Aead2022TcpDecryptor() { sodium_memzero(buffer_.storage().data(), buffer_.storage().size()); } + // A client decryptor checks the response echoes `request_salt`; a server // decryptor rejects salts already in `pool` (which may be null in tests). Status init(const Psk &psk, Role role, std::span request_salt = {}, @@ -166,6 +178,13 @@ struct UdpSession2022 { std::array rx_window{}; bool rx_ready = false; + // The peer session heard from before rx_id, so a peer that starts a new + // session cannot have its old packets replayed into a fresh window. + std::array prev_id{}; + std::uint64_t prev_highest = 0; + std::array prev_window{}; + bool prev_ready = false; + // Server side: the client session ID replies are addressed to. std::array peer_id{}; bool peer_known = false; diff --git a/src/crypto/keys.cpp b/src/crypto/keys.cpp index 091d6245..8681d3c8 100644 --- a/src/crypto/keys.cpp +++ b/src/crypto/keys.cpp @@ -54,9 +54,14 @@ bool parse_key(const char *base64, std::span key, bool exact) noex // One spare byte tells an over-long key from an exact one; base64_decode // never writes past the size it is given. std::uint8_t out[kMaxKey + 1]; - int want = static_cast(key.size()) + (exact ? 1 : 0); + int want = static_cast(key.size()) + 1; int n = base64_decode(out, base64, want); bool ok = exact ? n == static_cast(key.size()) : n >= static_cast(key.size()); + if (ok && n > static_cast(key.size())) { + // Kept for existing configs, but two different strings must not + // silently be the same key. + LOGE("the key is longer than %zu bytes; only the first %zu are used", key.size(), key.size()); + } if (ok) { std::memcpy(key.data(), out, key.size()); } diff --git a/src/event/program.hpp b/src/event/program.hpp index c253ef3c..7781dd5a 100644 --- a/src/event/program.hpp +++ b/src/event/program.hpp @@ -60,9 +60,13 @@ public: if (signal_count_ == signals_.size()) { return fail(Errc::exhausted); } - SignalWatch &w = signals_[signal_count_++]; + SignalWatch &w = signals_[signal_count_]; w.program = this; - (void)w.signal.open(loop_, w, signum); + Status opened = w.signal.open(loop_, w, signum); + if (w.signal.is_open()) { + ++signal_count_; // close() releases it even if starting failed + } + SS_TRY(opened); } (void)usr1; return {}; diff --git a/src/jconf.cpp b/src/jconf.cpp index 8e679786..766f44fb 100644 --- a/src/jconf.cpp +++ b/src/jconf.cpp @@ -382,13 +382,14 @@ read_jconf(const char *file) json_value *value = obj->u.object.values[i].value; if (strcmp(name, "server") == 0) { if (value->type == json_array) { - for (j = 0; j < value->u.array.length; j++) { - if (j >= MAX_REMOTE_NUM) { - break; + conf.remote_num = 0; + for (j = 0; j < value->u.array.length && conf.remote_num < MAX_REMOTE_NUM; j++) { + char *host = to_string(value->u.array.values[j]); + if (host == NULL) { + continue; // a null entry is not an address } - json_value *v = value->u.array.values[j]; - parse_conf_addr(to_string(v), conf.remote_addr + j); - conf.remote_num = j + 1; + parse_conf_addr(host, conf.remote_addr + conf.remote_num); + conf.remote_num++; } } else if (value->type == json_string) { parse_conf_addr(to_string(value), conf.remote_addr); @@ -396,16 +397,18 @@ read_jconf(const char *file) } } else if (strcmp(name, "port_password") == 0) { if (value->type == json_object) { - for (j = 0; j < value->u.object.length; j++) { - if (j >= MAX_PORT_NUM) { - break; - } + // Entries are packed: a skipped one leaves no hole. + conf.port_password_num = 0; + for (j = 0; j < value->u.object.length && conf.port_password_num < MAX_PORT_NUM; j++) { json_value *v = value->u.object.values[j].value; if (v->type == json_string) { - conf.port_password[j].port = conf_strndup( + int k = conf.port_password_num++; + conf.port_password[k].port = conf_strndup( std::string_view(value->u.object.values[j].name, value->u.object.values[j].name_length)); - conf.port_password[j].password = to_string(v); - conf.port_password_num = j + 1; + conf.port_password[k].password = to_string(v); + } else { + LOGE("ignoring the port_password entry for %s: the password must be a string", + value->u.object.values[j].name); } } } diff --git a/src/json.cpp b/src/json.cpp index dd4e9d0b..4f9ee22f 100644 --- a/src/json.cpp +++ b/src/json.cpp @@ -327,7 +327,19 @@ json_value * json_parse_ex (json_settings * settings, uc_b2 = (uc_b3 << 4) | uc_b4; uchar = (uc_b1 << 8) | uc_b2; - if ((uchar & 0xF800) == 0xD800) { + if (uchar == 0) { + // Every string ends up as a C string: a NUL would + // silently cut it short. + sprintf (error, "Invalid escape \\u0000 (at %d:%d)", line_and_col); + goto e_failed; + } + + if ((uchar & 0xFC00) == 0xDC00) { + sprintf (error, "Unpaired low surrogate (at %d:%d)", line_and_col); + goto e_failed; + } + + if ((uchar & 0xFC00) == 0xD800) { json_uchar uchar2; if (end - state.ptr <= 6 || (*++ state.ptr) != '\\' || (*++ state.ptr) != 'u' || @@ -343,8 +355,13 @@ json_value * json_parse_ex (json_settings * settings, uc_b1 = (uc_b1 << 4) | uc_b2; uc_b2 = (uc_b3 << 4) | uc_b4; uchar2 = (uc_b1 << 8) | uc_b2; - - uchar = 0x010000 | ((uchar & 0x3FF) << 10) | (uchar2 & 0x3FF); + + if ((uchar2 & 0xFC00) != 0xDC00) { + sprintf (error, "Invalid surrogate pair (at %d:%d)", line_and_col); + goto e_failed; + } + + uchar = 0x10000 + ((uchar & 0x3FF) << 10) + (uchar2 & 0x3FF); } if (sizeof (json_char) >= sizeof (json_uchar) || (uchar <= 0x7F)) diff --git a/src/local.cpp b/src/local.cpp index 4c7f7e85..f4855b27 100644 --- a/src/local.cpp +++ b/src/local.cpp @@ -301,11 +301,11 @@ struct Relays { } resolving = true; } - if (!tcp.init(*loop, method, config, resolving ? &resolver : NULL) || !tcp.listen(listenfd)) { + if (!tcp.init(*loop, method, config, resolving ? &resolver : NULL)) { return false; } - tcp_on = true; - return true; + tcp_on = true; // stop() shuts it down, closing listenfd once it is taken + return (bool)tcp.listen(listenfd); } // Returns the UDP socket, or -1. @@ -674,7 +674,11 @@ Same behavior as `-S`; see that option for details. Short alias: `-S`. break; case GETOPT_VAL_ACL: LOGI("initializing acl..."); - acl = !init_acl(optarg); + if (init_acl(optarg) != 0) { + LOGE("failed to load the ACL file %s", optarg); + FATAL("invalid ACL"); + } + acl = 1; break; case GETOPT_VAL_MTU: if (ss_parse_int(optarg, 0, INT_MAX, &mtu) == -1) { @@ -968,7 +972,11 @@ Same behavior as `-S`; see that option for details. Short alias: `-S`. } if (acl == 0 && conf->acl != NULL) { LOGI("initializing acl..."); - acl = !init_acl(conf->acl); + if (init_acl(conf->acl) != 0) { + LOGE("failed to load the ACL file %s", conf->acl); + FATAL("invalid ACL"); + } + acl = 1; } } @@ -1163,6 +1171,7 @@ Same behavior as `-S`; see that option for details. Short alias: `-S`. snprintf(remote_str + len, buf_size - len, "|%s", remote_addr[i].host); len = strlen(remote_str); } + set_plugin_user(user); // it drops root along with us int err = start_plugin(plugin, plugin_opts, remote_str, remote_port, plugin_host, plugin_port, #ifdef __MINGW32__ @@ -1271,6 +1280,7 @@ Same behavior as `-S`; see that option for details. Short alias: `-S`. } #endif + daemon_ready(); // Enter the loop ss::heap_seal(); { @@ -1306,13 +1316,14 @@ Same behavior as `-S`; see that option for details. Short alias: `-S`. // ------------------------------------------------------------ library API -int _start_ss_local_server(profile_t profile, ss_local_callback callback, void *udata); +int _start_ss_local_server(profile_t profile, ss_local_callback callback, void *udata, bool embedded); struct ss_local_handle { profile_t profile; ss_local_callback callback; void *udata; uv_thread_t thread; + uv_mutex_t join_lock; // serialises ss_local_wait() callers int result; bool waited; bool stop_requested; // guarded by control_lock @@ -1348,7 +1359,7 @@ void free_profile(profile_t *p) void server_thread(void *arg) { ss_local_handle *h = (ss_local_handle *)arg; - h->result = _start_ss_local_server(h->profile, h->callback, h->udata); + h->result = _start_ss_local_server(h->profile, h->callback, h->udata, true); uv_mutex_lock(&control_lock); running_handle = NULL; server_busy = false; @@ -1384,6 +1395,12 @@ ss_local_start(const profile_t *profile, ss_local_callback callback, void *udata h->profile.log = copy_string(profile->log); h->callback = callback; h->udata = udata; + if (uv_mutex_init(&h->join_lock) != 0) { + uv_mutex_unlock(&control_lock); + free_profile(&h->profile); + free(h); // heap-ok: library API, startup + return ENOMEM; + } server_busy = true; running_handle = h; uv_mutex_unlock(&control_lock); @@ -1392,6 +1409,7 @@ ss_local_start(const profile_t *profile, ss_local_callback callback, void *udata server_busy = false; running_handle = NULL; uv_mutex_unlock(&control_lock); + uv_mutex_destroy(&h->join_lock); free_profile(&h->profile); free(h); // heap-ok: library API, startup return ENOMEM; @@ -1421,11 +1439,14 @@ ss_local_wait(ss_local_handle *handle) if (handle == NULL) { return EINVAL; } + uv_mutex_lock(&handle->join_lock); if (!handle->waited) { uv_thread_join(&handle->thread); handle->waited = true; } - return handle->result; + int result = handle->result; + uv_mutex_unlock(&handle->join_lock); + return result; } extern "C" void @@ -1436,12 +1457,13 @@ ss_local_destroy(ss_local_handle *handle) } ss_local_stop(handle); (void)ss_local_wait(handle); + uv_mutex_destroy(&handle->join_lock); free_profile(&handle->profile); free(handle); // heap-ok: library API, startup } int -_start_ss_local_server(profile_t profile, ss_local_callback callback, void *udata) +_start_ss_local_server(profile_t profile, ss_local_callback callback, void *udata, bool embedded) { char *remote_host = profile.remote_host; char *local_addr = profile.local_addr; @@ -1471,9 +1493,18 @@ _start_ss_local_server(profile_t profile, ss_local_callback callback, void *udat USE_LOGFILE(log); + // Nothing carries over from an earlier run in this process. + ret_val = 0; + acl = 0; + udp_fd = -1; + free_acl(); if (profile.acl != NULL) { LOGI("initializing acl..."); - acl = !init_acl(profile.acl); + if (init_acl(profile.acl) != 0) { + LOGE("failed to load the ACL file %s", profile.acl); + return -1; + } + acl = 1; } if (local_addr == NULL) { @@ -1483,22 +1514,43 @@ _start_ss_local_server(profile_t profile, ss_local_callback callback, void *udat #ifndef __MINGW32__ // ignore SIGPIPE signal(SIGPIPE, SIG_IGN); - signal(SIGABRT, SIG_IGN); + if (!embedded) { + signal(SIGABRT, SIG_IGN); + } #endif + // The ss_local_start() API runs in a host process whose signals are its + // own: it stops through ss_local_stop() instead. ss::event::Program program; - if (!program.init() || !program.handle_signals(true) || !program.allow_remote_stop()) - FATAL("failed to set up the event loop"); + Relays relays; + auto abort_start = [&](int listenfd) { + if (listenfd >= 0 && relays.tcp.listeners() == 0) { + ss_socket_close(listenfd); + } + relays.stop(); + program.close(); + free_acl(); + acl = 0; +#ifdef __MINGW32__ + winsock_cleanup(); +#endif + return -1; + }; + if (!program.init() || (!embedded && !program.handle_signals(true)) || !program.allow_remote_stop()) { + LOGE("failed to set up the event loop"); + return abort_start(-1); + } // Setup keys - Relays relays; - if (!relays.init(program.loop(), method, password, NULL)) - FATAL("failed to init ciphers"); + if (!relays.init(program.loop(), method, password, NULL)) { + LOGE("failed to init ciphers"); + return abort_start(-1); + } struct sockaddr_storage storage; memset(&storage, 0, sizeof(struct sockaddr_storage)); if (get_sockaddr(remote_host, remote_port_str, &storage, 1, ipv6first) == -1) { - return -1; + return abort_start(-1); } // Setup proxy context @@ -1517,15 +1569,15 @@ _start_ss_local_server(profile_t profile, ss_local_callback callback, void *udat listenfd = create_and_bind(local_addr, local_port_str); if (listenfd == -1) { ERROR("bind()"); - return -1; + return abort_start(-1); } if (listen(listenfd, SOMAXCONN) == -1) { ERROR("listen()"); - return -1; + return abort_start(listenfd); } if (!relays.start_tcp(tcp_config, listenfd)) { LOGE("failed to set up the TCP relay"); - return -1; + return abort_start(listenfd); } listen_fd = listenfd; } @@ -1535,7 +1587,7 @@ _start_ss_local_server(profile_t profile, ss_local_callback callback, void *udat LOGI("udprelay enabled"); udp_fd = relays.start_udp(local_addr, local_port_str, &storage, mtu, timeout, NULL); if (udp_fd == -1) { - return -1; + return abort_start(-1); } } @@ -1568,6 +1620,8 @@ _start_ss_local_server(profile_t profile, ss_local_callback callback, void *udat // Clean up relays.stop(); program.close(); + free_acl(); + acl = 0; #ifdef __MINGW32__ winsock_cleanup(); @@ -1579,13 +1633,13 @@ _start_ss_local_server(profile_t profile, ss_local_callback callback, void *udat int start_ss_local_server(profile_t profile) { - return _start_ss_local_server(profile, NULL, NULL); + return _start_ss_local_server(profile, NULL, NULL, false); } int start_ss_local_server_with_callback(profile_t profile, ss_local_callback callback, void *udata) { - return _start_ss_local_server(profile, callback, udata); + return _start_ss_local_server(profile, callback, udata, false); } #endif diff --git a/src/manager.cpp b/src/manager.cpp index 0aa67c08..633cd54b 100644 --- a/src/manager.cpp +++ b/src/manager.cpp @@ -67,6 +67,8 @@ #include "event/program.hpp" +#define SERVER_START_TIMEOUT_MS 10000 + #ifndef BUF_SIZE #define BUF_SIZE 65535 #endif @@ -88,6 +90,104 @@ find_server(const char *port) return server_table.find_if([port](struct server &server) { return strcmp(server.port, port) == 0; }); } +// How the control channel is reached, set once at startup. +static bool control_is_unix = false; +static uid_t server_uid = (uid_t)-1; // the -a user managed servers run as + +#ifdef __linux__ +/* + * The uid owning the UDP socket a datagram came from, found in + * /proc/net/udp{,6} like identd would. Only meaningful for loopback + * senders, which is all the control channel accepts. + */ +static bool +udp_sender_uid(const struct sockaddr_storage *from, uid_t *uid) +{ + bool v6 = from->ss_family == AF_INET6; + unsigned want_port = ntohs(v6 ? ((const struct sockaddr_in6 *)from)->sin6_port + : ((const struct sockaddr_in *)from)->sin_port); + FILE *f = fopen(v6 ? "/proc/net/udp6" : "/proc/net/udp", "r"); + if (f == NULL) { + return false; + } + char line[512]; + bool found = false; + if (fgets(line, sizeof(line), f) != NULL) { // header + while (!found && fgets(line, sizeof(line), f) != NULL) { + char local[64]; + unsigned port; + unsigned long owner; + // sl local_address rem_address st tx:rx tr:when retrnsmt uid + if (sscanf(line, " %*s %63[0-9A-Fa-f]:%x %*s %*s %*s %*s %*s %lu", local, &port, &owner) != 3 + || port != want_port) { + continue; + } + // The kernel prints each 32-bit word of the address as a host + // order integer; zeros mean the socket is unbound (wildcard). + uint8_t bytes[16]; + size_t words = v6 ? 4 : 1; + if (strlen(local) != words * 8) { + continue; + } + bool wildcard = true; + for (size_t w = 0; w < words; w++) { + char hex[9]; + memcpy(hex, local + w * 8, 8); + hex[8] = '\0'; + uint32_t value = (uint32_t)strtoul(hex, NULL, 16); + memcpy(bytes + w * 4, &value, 4); + wildcard = wildcard && value == 0; + } + const void *addr = v6 ? (const void *)&((const struct sockaddr_in6 *)from)->sin6_addr + : (const void *)&((const struct sockaddr_in *)from)->sin_addr; + if (wildcard || memcmp(bytes, addr, words * 4) == 0) { + *uid = (uid_t)owner; + found = true; + } + } + } + fclose(f); + return found; +} +#endif + +/* + * Whether a datagram on the control channel may be obeyed. A unix socket + * is guarded by its file mode; a (loopback) UDP sender must, where the + * system can tell, be this user, root or the user servers run as. + */ +static bool +control_sender_allowed(const struct sockaddr_storage *from) +{ + if (control_is_unix) { + return true; + } +#ifdef __linux__ + uid_t uid; + if (!udp_sender_uid(from, &uid)) { + return false; + } + return uid == geteuid() || uid == 0 || (server_uid != (uid_t)-1 && uid == server_uid); +#else + (void)from; + return true; +#endif +} + +static bool +is_loopback_host(const char *host) +{ + struct ss_ip ip; + if (ss_ip_init(&ip, host) != 0) { + return strcmp(host, "localhost") == 0; + } + if (ip.version == 4) { + return ip.bytes[0] == 127; + } + static const uint8_t v6_loopback[16] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; + return memcmp(ip.bytes, v6_loopback, sizeof(v6_loopback)) == 0; +} + static int copy_port(char *dst, size_t dst_len, const char *src, size_t src_len) { @@ -109,6 +209,9 @@ copy_port(char *dst, size_t dst_len, const char *src, size_t src_len) dst[0] = '\0'; return -1; } + // Canonical form ("0080" is "80"): the table, the config file and the + // pid file ss-server writes must all use the same name. + snprintf(dst, dst_len, "%u", (unsigned)port); return 0; } @@ -327,7 +430,23 @@ build_config(char *prefix, struct manager_ctx *manager, struct server *server) path = (char *)ss_malloc(path_size); snprintf(path, path_size, "%s/.shadowsocks_%s.conf", prefix, server->port); - FILE *f = fopen(path, "w+"); + // It holds the password: owner-only, and never written through a link. + int flags = O_WRONLY | O_CREAT | O_TRUNC; +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif + int fd = open(path, flags, 0600); + FILE *f = NULL; + if (fd != -1) { + (void)fchmod(fd, 0600); // an older file may have been more open + f = fdopen(fd, "w"); + if (f == NULL) { + close(fd); + } + } if (f == NULL) { if (verbose) { LOGE("unable to open config file"); @@ -386,6 +505,8 @@ add_server_arg(char **argv, int *argc, int max_argc, char *arg) return 0; } +static void kill_pid_from_file(FILE *f); + static int start_server_process(struct manager_ctx *manager, struct server *server) { @@ -538,8 +659,29 @@ start_server_process(struct manager_ctx *manager, struct server *server) _exit(127); } - while (waitpid(pid, &status, 0) == -1) { - if (errno != EINTR) { + // ss-server -f returns once it is serving (or has failed). Wait for + // that, but not forever: a server stuck starting must not stall the + // control channel. + { + int waited = 0; + pid_t done; + while ((done = waitpid(pid, &status, WNOHANG)) == 0 && waited < SERVER_START_TIMEOUT_MS) { + struct timespec delay = { 0, 10 * 1000 * 1000 }; + nanosleep(&delay, NULL); + waited += 10; + } + if (done == 0) { + LOGE("server on port %d did not start within %d ms", port, SERVER_START_TIMEOUT_MS); + kill(pid, SIGKILL); + while (waitpid(pid, &status, 0) == -1 && errno == EINTR) {} + FILE *f = fopen(pid_path, "r"); // the daemon it may have forked already + if (f != NULL) { + kill_pid_from_file(f); + fclose(f); + } + goto ERROR; + } + if (done == -1) { ERROR("waitpid"); goto ERROR; } @@ -575,7 +717,9 @@ get_data(char *buf, int len) if (pos == len) { return NULL; } - data = buf + pos - 1; + // The JSON object itself (one byte earlier was the ':' that + // get_action() has already cut, or buf[-1] for a bare "{"). + data = buf + pos; return data; } @@ -666,6 +810,13 @@ get_server(char *buf, int len) strncpy(server->no_delay, (value->u.boolean ? "true" : "false"), 8); } } else if (strcmp(name, "plugin") == 0) { + if (!control_is_unix) { + // It names a program to run: only over the unix socket, + // whose file mode says who may send. + LOGE("a per-server plugin is accepted only over a unix socket manager address"); + valid = 0; + break; + } if (value->type == json_string) { server->plugin = strdup(value->u.string.ptr); } @@ -678,7 +829,7 @@ get_server(char *buf, int len) server->mode = strdup(value->u.string.ptr); } } else { - LOGE("invalid data: %s", data); + LOGE("invalid data: unknown field \"%s\"", name); valid = 0; break; } @@ -839,6 +990,7 @@ check_port(struct manager_ctx *manager, struct server *server) udp_ret = create_and_bind(manager->hosts[i], server->port, IPPROTO_UDP); } + // create_and_bind() has already closed the probe socket. if (tcp_udp_ret == -1 || udp_ret == -1) { bind_err = -1; break; @@ -871,6 +1023,64 @@ add_server(struct manager_ctx *manager, struct server *server) return 0; } +/* + * A stale pid file may name a pid that has been reused since: signal only + * a process that is one of our ss-server instances, where the system can + * tell (each one is started with --manager-address). + */ +static bool +is_managed_server(int pid) +{ +#ifdef __linux__ + char path[64]; + snprintf(path, sizeof(path), "/proc/%d/cmdline", pid); + FILE *f = fopen(path, "r"); + if (f == NULL) { + return errno != ENOENT; // gone: nothing to signal; unreadable: assume ours + } + char cmdline[4096]; + size_t n = fread(cmdline, 1, sizeof(cmdline), f); + fclose(f); + for (size_t i = 0; i < n;) { + const char *arg = cmdline + i; + size_t len = strnlen(arg, n - i); + if (len == strlen("--manager-address") && memcmp(arg, "--manager-address", len) == 0) { + return true; + } + i += len + 1; + } + return false; +#else + (void)pid; + return true; +#endif +} + +/* + * SIGTERM, then wait (briefly) for the server to go, so its port is free + * for a server added in its place; SIGKILL if it lingers. + */ +static void +stop_pid(int pid) +{ + if (!is_managed_server(pid)) { + LOGE("pid %d is not a managed ss-server; not signaling it", pid); + return; + } + if (kill(pid, SIGTERM) != 0) { + return; + } + for (int i = 0; i < 200; i++) { + if (kill(pid, 0) != 0 && errno == ESRCH) { + return; + } + struct timespec delay = { 0, 10 * 1000 * 1000 }; + nanosleep(&delay, NULL); + } + LOGE("server %d ignored SIGTERM; killing it", pid); + kill(pid, SIGKILL); +} + static void kill_pid_from_file(FILE *f) { @@ -889,7 +1099,7 @@ kill_pid_from_file(FILE *f) } // Reject malformed pid file content instead of signaling a garbage pid if (ss_parse_int(buf, 1, INT_MAX, &pid) == 0) { - kill(pid, SIGTERM); + stop_pid(pid); } } @@ -931,6 +1141,10 @@ stop_server(char *prefix, char *port) } kill_pid_from_file(f); fclose(f); + remove(path); + // The config holds the password of a server that no longer runs. + snprintf(path, path_size, "%s/.shadowsocks_%s.conf", prefix, port); + remove(path); ss_free(path); } @@ -967,17 +1181,27 @@ manager_recv_cb(void *ctx) noexcept struct manager_ctx *manager = (struct manager_ctx *)ctx; socklen_t len; ssize_t r; - struct sockaddr_un claddr; + // Big enough for a unix, IPv4 or IPv6 sender. + union { + struct sockaddr_un un; + struct sockaddr_storage storage; + } claddr_buf; + memset(&claddr_buf, 0, sizeof(claddr_buf)); + struct sockaddr_un &claddr = claddr_buf.un; char buf[BUF_SIZE]; memset(buf, 0, BUF_SIZE); - len = sizeof(struct sockaddr_un); + len = sizeof(claddr_buf); r = recvfrom(manager->fd, buf, BUF_SIZE, 0, (struct sockaddr *)&claddr, &len); if (r == -1) { ERROR("manager_recvfrom"); return; } + if (!control_sender_allowed((const struct sockaddr_storage *)&claddr)) { + LOGE("ignoring a control datagram from another user"); + return; + } if (r > BUF_SIZE / 2) { LOGE("too large request: %d", (int)r); @@ -996,7 +1220,7 @@ manager_recv_cb(void *ctx) noexcept struct server *server = get_server(buf, r); if (server == NULL || server->port[0] == 0 || server->password[0] == 0) { - LOGE("invalid command: %s:%s", buf, get_data(buf, r)); + LOGE("invalid command: %s", buf); if (server != NULL) { free_server(server); } @@ -1033,17 +1257,25 @@ manager_recv_cb(void *ctx) noexcept size_t entry_pos = 0; size_t pos = strlen(buf); - if (append_list_entry(entry_buf, sizeof(entry_buf), &entry_pos, server, method) == -1) { + // Leaves room for "[" and "\n]\0" around it in buf. + if (append_list_entry(entry_buf, sizeof(entry_buf) - 4, &entry_pos, server, method) == -1) { LOGE("list entry is too large"); continue; } - if (pos >= BUF_SIZE - entry_pos) { - if (sendto(manager->fd, buf, pos, 0, (struct sockaddr *)&claddr, len) - != (ssize_t)pos) { - ERROR("list_sendto"); + // Room for the entry and the closing "\n]" with its NUL; a full + // buffer goes out as a complete array of its own. + if (pos + entry_pos + 3 > BUF_SIZE) { + if (pos > 1) { + strcpy(buf + pos - 1, "\n]"); // replaces the trailing "," + pos = strlen(buf); + if (sendto(manager->fd, buf, pos, 0, (struct sockaddr *)&claddr, len) + != (ssize_t)pos) { + ERROR("list_sendto"); + } } memset(buf, 0, BUF_SIZE); - pos = 0; + strcpy(buf, "["); + pos = 1; } memcpy(buf + pos, entry_buf, entry_pos + 1); } @@ -1059,7 +1291,7 @@ manager_recv_cb(void *ctx) noexcept struct server *server = get_server(buf, r); if (server == NULL || server->port[0] == 0) { - LOGE("invalid command: %s:%s", buf, get_data(buf, r)); + LOGE("invalid command: %s", buf); if (server != NULL) { free_server(server); } @@ -1078,7 +1310,7 @@ manager_recv_cb(void *ctx) noexcept uint64_t traffic = 0; if (parse_traffic(buf, r, port, &traffic) == -1) { - LOGE("invalid command: %s:%s", buf, get_data(buf, r)); + LOGE("invalid command: %s", buf); return; } @@ -1688,12 +1920,23 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. } LOGI("working directory points to %s", working_dir); - int err = mkdir(working_dir, S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH); + // It holds every server's config (with its password) and pid file. + int err = mkdir(working_dir, S_IRWXU); if (err != 0 && errno != EEXIST) { ERROR("mkdir"); ss_free(working_dir); FATAL("unable to create working directory"); } + // An existing directory must be ours alone: in a shared place such as + // /tmp, someone else could have made it to plant links and pid files. + struct stat st; + if (stat(working_dir, &st) != 0 || !S_ISDIR(st.st_mode) + || (st.st_uid != geteuid() && st.st_uid != 0) || (st.st_mode & (S_IWGRP | S_IWOTH)) != 0) { + LOGE("working directory %s must be a directory owned by this user and writable by it alone", + working_dir); + ss_free(working_dir); + FATAL("unsafe working directory"); + } if (manager_address == NULL) { size_t manager_address_size = strlen(workdir) + 20; @@ -1725,6 +1968,18 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. manager.iface = iface; manager.acl = acl; manager.user = user; + if (user != NULL) { + // Servers started with -a report from this uid. + char *end = NULL; + long numeric = strtol(user, &end, 10); + struct passwd *pw = (*user != '\0' && *end == '\0' && numeric >= 0) ? getpwuid((uid_t)numeric) + : getpwnam(user); + if (pw == NULL) { + LOGE("unknown user %s", user); + FATAL("invalid user"); + } + server_uid = pw->pw_uid; + } manager.manager_address = manager_address; manager.hosts = server_host; manager.host_num = server_num; @@ -1812,7 +2067,29 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. ss_free(working_dir); exit(EXIT_FAILURE); } + // The protocol has no authentication of its own: only this user + // (and the -a user the servers report as) may send, whatever the + // umask. + if (chmod(manager_address, S_IRUSR | S_IWUSR) == -1) { + ERROR("chmod"); + ss_free(working_dir); + exit(EXIT_FAILURE); + } + if (server_uid != (uid_t)-1 && geteuid() == 0 && chown(manager_address, server_uid, (gid_t)-1) == -1) { + ERROR("chown"); + ss_free(working_dir); + exit(EXIT_FAILURE); + } + control_is_unix = true; } else { + // Over IP anyone who can reach the port could list passwords and + // start servers: loopback only. + if (ip_addr.host == NULL || !is_loopback_host(ip_addr.host)) { + LOGE("the manager address %s:%s is not a loopback address; use a unix socket or 127.0.0.1", + ip_addr.host != NULL ? ip_addr.host : "*", ip_addr.port); + ss_free(working_dir); + FATAL("unsafe manager address"); + } sfd = create_server_socket(ip_addr.host, ip_addr.port); if (sfd == -1) { ss_free(working_dir); @@ -1824,6 +2101,7 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. if (!program.watch(manager.fd, manager_recv_cb, &manager)) FATAL("failed to watch the control socket"); + daemon_ready(); // start ev loop (void)program.run(); diff --git a/src/netutils.cpp b/src/netutils.cpp index 964a2f34..ac52bd60 100644 --- a/src/netutils.cpp +++ b/src/netutils.cpp @@ -175,6 +175,8 @@ get_sockaddr(char *host, char *port, if (port != NULL) { addr->sin6_port = htons(numeric_port); } + } else { + return -1; } return 0; } else { @@ -221,12 +223,12 @@ get_sockaddr(char *host, char *port, } } - if (rp == NULL) { + int found = rp != NULL; + freeaddrinfo(result); + if (!found) { LOGE("failed to resolve remote addr"); return -1; } - - freeaddrinfo(result); return 0; } @@ -332,7 +334,9 @@ validate_hostname(const char *hostname, const int hostname_len) } } - label += label_len + 1; + if (next_dot == NULL) + break; + label = next_dot + 1; // a trailing dot ends the loop here } return 1; diff --git a/src/plugin.cpp b/src/plugin.cpp index 0080ccbe..9d3017b9 100644 --- a/src/plugin.cpp +++ b/src/plugin.cpp @@ -178,6 +178,14 @@ start_obfsproxy(const char *plugin, return ret; } +static const char *plugin_user = NULL; + +void +set_plugin_user(const char *user) +{ + plugin_user = user; +} + int start_plugin(const char *plugin, const char *plugin_opts, @@ -200,6 +208,10 @@ start_plugin(const char *plugin, stop_plugin(); sub = ss_process_new(plugin); + if (ss_process_user(sub, plugin_user) != 0) { + stop_plugin(); + return -1; + } #ifdef __MINGW32__ sub_control_port = control_port; #endif diff --git a/src/plugin.h b/src/plugin.h index 8bf3e14a..95373644 100644 --- a/src/plugin.h +++ b/src/plugin.h @@ -77,6 +77,9 @@ int start_plugin(const char *plugin, uint16_t control_port, #endif enum plugin_mode mode); +/* The user the plugin runs as once started as root (see --user); NULL + * keeps the starting user. Set before start_plugin(). */ +void set_plugin_user(const char *user); uint16_t get_local_port(); void stop_plugin(); int is_plugin_running(); diff --git a/src/plugin_disabled.cpp b/src/plugin_disabled.cpp index f117ef0f..10e9cc8d 100644 --- a/src/plugin_disabled.cpp +++ b/src/plugin_disabled.cpp @@ -19,5 +19,6 @@ start_plugin(const char *plugin, const char *options, const char *remote_host, return -1; } uint16_t get_local_port(void) { FATAL("Plugin support is disabled in this build"); return 0; } +void set_plugin_user(const char *user) { (void)user; } void stop_plugin(void) {} int is_plugin_running(void) { return 0; } diff --git a/src/process.cpp b/src/process.cpp index 74e15f2c..d8ffa1a0 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -8,14 +8,31 @@ #include #include #else +#include +#include +#include #include #include #include #include #include +#ifdef __linux__ +#include +#include +#include +#endif extern char **environ; #endif +#ifndef _WIN32 +#ifdef __APPLE__ +typedef int ss_group_t; /* getgrouplist() takes int on macOS */ +#else +typedef gid_t ss_group_t; +#endif +enum { SS_MAX_GROUPS = 64 }; +#endif + struct ss_process { char *program; char **argv, **env; @@ -25,6 +42,11 @@ struct ss_process { uint16_t control_port; #else pid_t pid; + bool drop; /* switch to uid/gid/groups in the child */ + uid_t uid; + gid_t gid; + ss_group_t groups[SS_MAX_GROUPS]; + int ngroups; #endif }; @@ -66,6 +88,149 @@ ss_process_env(struct ss_process *process, const char *name, const char *value) } #ifndef _WIN32 +int +ss_process_user(struct ss_process *process, const char *user) +{ + if (user == NULL || geteuid() != 0) { + return 0; /* nothing to drop */ + } + char buf[16 * 1024]; + struct passwd pwdbuf, *pwd = NULL; + char *end = NULL; + long uid = strtol(user, &end, 10); + int err = (*user != '\0' && *end == '\0' && uid >= 0) + ? getpwuid_r((uid_t)uid, &pwdbuf, buf, sizeof(buf), &pwd) + : getpwnam_r(user, &pwdbuf, buf, sizeof(buf), &pwd); + if (err != 0 || pwd == NULL) { + LOGE("plugin: unknown user %s", user); + return -1; + } + process->ngroups = SS_MAX_GROUPS; + if (getgrouplist(pwd->pw_name, (ss_group_t)pwd->pw_gid, process->groups, &process->ngroups) == -1) { + process->groups[0] = (ss_group_t)pwd->pw_gid; /* more than SS_MAX_GROUPS: keep the primary one */ + process->ngroups = 1; + } + process->uid = pwd->pw_uid; + process->gid = pwd->pw_gid; + process->drop = true; + return 0; +} + +/* The executable execve() runs: `program` itself if it names a path, or the + * first match on PATH, as posix_spawnp() would find it. */ +static char * +find_program(const char *program) +{ + if (strchr(program, '/') != NULL) { + return copy_string(program); + } + const char *path = getenv("PATH"); + if (path == NULL || *path == '\0') { + path = "/usr/local/bin:/usr/bin:/bin"; + } + size_t room = strlen(path) + strlen(program) + 2; + char *candidate = (char *)ss_malloc(room); // heap-ok: plugin start, startup + for (const char *dir = path;; ) { + const char *colon = strchr(dir, ':'); + size_t len = colon != NULL ? (size_t)(colon - dir) : strlen(dir); + snprintf(candidate, room, "%.*s%s%s", (int)len, dir, len > 0 ? "/" : "", program); + if (access(candidate, X_OK) == 0) { + return candidate; + } + if (colon == NULL) { + break; + } + dir = colon + 1; + } + free(candidate); // heap-ok: plugin start, startup + return NULL; +} + +/* In the forked child: lose root, but keep the right to bind ports below + * 1024 (on Linux), which a server-side plugin listening on 443 needs. Only + * system calls from here on. */ +static int +drop_in_child(const struct ss_process *process) +{ +#ifdef __linux__ + (void)prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0); +#endif + if (setgroups((size_t)process->ngroups, (const gid_t *)process->groups) != 0 + || setgid(process->gid) != 0 || setuid(process->uid) != 0) { + return -1; + } + if (setuid(0) == 0) { + return -1; /* root must be gone for good */ + } +#ifdef __linux__ + struct __user_cap_header_struct header = { _LINUX_CAPABILITY_VERSION_3, 0 }; + struct __user_cap_data_struct data[2]; + memset(data, 0, sizeof(data)); + data[0].effective = data[0].permitted = data[0].inheritable = 1U << CAP_NET_BIND_SERVICE; + if (syscall(SYS_capset, &header, data) == 0) { +#ifdef PR_CAP_AMBIENT + /* Ambient, so it survives execve() of a non-setcap binary. */ + (void)prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, CAP_NET_BIND_SERVICE, 0, 0); +#endif + } +#endif + return 0; +} + +static int +fork_as_user(struct ss_process *process, char **env) +{ + char *path = find_program(process->program); + if (path == NULL) { + return ENOENT; + } + /* The child reports a failed switch or exec through this pipe. */ + int report[2]; + if (pipe(report) != 0) { + int error = errno; + free(path); // heap-ok: plugin start, startup + return error; + } + (void)fcntl(report[1], F_SETFD, FD_CLOEXEC); + pid_t pid = fork(); + if (pid == 0) { + close(report[0]); + (void)setpgid(0, 0); + sigset_t empty; + sigemptyset(&empty); + (void)sigprocmask(SIG_SETMASK, &empty, NULL); + signal(SIGPIPE, SIG_DFL); + signal(SIGTERM, SIG_DFL); + signal(SIGINT, SIG_DFL); + int error = EPERM; + if (drop_in_child(process) == 0) { + execve(path, process->argv, env); // NOLINT(clang-analyzer-optin.taint.GenericTaint): the PATH lookup posix_spawnp() would do + error = errno; + } + ssize_t ignored = write(report[1], &error, sizeof(error)); + (void)ignored; + _exit(127); + } + int error = pid < 0 ? errno : 0; + close(report[1]); + if (pid > 0) { + int child_error = 0; + ssize_t n; + do { + n = read(report[0], &child_error, sizeof(child_error)); + } while (n < 0 && errno == EINTR); + if (n == (ssize_t)sizeof(child_error)) { + while (waitpid(pid, NULL, 0) < 0 && errno == EINTR) {} + error = child_error; + } else { + process->pid = pid; + } + } + close(report[0]); + free(path); // heap-ok: plugin start, startup + return error; +} + static bool overridden(const struct ss_process *process, const char *entry) { @@ -100,6 +265,16 @@ ss_process_start(struct ss_process *process, uint16_t control_port) env[used++] = process->env[i]; } env[used] = NULL; + if (process->drop) { + int error = fork_as_user(process, env); + free(env); // heap-ok: plugin start, startup + if (error != 0) { + process->pid = 0; + errno = error; + return -1; + } + return 0; + } posix_spawnattr_t attr; int error = posix_spawnattr_init(&attr); if (error == 0) { @@ -159,6 +334,14 @@ stop_process(struct ss_process *process) } } #else +int +ss_process_user(struct ss_process *process, const char *user) +{ + (void)process; + (void)user; + return 0; +} + static wchar_t * wide_string(const char *text) { diff --git a/src/redir.cpp b/src/redir.cpp index 5e4a271d..7237b3a6 100644 --- a/src/redir.cpp +++ b/src/redir.cpp @@ -817,6 +817,7 @@ Same behavior as `-T`; see that option for details. Short alias: `-T`. snprintf(remote_str + len, buf_size - len, "|%s", remote_addr[i].host); len = strlen(remote_str); } + set_plugin_user(user); // it drops root along with us int err = start_plugin(plugin, plugin_opts, remote_str, remote_port, plugin_host, plugin_port, MODE_CLIENT); if (err) { @@ -949,6 +950,7 @@ Same behavior as `-T`; see that option for details. Short alias: `-T`. LOGI("running from root user"); } + daemon_ready(); ss::heap_seal(); { int code = program.run(); diff --git a/src/relay/codec.cpp b/src/relay/codec.cpp index 5914e918..98c8d7d2 100644 --- a/src/relay/codec.cpp +++ b/src/relay/codec.cpp @@ -31,10 +31,11 @@ Status Method::init(const char *method, const char *password, const char *key, R role_ = role; // The salt/IV replay filter shared by every connection and datagram. if (!bloom_ready_) { - if (role == Role::server) { - ppbloom_init(kBloomEntriesServer, kBloomErrorServer); - } else { - ppbloom_init(kBloomEntriesClient, kBloomErrorClient); + int err = role == Role::server ? ppbloom_init(kBloomEntriesServer, kBloomErrorServer) + : ppbloom_init(kBloomEntriesClient, kBloomErrorClient); + if (err != 0) { + LOGE("failed to set up the replay filter"); + return fail(Errc::exhausted); } bloom_ready_ = true; } diff --git a/src/relay/codec.hpp b/src/relay/codec.hpp index 759a04af..5329a6d9 100644 --- a/src/relay/codec.hpp +++ b/src/relay/codec.hpp @@ -30,6 +30,10 @@ public: // or a base64 key (AEAD-2022 needs the key). Also initialises libsodium // and the process-wide ppbloom replay filter, sized for the role. Status init(const char *method, const char *password, const char *key, Role role) noexcept; + Method() = default; + // It owns the process-wide replay filter: a copy would free it twice. + Method(const Method &) = delete; + Method &operator=(const Method &) = delete; ~Method(); Role role() const noexcept { return role_; } diff --git a/src/relay/resolver.cpp b/src/relay/resolver.cpp index aa95f727..c90963a7 100644 --- a/src/relay/resolver.cpp +++ b/src/relay/resolver.cpp @@ -37,12 +37,17 @@ Status Resolver::init(event::Loop &loop, const char *nameservers, bool ipv6_firs if (status != ARES_SUCCESS) { LOGE("c-ares error: %s", ares_strerror(status)); channel_ = nullptr; + ares_library_cleanup(); return fail(Errc::config); } - if (nameservers != nullptr && ares_set_servers_ports_csv(channel_, nameservers) != ARES_SUCCESS) { - LOGE("failed to set nameservers %s", nameservers); + auto abandon = [this] { ares_destroy(channel_); channel_ = nullptr; + ares_library_cleanup(); + }; + if (nameservers != nullptr && ares_set_servers_ports_csv(channel_, nameservers) != ARES_SUCCESS) { + LOGE("failed to set nameservers %s", nameservers); + abandon(); return fail(Errc::config); } loop_ = &loop; @@ -50,7 +55,10 @@ Status Resolver::init(event::Loop &loop, const char *nameservers, bool ipv6_firs for (Watch &w : watches_) { w.owner = this; } - SS_TRY(timer_.open(loop, *this)); + if (Status opened = timer_.open(loop, *this); !opened) { + abandon(); + return opened; + } return {}; } @@ -89,6 +97,9 @@ void Resolver::on_host(void *arg, int status, int, hostent *he) noexcept } else if (verbose && status != ARES_EDESTRUCTION) { LOGI("failed to lookup address: %s", ares_strerror(status)); } + if (status == ARES_EDESTRUCTION || status == ARES_ECANCELLED) { + q->cancelled = true; + } q->owner->finish(q, status); } @@ -99,7 +110,8 @@ void Resolver::finish(Query *q, int status) noexcept } PoolPtr owned = queries_.adopt(q); const sockaddr *best = nullptr; - if (status != ARES_EDESTRUCTION) { + (void)status; + if (!q->cancelled) { if (ipv6_first_) { best = q->has6 ? reinterpret_cast(&q->v6) : (q->has4 ? reinterpret_cast(&q->v4) : nullptr); diff --git a/src/relay/resolver.hpp b/src/relay/resolver.hpp index e3eaddab..06ca01f3 100644 --- a/src/relay/resolver.hpp +++ b/src/relay/resolver.hpp @@ -67,6 +67,7 @@ private: std::uint16_t port = 0; std::uint8_t pending = 2; // A and AAAA bool has4 = false, has6 = false; + bool cancelled = false; // shutdown() ended a lookup: report no address sockaddr_in v4{}; sockaddr_in6 v6{}; }; diff --git a/src/relay/tcp.cpp b/src/relay/tcp.cpp index 06f8a80a..93941d53 100644 --- a/src/relay/tcp.cpp +++ b/src/relay/tcp.cpp @@ -33,6 +33,7 @@ namespace { constexpr std::uint64_t kDelayMs = 50; // wait for a first payload before connecting constexpr int kAcceptBurst = 64; // connections accepted per wakeup +constexpr std::uint64_t kAcceptRetryMs = 1000; // pause after accept() runs out of fds constexpr int kPumpRounds = 16; // receives per direction per wakeup constexpr std::uint8_t kSocksVersion = 5; @@ -144,6 +145,10 @@ Status TcpRelay::init(event::Loop &loop, Method &method, const TcpConfig &config const std::uint64_t timeout = static_cast(std::max(config.timeout_secs, 1)); delay_list_.set_timeout(kDelayMs); + // A handshake (SOCKS request, ss header, or the drain after a rejected + // one) gets the plain --timeout, not the day-long idle minimum, so idle + // sockets cannot hold the fixed pool. + handshake_list_.set_timeout(timeout * 1000); connect_list_.set_timeout(std::min(MAX_CONNECT_TIMEOUT, timeout) * 1000); idle_list_.set_timeout(std::max(MIN_TCP_IDLE_TIMEOUT, timeout) * 1000); SS_TRY(timer_.open(loop, *this)); @@ -183,9 +188,22 @@ void TcpRelay::accept_from(Listener &listener) noexcept if (fd < 0) { #endif int err = ss_socket_error(); - if (err != EAGAIN && err != EWOULDBLOCK && err != EINTR) { - ERROR("accept"); + if (err == EAGAIN || err == EWOULDBLOCK || err == EINTR) { + return; } + if (err == ECONNABORTED) { + continue; + } + // Out of descriptors or memory: the pending connection stays + // queued, so a level-triggered poll would spin. Back off. + std::uint64_t now = loop_->now(); + if (now - last_refusal_log_ >= 1000 || last_refusal_log_ == 0) { + ERROR("accept"); + last_refusal_log_ = now; + } + pause_accept(); + accept_retry_at_ = now + kAcceptRetryMs; + schedule(); return; } if (pool_.full()) { @@ -203,9 +221,7 @@ void TcpRelay::accept_from(Listener &listener) noexcept if (config_.kind == Kind::server && config_.acl) { char peer[INET6_ADDRSTRLEN]; peer_name(fd, peer, sizeof(peer)); - if (peer[0] != '\0' - && ((config_.acl_hooks.mode() == BLACK_LIST && config_.acl_hooks.match_host(peer) == 1) - || (config_.acl_hooks.mode() == WHITE_LIST && config_.acl_hooks.match_host(peer) >= 0))) { + if (peer[0] != '\0' && config_.acl_hooks.peer_denied(peer)) { LOGE("Access denied from %s", peer); ss_socket_close(fd); continue; @@ -268,9 +284,10 @@ void TcpRelay::set_deadline(Connection &conn, Connection::Timed which) noexcept if (conn.timed_ != which) { clear_deadline(conn); } - event::DeadlineList *list = which == Connection::Timed::delay ? &delay_list_ - : which == Connection::Timed::connect ? &connect_list_ - : &idle_list_; + event::DeadlineList *list = which == Connection::Timed::delay ? &delay_list_ + : which == Connection::Timed::handshake ? &handshake_list_ + : which == Connection::Timed::connect ? &connect_list_ + : &idle_list_; std::uint64_t now = loop_->now(); list->touch(conn, now); conn.timed_ = which; @@ -307,15 +324,20 @@ void TcpRelay::on_timer() noexcept }; }; delay_list_.expire(now, fire(Connection::Timed::delay)); + handshake_list_.expire(now, fire(Connection::Timed::handshake)); connect_list_.expire(now, fire(Connection::Timed::connect)); idle_list_.expire(now, fire(Connection::Timed::idle)); + if (accept_retry_at_ != 0 && accept_retry_at_ <= now) { + accept_retry_at_ = 0; + slot_freed(); + } schedule(); } void TcpRelay::schedule() noexcept { - std::uint64_t due = 0; - for (auto *list : { &delay_list_, &connect_list_, &idle_list_ }) { + std::uint64_t due = accept_retry_at_; + for (auto *list : { &delay_list_, &handshake_list_, &connect_list_, &idle_list_ }) { if (auto next = list->next_due()) { due = due == 0 ? *next : std::min(due, *next); } @@ -427,9 +449,9 @@ Flow Connection::start(ss_socket_t fd) noexcept } const TcpConfig &cfg = relay_->config_; // Every connection is timed from the start, so a client that never - // finishes its handshake cannot hold a pool slot forever. Later stages + // finishes its handshake cannot hold a pool slot for long. Later stages // replace this with their own deadline. - relay_->set_deadline(*this, Timed::idle); + relay_->set_deadline(*this, Timed::handshake); switch (cfg.kind) { case Kind::local: if (verbose) { @@ -550,7 +572,10 @@ Flow Connection::on_upstream(int events) noexcept if ((events & event::writable) && pump_to_upstream() == Flow::closed) { return Flow::closed; } - if ((events & (event::readable | event::failed)) && pump_to_client() == Flow::closed) { + // A rejected request (server_reject) leaves only the drain; the closing + // upstream must not be read. + if (stage_ == Stage::stream && (events & (event::readable | event::failed)) + && pump_to_client() == Flow::closed) { return Flow::closed; } update_interest(); @@ -948,18 +973,11 @@ Flow Connection::server_header() noexcept Flow Connection::server_connect(const sockaddr *target) noexcept { TcpConfig &cfg = relay_->config_; - if (cfg.acl) { - char ip[INET6_ADDRSTRLEN] = {}; - const void *raw = target->sa_family == AF_INET - ? static_cast(&reinterpret_cast(target)->sin_addr) - : static_cast(&reinterpret_cast(target)->sin6_addr); - ss_inet_ntop(target->sa_family, raw, ip, sizeof(ip)); - if (cfg.acl_hooks.outbound_blocked(ip) == 1) { - if (verbose) { - LOGI("outbound blocked %s", ip); - } - return close(); + if (cfg.acl && cfg.acl_hooks.target_blocked(target)) { + if (verbose) { + LOGI("outbound blocked %s", target_.host); } + return close(); } auto fd = open_upstream(target->sa_family, cfg.upstream, false); if (!fd) { @@ -982,6 +1000,7 @@ Flow Connection::server_connect(const sockaddr *target) noexcept } (void)to_upstream_.consume(*sent); stage_ = Stage::connecting; + relay_->set_deadline(*this, Timed::connect); update_interest(); return Flow::proceed; } @@ -990,9 +1009,7 @@ Flow Connection::server_connect(const sockaddr *target) noexcept Flow Connection::connected() noexcept { - if (client_kind()) { - relay_->set_deadline(*this, Timed::idle); // replaces the connect deadline - } + relay_->set_deadline(*this, Timed::idle); // replaces the connect deadline stage_ = Stage::stream; if (pump_to_upstream() == Flow::closed) { return Flow::closed; @@ -1072,7 +1089,9 @@ Connection::Pull Connection::pull_into(ss_socket_t fd, bool decode, LeasedBuffer } } if (stage_ == Stage::stream) { - relay_->set_deadline(*this, Timed::idle); + // A half-closed connection gets the plain --timeout, not the + // day-long idle minimum. + relay_->set_deadline(*this, client_eof_ || upstream_eof_ ? Timed::handshake : Timed::idle); } std::span in(relay_->scratch_.data(), *r); if (decode) { @@ -1099,6 +1118,9 @@ Connection::Pull Connection::pull_into(ss_socket_t fd, bool decode, LeasedBuffer Flow Connection::pump_to_upstream() noexcept { + if (client_eof_) { + return Flow::proceed; // nothing more comes from the client + } const bool decode = !client_kind(); for (int round = 0; round < kPumpRounds; ++round) { if (!to_upstream_.empty()) { @@ -1121,6 +1143,9 @@ Flow Connection::pump_to_upstream() noexcept case Pull::again: return Flow::proceed; case Pull::eof: + // Everything read has been flushed: pass the EOF on. + client_eof_ = true; + return half_close(upstream_.fd()); case Pull::error: return close(); } @@ -1130,6 +1155,9 @@ Flow Connection::pump_to_upstream() noexcept Flow Connection::pump_to_client() noexcept { + if (upstream_eof_) { + return Flow::proceed; // nothing more comes from upstream + } const bool decode = client_kind(); for (int round = 0; round < kPumpRounds; ++round) { if (!to_client_.empty()) { @@ -1161,6 +1189,8 @@ Flow Connection::pump_to_client() noexcept case Pull::again: return Flow::proceed; case Pull::eof: + upstream_eof_ = true; + return half_close(client_.fd()); case Pull::error: return close(); } @@ -1168,6 +1198,23 @@ Flow Connection::pump_to_client() noexcept return Flow::proceed; } +Flow Connection::half_close(ss_socket_t peer) noexcept +{ + if (client_eof_ && upstream_eof_) { + return close(); + } +#ifdef _WIN32 + int how = SD_SEND; +#else + int how = SHUT_WR; +#endif + if (shutdown(peer, how) != 0) { + return close(); + } + relay_->set_deadline(*this, Timed::handshake); + return Flow::proceed; +} + void Connection::update_interest() noexcept { int c = 0, u = 0; @@ -1187,8 +1234,8 @@ void Connection::update_interest() noexcept u = event::writable; break; case Stage::stream: - c = (to_upstream_.empty() ? event::readable : 0) | (to_client_.empty() ? 0 : event::writable); - u = (to_client_.empty() ? event::readable : 0) | (to_upstream_.empty() ? 0 : event::writable); + c = (to_upstream_.empty() && !client_eof_ ? event::readable : 0) | (to_client_.empty() ? 0 : event::writable); + u = (to_client_.empty() && !upstream_eof_ ? event::readable : 0) | (to_upstream_.empty() ? 0 : event::writable); break; } if (client_.is_open() && !client_.is_closing()) { diff --git a/src/relay/tcp.hpp b/src/relay/tcp.hpp index 110d7fcf..7f03355f 100644 --- a/src/relay/tcp.hpp +++ b/src/relay/tcp.hpp @@ -44,6 +44,25 @@ struct AclHooks { int (*match_host)(const char *host) = nullptr; // > 0 listed as bypass/block, < 0 as proxy, 0 neither int (*mode)() = nullptr; // BLACK_LIST or WHITE_LIST int (*outbound_blocked)(const char *host) = nullptr; + + // ss-server: is a client at `peer` (an address) refused? + bool peer_denied(const char *peer) const noexcept + { + constexpr int kWhiteList = 1; // acl.h WHITE_LIST + int listed = match_host(peer); + return mode() == kWhiteList ? listed >= 0 : listed == 1; + } + + // ss-server: may a client reach `target`? + bool target_blocked(const sockaddr *target) const noexcept + { + char ip[INET6_ADDRSTRLEN] = {}; + const void *raw = target->sa_family == AF_INET + ? static_cast(&reinterpret_cast(target)->sin_addr) + : static_cast(&reinterpret_cast(target)->sin6_addr); + ss_inet_ntop(target->sa_family, raw, ip, sizeof(ip)); + return outbound_blocked(ip) == 1; + } }; struct TcpConfig { @@ -166,7 +185,7 @@ private: stream, stopped, // server: failed the handshake; drain until timeout }; - enum class Timed : std::uint8_t { none, delay, connect, idle }; + enum class Timed : std::uint8_t { none, delay, handshake, connect, idle }; event::Flow start(ss_socket_t fd) noexcept; event::Flow close() noexcept; @@ -185,6 +204,7 @@ private: event::Flow connected() noexcept; event::Flow pump_to_upstream() noexcept; event::Flow pump_to_client() noexcept; + event::Flow half_close(ss_socket_t peer) noexcept; void update_interest() noexcept; enum class Pull : std::uint8_t { data, nothing, again, eof, error }; @@ -217,6 +237,10 @@ private: bool dns_done_ = false; bool dns_ok_ = false; bool replied_ = false; // first upstream bytes reached the client + // Half-close: one side has sent EOF, which went on to the other side as + // a shutdown(SHUT_WR); the other direction keeps flowing until its EOF. + bool client_eof_ = false; + bool upstream_eof_ = false; void *hook_state_ = nullptr; int tos_ = -1; // from the listener that accepted it sockaddr_storage dns_answer_{}; @@ -238,6 +262,8 @@ public: // Close every connection and listener. Run the loop until closed(). void shutdown() noexcept; bool closed() const noexcept; + // Listening sockets the relay owns (and closes on shutdown). + std::size_t listeners() const noexcept { return listener_count_; } std::uint32_t live() const noexcept { return pool_.live(); } std::uint64_t refused() const noexcept { return refused_; } @@ -282,8 +308,10 @@ private: bool accept_paused_ = false; std::uint64_t refused_ = 0; std::uint64_t last_refusal_log_ = 0; + std::uint64_t accept_retry_at_ = 0; // accept() failed hard (e.g. EMFILE): retry then event::DeadlineList delay_list_; + event::DeadlineList handshake_list_; event::DeadlineList connect_list_; event::DeadlineList idle_list_; event::Timer timer_; diff --git a/src/relay/udp.cpp b/src/relay/udp.cpp index b6a6b602..e87aa23d 100644 --- a/src/relay/udp.cpp +++ b/src/relay/udp.cpp @@ -298,8 +298,11 @@ Status UdpRelay::init(event::Loop &loop, Method &method, const UdpConfig &config if (config.kind == Kind::server) { if (method.psk() != nullptr) { // An entry evicted from shared_ stays alive while a relay - // session still references it (at most one per session). - SS_TRY(shared_pool_.reserve(config.max_sessions_2022 + config.max_sessions)); + // session still references it (one per session, closing ones + // included); pending DNS queries hold one too, and a new session + // holds a slot while its first packet is checked. + SS_TRY(shared_pool_.reserve(config.max_sessions_2022 + 2 * config.max_sessions + + config.max_pending_dns + 1)); SS_TRY(shared_.reserve(config.max_sessions_2022, seed ^ 0x9E3779B97F4A7C15ULL)); } SS_TRY(pending_.reserve(config.max_pending_dns)); @@ -461,9 +464,6 @@ UdpSession *UdpRelay::session_for(std::uint8_t listener, const sockaddr_storage if (verbose) { LOGI("[%s] [udp] cache miss", config_.label); } - if (sessions_.full()) { - (void)sessions_.pop_oldest(); // closes the least recently used session - } auto fd = open_upstream_socket(family); if (!fd) { return nullptr; @@ -482,6 +482,11 @@ UdpSession *UdpRelay::session_for(std::uint8_t listener, const sockaddr_storage s.key_ = raw; s.key_len_ = static_cast(raw.size()); (void)s.socket_.want(event::readable); + // Evict only once the replacement exists, so a failure above loses no + // live mapping. + if (sessions_.full()) { + (void)sessions_.pop_oldest(); // closes the least recently used session + } auto inserted = sessions_.emplace(*key, std::move(*session)); if (!inserted) { return nullptr; // the PoolPtr went out of scope: closed again @@ -568,17 +573,38 @@ void UdpRelay::client_packet(Listener &listener, const sockaddr_storage &from, s } } -RefPtr UdpRelay::shared_for(const std::array &id) noexcept +RefPtr UdpRelay::shared_find(const std::array &id) noexcept { auto key = FixedKey::from(std::as_bytes(std::span(id))); if (!key) { return nullptr; } - std::uint64_t now = now_secs(); if (RefPtr *found = shared_.find(*key)) { - (*found)->last_used = now; + (*found)->last_used = now_secs(); return *found; } + return nullptr; +} + +RefPtr UdpRelay::shared_new() noexcept +{ + auto slot = shared_pool_.acquire(*this); + if (!slot) { + return nullptr; + } + Shared2022 *raw = slot->release(); // the references own it from here + raw->last_used = now_secs(); + return RefPtr(raw); +} + +bool UdpRelay::shared_insert(const std::array &id, + const RefPtr &ref) noexcept +{ + auto key = FixedKey::from(std::as_bytes(std::span(id))); + if (!key) { + return false; + } + std::uint64_t now = now_secs(); // Forget client sessions idle for longer than the TTL (oldest first). while (RefPtr *oldest = shared_.oldest()) { if ((*oldest)->last_used + kTtl2022 > now) { @@ -589,17 +615,7 @@ RefPtr UdpRelay::shared_for(const std::arrayrelease(); // the references own it from here - raw->last_used = now; - RefPtr ref(raw); - if (!shared_.emplace(*key, ref)) { - return nullptr; - } - return ref; + return shared_.emplace(*key, ref).has_value(); } void UdpRelay::server_packet(Listener &listener, const sockaddr_storage &from, std::span packet) noexcept @@ -607,6 +623,19 @@ void UdpRelay::server_packet(Listener &listener, const sockaddr_storage &from, s if (config_.tx != nullptr) { *config_.tx += packet.size(); } + if (config_.acl) { + char peer[INET6_ADDRSTRLEN] = {}; + const void *raw = from.ss_family == AF_INET + ? static_cast(&reinterpret_cast(&from)->sin_addr) + : static_cast(&reinterpret_cast(&from)->sin6_addr); + ss_inet_ntop(from.ss_family, raw, peer, sizeof(peer)); + if (peer[0] != '\0' && config_.acl_hooks.peer_denied(peer)) { + if (verbose) { + LOGI("[udp] access denied from %s", peer); + } + return; + } + } Result opened = fail(Errc::config); RefPtr shared; if (const crypto::MasterKey *master = method_->aead()) { @@ -614,10 +643,20 @@ void UdpRelay::server_packet(Listener &listener, const sockaddr_storage &from, s } else if (const crypto::Psk *psk = method_->psk()) { auto pkt = crypto::udp2022_decrypt(*psk, packet, out_); if (pkt) { - shared = shared_for(pkt->session_id); + // A new client session enters the table only once its first + // packet passes the timestamp and replay checks, so replayed + // stale packets cannot evict live sessions. + shared = shared_find(pkt->session_id); + bool fresh = !shared; + if (fresh) { + shared = shared_new(); + } if (shared) { opened = crypto::udp2022_accept(Role::server, shared->state, *pkt, out_, static_cast(std::time(nullptr))); + if (opened && fresh && !shared_insert(pkt->session_id, shared)) { + opened = fail(Errc::exhausted); + } } } } @@ -655,6 +694,12 @@ void UdpRelay::server_packet(Listener &listener, const sockaddr_storage &from, s LOGE("[udp] invalid host name"); return; } + if (config_.acl && config_.acl_hooks.outbound_blocked(target.host) == 1) { + if (verbose) { + LOGI("[udp] outbound blocked %s", target.host); + } + return; + } // Hold the payload until the name resolves. auto pending = pending_.acquire(); if (!pending || resolver_ == nullptr) { @@ -699,6 +744,12 @@ void UdpRelay::resolved(const ResolveCallback &cb, const sockaddr *address) noex void UdpRelay::server_forward(std::uint8_t listener, const sockaddr_storage &client, const sockaddr *target, RefPtr shared, std::span payload) noexcept { + if (config_.acl && config_.acl_hooks.target_blocked(target)) { + if (verbose) { + LOGI("[udp] outbound blocked"); + } + return; + } UdpSession *session = session_for(listener, client, target->sa_family); if (session == nullptr) { return; diff --git a/src/relay/udp.hpp b/src/relay/udp.hpp index 2baf9e53..1d308d48 100644 --- a/src/relay/udp.hpp +++ b/src/relay/udp.hpp @@ -58,6 +58,8 @@ struct UdpConfig { std::uint64_t *tx = nullptr; std::uint64_t *rx = nullptr; void (*on_traffic)() = nullptr; + bool acl = false; // server: with `acl_hooks` set, as for TCP + AclHooks acl_hooks; }; // Bind the UDP socket clients send to (ss-redir: transparent, with the @@ -149,7 +151,10 @@ private: RefPtr shared, std::span payload) noexcept; void reply(UdpSession &session) noexcept; UdpSession *session_for(std::uint8_t listener, const sockaddr_storage &client, int family) noexcept; - RefPtr shared_for(const std::array &id) noexcept; + RefPtr shared_find(const std::array &id) noexcept; + RefPtr shared_new() noexcept; + bool shared_insert(const std::array &id, + const RefPtr &ref) noexcept; void expire(UdpSession &session) noexcept; Result open_upstream_socket(int family) noexcept; static void resolved(const ResolveCallback &cb, const sockaddr *address) noexcept; diff --git a/src/rule.cpp b/src/rule.cpp index 551cbe07..719d9eda 100644 --- a/src/rule.cpp +++ b/src/rule.cpp @@ -72,6 +72,10 @@ add_rule(rule_list_t *rules, rule_t *rule) rules->push_back(*rule); } +#if SS_ENABLE_REGEX +static pcre2_match_context *match_context = NULL; +#endif + int init_rule(rule_t *rule) { @@ -86,11 +90,22 @@ init_rule(rule_t *rule) return *rule->literal != '\0' ? 1 : 0; } #if SS_ENABLE_REGEX + // Bounds the backtracking a rule can do on a client-chosen host name; + // made here, at startup, not on a first match. + if (match_context == NULL) { + match_context = pcre2_match_context_create(NULL); + if (match_context == NULL) { + return -1; + } + pcre2_set_match_limit(match_context, 100000); + pcre2_set_depth_limit(match_context, 1000); + } if (rule->pattern_re == NULL) { int errcode; PCRE2_SIZE erroffset; + // Host names are case-insensitive. rule->pattern_re = pcre2_compile((PCRE2_SPTR)rule->pattern, - PCRE2_ZERO_TERMINATED, 0, &errcode, &erroffset, NULL); + PCRE2_ZERO_TERMINATED, PCRE2_CASELESS, &errcode, &erroffset, NULL); if (rule->pattern_re == NULL) { PCRE2_UCHAR errbuf[256]; pcre2_get_error_message(errcode, errbuf, sizeof(errbuf)); @@ -135,7 +150,7 @@ rule_matches(rule_t *rule, const char *name, size_t name_len) } #if SS_ENABLE_REGEX return pcre2_match(rule->pattern_re, (PCRE2_SPTR)name, - name_len, 0, 0, rule->match_data, NULL) >= 0; + name_len, 0, 0, rule->match_data, match_context) >= 0; #else return false; #endif @@ -148,6 +163,10 @@ lookup_rule(rule_list_t *rules, const char *name, size_t name_len) name = ""; name_len = 0; } + // "example.com." is the same name as "example.com". + while (name_len > 0 && name[name_len - 1] == '.') { + name_len--; + } return rules->find_if([&](rule_t &rule) { return rule_matches(&rule, name, name_len); }); } diff --git a/src/server.cpp b/src/server.cpp index 07d385ff..d8befcf3 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -164,14 +164,22 @@ static ss::relay::Resolver *stat_resolver = NULL; static void stat_resolved(const ss::relay::ResolveCallback &cb, const sockaddr *address) noexcept { char *message = (char *)cb.ctx; + // One socket for the process's life: ss-manager tells who sent a + // datagram by the owner of its source socket, which must still exist + // when the manager looks. + static int stat_fd[2] = { -1, -1 }; // AF_INET, AF_INET6 if (address) { - int fd = ss_socket(address->sa_family, SOCK_DGRAM, 0); + int &fd = stat_fd[address->sa_family == AF_INET6 ? 1 : 0]; + if (fd == -1) { + fd = ss_socket(address->sa_family, SOCK_DGRAM, 0); + if (fd != -1) { + setnonblocking(fd); + } + } if (fd != -1) { - setnonblocking(fd); size_t length = strlen(message) + 1; if (sendto(fd, message, length, 0, address, get_sockaddr_len((struct sockaddr *)address)) != (ssize_t)length) ERROR("stat_sendto"); - ss_socket_close(fd); } } free(message); // heap-ok: one stats datagram every few seconds @@ -180,7 +188,7 @@ static void stat_resolved(const ss::relay::ResolveCallback &cb, const sockaddr * static void stat_update_cb(void *) noexcept { - struct sockaddr_un svaddr, claddr; + struct sockaddr_un svaddr; int sfd = -1; size_t msgLen; char resp[SOCKET_BUF_SIZE]; @@ -204,24 +212,13 @@ stat_update_cb(void *) noexcept return; } - memset(&claddr, 0, sizeof(struct sockaddr_un)); - claddr.sun_family = AF_UNIX; - snprintf(claddr.sun_path, sizeof(claddr.sun_path), "/tmp/shadowsocks.%s", remote_port); - - unlink(claddr.sun_path); - - if (bind(sfd, (struct sockaddr *)&claddr, sizeof(struct sockaddr_un)) == -1) { - ERROR("stat_bind"); - ss_socket_close(sfd); - return; - } - + // The manager never answers a stat datagram, so the socket stays + // unnamed: no fixed path in /tmp for anyone to race. memset(&svaddr, 0, sizeof(struct sockaddr_un)); svaddr.sun_family = AF_UNIX; if (strlen(manager_addr) >= sizeof(svaddr.sun_path)) { LOGE("manager unix socket path is too long"); ss_socket_close(sfd); - unlink(claddr.sun_path); return; } strncpy(svaddr.sun_path, manager_addr, sizeof(svaddr.sun_path) - 1); @@ -229,11 +226,8 @@ stat_update_cb(void *) noexcept if (sendto(sfd, resp, strlen(resp) + 1, 0, (struct sockaddr *)&svaddr, sizeof(struct sockaddr_un)) != (ssize_t)msgLen) { ERROR("stat_sendto"); - ss_socket_close(sfd); - return; } - - unlink(claddr.sun_path); + ss_socket_close(sfd); } else { uint16_t port; if (parse_numeric_port(ip_addr.port, &port) == 0) { @@ -1076,7 +1070,11 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. break; case GETOPT_VAL_ACL: LOGI("initializing acl..."); - acl = !init_acl(optarg); + if (init_acl(optarg) != 0) { + LOGE("failed to load the ACL file %s", optarg); + FATAL("invalid ACL"); + } + acl = 1; break; case GETOPT_VAL_MANAGER_ADDRESS: manager_addr = optarg; @@ -1317,7 +1315,11 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. } if (acl == 0 && conf->acl != NULL) { LOGI("initializing acl..."); - acl = !init_acl(conf->acl); + if (init_acl(conf->acl) != 0) { + LOGE("failed to load the ACL file %s", conf->acl); + FATAL("invalid ACL"); + } + acl = 1; } } @@ -1523,6 +1525,7 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. len = strlen(server_str); } + set_plugin_user(user); // it drops root along with us int err = start_plugin(plugin, plugin_opts, server_str, plugin_port, plugin_host, server_port, #ifdef __MINGW32__ @@ -1634,6 +1637,10 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. udp_config.label = remote_port != NULL ? remote_port : ""; udp_config.tx = &tx; udp_config.rx = ℞ + udp_config.acl = acl != 0; + udp_config.acl_hooks.match_host = &acl_match_host; + udp_config.acl_hooks.mode = &get_acl_mode; + udp_config.acl_hooks.outbound_blocked = &outbound_block_match_host; if (is_bind_local_addr) { udp_config.bind_v4 = &local_addr_v4; udp_config.bind_v6 = &local_addr_v6; @@ -1692,6 +1699,7 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. } #endif + daemon_ready(); // start ev loop ss::heap_seal(); { diff --git a/src/shadowsocks/control.h b/src/shadowsocks/control.h index 4ba3f41e..a9841291 100644 --- a/src/shadowsocks/control.h +++ b/src/shadowsocks/control.h @@ -31,10 +31,12 @@ int ss_local_start(const profile_t *profile, ss_local_callback callback, void *u void ss_local_stop(ss_local_handle *handle); /* Wait for the server thread and return what the server returned (0 after - * a requested stop). Call it once. */ + * a requested stop). Later or concurrent calls return the same value. */ int ss_local_wait(ss_local_handle *handle); -/* Release the handle, stopping and waiting for the server first if needed. */ +/* Release the handle, stopping and waiting for the server first if needed. + * The handle is freed: no other thread may still be calling stop or wait + * on it, or call them afterwards. */ void ss_local_destroy(ss_local_handle *handle); #ifdef __cplusplus diff --git a/src/ss_process.h b/src/ss_process.h index 619060a3..8da087d9 100644 --- a/src/ss_process.h +++ b/src/ss_process.h @@ -11,6 +11,9 @@ struct ss_process; struct ss_process *ss_process_new(const char *program); void ss_process_arg(struct ss_process *process, const char *argument); void ss_process_env(struct ss_process *process, const char *name, const char *value); +/* Run the child as `user` (a name or a numeric uid) when started as root. + * Returns -1 if the user is unknown. A no-op on Windows. */ +int ss_process_user(struct ss_process *process, const char *user); int ss_process_start(struct ss_process *process, uint16_t control_port); bool ss_process_running(struct ss_process *process); void ss_process_free(struct ss_process *process); diff --git a/src/ssurl.cpp b/src/ssurl.cpp index 1b03944e..c23f9f14 100644 --- a/src/ssurl.cpp +++ b/src/ssurl.cpp @@ -195,6 +195,9 @@ parse_query(const char *s, size_t len, ss_url_t *url) if (name_len == 6 && strncmp(s, "plugin", 6) == 0) { char *value = percent_decode(eq + 1, (size_t)(pair_end - eq - 1)); char *semi = strchr(value, ';'); + // A repeated plugin= replaces the earlier one, options too. + ss_free(url->plugin); + ss_free(url->plugin_opts); if (semi != NULL) { *semi = '\0'; url->plugin_opts = str_ndup(semi + 1, strlen(semi + 1)); @@ -248,6 +251,13 @@ ss_url_parse(const char *uri, ss_url_t *url) const char *hash = (const char *)memchr(body, '#', body_len); if (hash != NULL) { url->tag = percent_decode(hash + 1, body_len - (size_t)(hash - body) - 1); + /* It is only logged: no control characters (newlines, escapes) + * from a link may reach the log. */ + for (char *c = url->tag; *c != '\0'; c++) { + if ((unsigned char)*c < 0x20 || *c == 0x7f) { + *c = '?'; + } + } body_len = (size_t)(hash - body); } @@ -315,6 +325,13 @@ ss_url_parse(const char *uri, ss_url_t *url) size_t query_ofst = (size_t)(query - body) + 1; parse_query(body + query_ofst, body_len - query_ofst, url); } + /* A link is often shared: let it name a plugin, but not a path to an + * arbitrary program, and no empty name. The plugin is looked up on PATH. */ + if (url->plugin != NULL && (url->plugin[0] == '\0' || strchr(url->plugin, '/') != NULL + || strchr(url->plugin, '\\') != NULL)) { + LOGE("the URL's plugin must be a plain program name, not a path"); + goto fail; + } return 0; diff --git a/src/tunnel.cpp b/src/tunnel.cpp index 74949da7..0251fb93 100644 --- a/src/tunnel.cpp +++ b/src/tunnel.cpp @@ -906,6 +906,7 @@ Same behavior as `-V`; see that option for details. Short alias: `-V`. snprintf(remote_str + len, buf_size - len, "|%s", remote_addr[i].host); len = strlen(remote_str); } + set_plugin_user(user); // it drops root along with us int err = start_plugin(plugin, plugin_opts, remote_str, remote_port, plugin_host, plugin_port, #ifdef __MINGW32__ @@ -1044,6 +1045,7 @@ Same behavior as `-V`; see that option for details. Short alias: `-V`. } #endif + daemon_ready(); ss::heap_seal(); { int code = program.run(); diff --git a/src/utils.cpp b/src/utils.cpp index 3f0c0390..cb45175f 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -145,6 +145,11 @@ int run_as(const char *user) { #ifndef __MINGW32__ + if (user[0] == '\0') { + /* An empty --user (say, an unset variable) must not keep root. */ + LOGE("run_as user is empty"); + return 0; + } if (user[0]) { /* Convert user to a long integer if it is a non-negative number. * -1 means it is a user name. */ @@ -174,6 +179,13 @@ run_as(const char *user) err = uid >= 0 ? getpwuid_r((uid_t)uid, &pwdbuf, buf, buflen, &pwd) : getpwnam_r(user, &pwdbuf, buf, buflen, &pwd); + if (err == 0 && pwd && getuid() == pwd->pw_uid && geteuid() == pwd->pw_uid + && getgid() == pwd->pw_gid) { + /* Already that user (say, started by an ss-manager that has + * switched to it): nothing to drop, and initgroups() would + * need root. */ + break; + } if (err == 0 && pwd) { /* setgid first, because we may not be allowed to do it anymore after setuid */ if (setgid(pwd->pw_gid) != 0) { @@ -779,6 +791,25 @@ usage(void) puts("Manual: https://shadowsocks.github.io/shadowsocks-c/"); } +#ifndef __MINGW32__ +static int daemon_ready_fd = -1; +#endif + +void +daemon_ready(void) +{ +#ifndef __MINGW32__ + if (daemon_ready_fd == -1) { + return; + } + char byte = 1; + ssize_t ignored = write(daemon_ready_fd, &byte, 1); + (void)ignored; + close(daemon_ready_fd); + daemon_ready_fd = -1; +#endif +} + void daemonize(const char *path) { @@ -786,6 +817,29 @@ daemonize(const char *path) /* Our process ID and Session ID */ pid_t pid, sid; + /* Open the pid file before forking, so a bad path fails the start + * instead of leaving an untracked daemon, and never through a symlink + * someone planted in a shared directory. */ + int flags = O_WRONLY | O_CREAT | O_TRUNC; +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif + int pid_fd = open(path, flags, 0644); + if (pid_fd == -1) { + LOGE("cannot open pid file %s: %s", path, strerror(errno)); + FATAL("Invalid pid file"); + } + + /* The daemon says over this pipe when it is serving (daemon_ready()); + * the starting process exits with success only then. */ + int ready[2]; + if (pipe(ready) != 0) { + FATAL("pipe"); + } + /* Fork off the parent process */ pid = fork(); if (pid < 0) { @@ -795,18 +849,29 @@ daemonize(const char *path) /* If we got a good PID, then * we can exit the parent process. */ if (pid > 0) { - FILE *file = fopen(path, "w"); - if (file == NULL) { - FATAL("Invalid pid file\n"); + close(ready[1]); + char text[32]; + int len = snprintf(text, sizeof(text), "%d", (int)pid); + if (write(pid_fd, text, (size_t)len) != len) { + ERROR("write pid file"); } - - fprintf(file, "%d", (int)pid); - fclose(file); - exit(EXIT_SUCCESS); + close(pid_fd); + char byte = 0; + ssize_t n; + do { + n = read(ready[0], &byte, 1); + } while (n < 0 && errno == EINTR); + /* EOF: the daemon exited (or exec'd) before it was ready. */ + exit(n == 1 ? EXIT_SUCCESS : EXIT_FAILURE); } + close(pid_fd); + close(ready[0]); + (void)fcntl(ready[1], F_SETFD, FD_CLOEXEC); /* plugins must not hold it open */ + daemon_ready_fd = ready[1]; - /* Change the file mode mask */ - umask(0); + /* Files the daemon creates (configs with passwords, control sockets) + * must not be world-writable. */ + umask(022); /* Open any logs here */ diff --git a/src/utils.h b/src/utils.h index 00aaa2b4..62ce4474 100644 --- a/src/utils.h +++ b/src/utils.h @@ -226,6 +226,10 @@ void usage(void); void cli_version(void); void cli_error(const char *message, int option, const char *token) __attribute__((noreturn)); void daemonize(const char *path); +/* After daemonize(): the daemon is serving, so the starting process may + * exit successfully (it fails if the daemon exits first). A no-op without + * daemonize(). */ +void daemon_ready(void); char *ss_strndup(const char *s, size_t n); #ifdef HAVE_SETRLIMIT int set_nofile(int nofile); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ae604c09..e08b5675 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -398,6 +398,14 @@ if(SS_BUILD_EXECUTABLES AND NOT CMAKE_CROSSCOMPILING) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_limits.py --bin ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) set_tests_properties(test_limits PROPERTIES LABELS "integration" TIMEOUT 60) + add_test(NAME test_server_acl + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_server_acl.py + --bin ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) + set_tests_properties(test_server_acl PROPERTIES LABELS "integration" TIMEOUT 90) + add_test(NAME test_half_close + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_half_close.py + --bin ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) + set_tests_properties(test_half_close PROPERTIES LABELS "integration" TIMEOUT 90) add_test(NAME test_tunnel_interop COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tunnel_interop.py ${SS_TUNNEL_INTEROP_ARGS}) set_tests_properties(test_tunnel_interop PROPERTIES LABELS "integration" TIMEOUT 300) diff --git a/tests/test_acl_cpp.cpp b/tests/test_acl_cpp.cpp index 0058d1d6..ed7ef783 100644 --- a/tests/test_acl_cpp.cpp +++ b/tests/test_acl_cpp.cpp @@ -1,8 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// ACL file parsing (src/acl.cpp): invalid rules are discarded, valid ones -// still match. +// ACL file parsing (src/acl.cpp): an invalid entry fails the whole load, +// valid ones match, and IPv4-mapped addresses meet the IPv4 rules. #include #include +#include #include "acl.h" #include "test_helpers.h" @@ -14,27 +15,73 @@ int verbose = 0; namespace { -void test_invalid_regex_is_not_installed() +// Writes `body` to a temporary ACL file and loads it. +int load_acl(const char *body) { char path[4096]; FILE *f = test_tempfile(path, sizeof(path)); SS_CHECK(f != nullptr); - std::fprintf(f, "[black_list]\n"); - std::fprintf(f, "[invalid\n"); - std::fprintf(f, "^blocked\\.example$\n"); - std::fprintf(f, "[outbound_block_list]\n"); - std::fprintf(f, "[also-invalid\n"); - std::fprintf(f, "^outbound\\.example$\n"); + std::fputs(body, f); std::fclose(f); + int rc = init_acl(path); + std::remove(path); + return rc; +} - SS_CHECK(init_acl(path) == 0); +void test_invalid_regex_fails_the_load() +{ + // An unusable rule must not silently drop out of a block list: the + // whole file is refused and nothing stays installed. + SS_CHECK(load_acl("[black_list]\n[invalid\n^blocked\\.example$\n") == -1); + SS_CHECK(acl_match_host("blocked.example") == 0); + SS_CHECK(load_acl("[outbound_block_list]\n^outbound\\.example$\n[also-invalid\n") == -1); + SS_CHECK(outbound_block_match_host("outbound.example") == 0); + SS_CHECK(get_acl_mode() == BLACK_LIST); + + SS_CHECK(load_acl("[black_list]\n^blocked\\.example$\n[outbound_block_list]\n^outbound\\.example$\n") == 0); SS_CHECK(acl_match_host("does-not-crash.example") == 0); SS_CHECK(acl_match_host("blocked.example") == 1); SS_CHECK(outbound_block_match_host("does-not-crash.example") == 0); SS_CHECK(outbound_block_match_host("outbound.example") == 1); free_acl(); +} - std::remove(path); +void test_reload_starts_from_nothing() +{ + SS_CHECK(load_acl("[reject_all]\n[white_list]\n1.1.1.1\n") == 0); + SS_CHECK(get_acl_mode() == WHITE_LIST); + SS_CHECK(load_acl("[black_list]\n2.2.2.2\n") == 0); + SS_CHECK(get_acl_mode() == BLACK_LIST); + SS_CHECK(acl_match_host("1.1.1.1") == 0); + SS_CHECK(acl_match_host("2.2.2.2") == 1); + free_acl(); +} + +void test_rules_ignore_case_and_trailing_dot() +{ + SS_CHECK(load_acl("[outbound_block_list]\n(^|\\.)localhost$\nfull:internal.example\n") == 0); + SS_CHECK(outbound_block_match_host("LOCALHOST") == 1); + SS_CHECK(outbound_block_match_host("localhost.") == 1); + SS_CHECK(outbound_block_match_host("Internal.Example.") == 1); + SS_CHECK(outbound_block_match_host("example") == 0); + free_acl(); +} + +void test_ipv4_mapped_addresses_match_ipv4_rules() +{ + SS_CHECK(load_acl("[outbound_block_list]\n127.0.0.0/8\n169.254.169.254\n::ffff:10.0.0.0/104\n") == 0); + SS_CHECK(outbound_block_match_host("127.0.0.1") == 1); + SS_CHECK(outbound_block_match_host("::ffff:127.0.0.1") == 1); + SS_CHECK(outbound_block_match_host("::FFFF:169.254.169.254") == 1); + SS_CHECK(outbound_block_match_host("10.1.2.3") == 1); // from the mapped /104 + SS_CHECK(outbound_block_match_host("::ffff:10.1.2.3") == 1); + SS_CHECK(outbound_block_match_host("::ffff:8.8.8.8") == 0); + SS_CHECK(outbound_block_match_host("::1") == 0); + free_acl(); + + SS_CHECK(load_acl("[black_list]\n203.0.113.0/24\n") == 0); + SS_CHECK(acl_match_host("::ffff:203.0.113.7") == 1); // a v4 client on a dual-stack socket + free_acl(); } void test_long_acl_line_is_discarded() @@ -57,36 +104,29 @@ void test_long_acl_line_is_discarded() std::remove(path); } -void test_invalid_cidr_is_discarded() +void test_invalid_cidr_fails_the_load() { - char path[4096]; - FILE *f = test_tempfile(path, sizeof(path)); - SS_CHECK(f != nullptr); - std::fprintf(f, "[black_list]\n"); - std::fprintf(f, "1.2.3.0/999\n"); - std::fprintf(f, "2.2.2.2/-1\n"); - std::fprintf(f, "3.3.3.3/abc\n"); - std::fprintf(f, "4.4.4.0/24\n"); - std::fprintf(f, "^path/with/slash\\.example$\n"); - std::fclose(f); - - SS_CHECK(init_acl(path) == 0); - SS_CHECK(acl_match_host("1.2.3.1") == 0); - SS_CHECK(acl_match_host("2.2.2.2") == 0); - SS_CHECK(acl_match_host("3.3.3.3") == 0); + for (const char *line : { "1.2.3.0/999\n", "2.2.2.2/-1\n", "3.3.3.3/abc\n", "::1/129\n", "::ffff:1.2.3.4/64\n" }) { + char body[64]; + std::snprintf(body, sizeof(body), "[black_list]\n4.4.4.0/24\n%s", line); + SS_CHECK(load_acl(body) == -1); + SS_CHECK(acl_match_host("4.4.4.8") == 0); + } + SS_CHECK(load_acl("[black_list]\n4.4.4.0/24\n^path/with/slash\\.example$\n") == 0); SS_CHECK(acl_match_host("4.4.4.8") == 1); SS_CHECK(acl_match_host("path/with/slash.example") == 1); free_acl(); - - std::remove(path); } } // namespace int main() { - SS_RUN(test_invalid_regex_is_not_installed); + SS_RUN(test_invalid_regex_fails_the_load); + SS_RUN(test_reload_starts_from_nothing); + SS_RUN(test_rules_ignore_case_and_trailing_dot); + SS_RUN(test_ipv4_mapped_addresses_match_ipv4_rules); SS_RUN(test_long_acl_line_is_discarded); - SS_RUN(test_invalid_cidr_is_discarded); + SS_RUN(test_invalid_cidr_fails_the_load); return 0; } diff --git a/tests/test_crypto_aead2022_cpp.cpp b/tests/test_crypto_aead2022_cpp.cpp index 99f30196..cf1d9199 100644 --- a/tests/test_crypto_aead2022_cpp.cpp +++ b/tests/test_crypto_aead2022_cpp.cpp @@ -351,6 +351,41 @@ void udp_window() SS_CHECK(pkt && !sc::udp2022_accept(sc::Role::server, server, *pkt, out, std::time(nullptr) + 31)); } +// A client keeps the replay window of the server session it heard from +// before, so a server that starts a new session cannot have its old replies +// replayed into a fresh window. +void udp_server_session_change() +{ + sc::Psk psk = psk_of("2022-blake3-aes-128-gcm"); + sc::UdpSession2022 client, server1, server2; + std::uint8_t plain[sizeof(kAddr)]; + std::memcpy(plain, kAddr, sizeof(kAddr)); + std::vector req(sizeof(plain) + sc::udp2022_overhead(psk, sc::Role::client)); + SS_CHECK(sc::udp2022_seal(psk, sc::Role::client, client, plain, req)); + for (sc::UdpSession2022 *server : { &server1, &server2 }) { + std::vector out(req.size()); + auto pkt = sc::udp2022_decrypt(psk, req, out); + SS_CHECK(pkt && sc::udp2022_accept(sc::Role::server, *server, *pkt, out, std::time(nullptr))); + } + auto reply = [&](sc::UdpSession2022 &server) { + std::vector p(sizeof(plain) + sc::udp2022_overhead(psk, sc::Role::server)); + SS_CHECK(sc::udp2022_seal(psk, sc::Role::server, server, plain, p)); + return p; + }; + auto deliver = [&](const std::vector &p) { + std::vector out(p.size()); + auto pkt = sc::udp2022_decrypt(psk, p, out); + SS_CHECK(pkt); + return static_cast(sc::udp2022_accept(sc::Role::client, client, *pkt, out, std::time(nullptr))); + }; + auto a1 = reply(server1), b1 = reply(server1), a2 = reply(server2); + SS_CHECK(deliver(a1)); + SS_CHECK(deliver(a2)); // the server moved to a new session + SS_CHECK(!deliver(a1)); // a replay of the old session is still caught + SS_CHECK(deliver(b1)); // a late packet of the old session is not + SS_CHECK(!deliver(a2) && !deliver(b1)); +} + void salt_pool() { sc::SaltPool pool; @@ -358,7 +393,11 @@ void salt_pool() std::uint8_t a[32] = { 1 }, b[32] = { 2 }, d[32] = { 3 }; SS_CHECK(pool.check_and_add(a, 100) && !pool.check_and_add(a, 159)); SS_CHECK(pool.check_and_add(a, 160)); // retention over - SS_CHECK(pool.check_and_add(b, 161) && pool.check_and_add(d, 161) && pool.size() == 2); + SS_CHECK(pool.check_and_add(b, 161) && pool.size() == 2); + // Full of salts still inside the window: a new one is refused rather + // than evicting one that could then be replayed. + SS_CHECK(!pool.check_and_add(d, 161) && pool.size() == 2 && !pool.check_and_add(a, 161)); + SS_CHECK(pool.check_and_add(d, 221)); // a and b have expired } void steady(const sc::Psk &psk, sc::SaltPool &pool) @@ -395,6 +434,7 @@ void test_methods() } tcp_rejects(); udp_window(); + udp_server_session_change(); salt_pool(); SS_CHECK(!sc::find_aead2022("aes-256-gcm")); auto spec = sc::find_aead2022("2022-blake3-aes-256-gcm"); diff --git a/tests/test_half_close.py b/tests/test_half_close.py new file mode 100644 index 00000000..32464ef9 --- /dev/null +++ b/tests/test_half_close.py @@ -0,0 +1,63 @@ +#!/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() diff --git a/tests/test_manager.py b/tests/test_manager.py index 74f59e7a..77f5a318 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -1,11 +1,13 @@ #!/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.""" +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 @@ -21,6 +23,70 @@ def control(port, message, timeout=5): 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: @@ -59,31 +125,40 @@ def main(): reply = control(manager_port, 'add: {"server_port": %d, "password": "secret"}' % server_port) assert reply == "ok", reply wait_listening(server_port) - 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(control(manager_port, "ping")[len("stat: "):]) - traffic = stat.get(str(server_port), 0) - time.sleep(.5) - assert traffic > 0, "no traffic statistics from the managed server" + 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" names the same server as "". + 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}" - print("PASS ss-manager add/ping/list/remove with traffic statistics") + 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 diff --git a/tests/test_server_acl.py b/tests/test_server_acl.py new file mode 100644 index 00000000..db064d76 --- /dev/null +++ b/tests/test_server_acl.py @@ -0,0 +1,116 @@ +#!/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() diff --git a/tests/test_ssurl_cpp.cpp b/tests/test_ssurl_cpp.cpp index 9d7d50d4..8c20e025 100644 --- a/tests/test_ssurl_cpp.cpp +++ b/tests/test_ssurl_cpp.cpp @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later // ss:// URI parsing (src/ssurl.cpp): SIP002, the legacy form and malformed // input. +#include +#include #include #include @@ -60,6 +62,25 @@ void test_sip002_plugin() ss_url_free(&u); } +/* A shared link may name a plugin but not a path to a program; a repeated + * plugin= replaces the first; the tag cannot carry control characters. */ +void test_sip002_plugin_hardening() +{ + ss_url_t u; + const char *base = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@192.168.100.1:8888/?plugin="; + char url[256]; + for (const char *bad : { "%2Ftmp%2Fevil", "..%2Fevil", "C:%5Cevil.exe", "", "%3Bopts" }) { + std::snprintf(url, sizeof(url), "%s%s", base, bad); + SS_CHECK(ss_url_parse(url, &u) == -1); + } + std::snprintf(url, sizeof(url), "%s%s", base, "first%3Ba&plugin=second%3Bb#line%0Abreak%1B"); + SS_CHECK(ss_url_parse(url, &u) == 0); + SS_CHECK(streq(u.plugin, "second")); + SS_CHECK(streq(u.plugin_opts, "b")); + SS_CHECK(streq(u.tag, "line?break?")); + ss_url_free(&u); +} + /* The trailing slash before a query is allowed, and so is no query at all. */ void test_sip002_trailing_slash() { @@ -212,6 +233,7 @@ int main() SS_RUN(test_sip002_basic); SS_RUN(test_sip002_tag); SS_RUN(test_sip002_plugin); + SS_RUN(test_sip002_plugin_hardening); SS_RUN(test_sip002_trailing_slash); SS_RUN(test_sip002_ipv6); SS_RUN(test_sip002_plain_userinfo);