Files
Max LvandClaude 50f1ae54f7 Fix findings from the security audit (#3083)
* Fix findings from the security audit

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

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

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

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

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

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

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

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

* coverage: tolerate gcov 15 lambda inconsistencies in lcov 2

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

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

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

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

---------

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

247 lines
7.6 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// ss:// URI parsing (src/ssurl.cpp): SIP002, the legacy form and malformed
// input.
#include <cstdio>
#include <initializer_list>
#include <cstddef>
#include <cstring>
#include "ssurl.h"
#include "test_helpers.hpp"
extern "C" {
int verbose = 0;
}
namespace {
bool streq(const char *a, const char *b)
{
return a != nullptr && std::strcmp(a, b) == 0;
}
/* ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@192.168.100.1:8888 */
void test_sip002_basic()
{
ss_url_t u;
SS_CHECK(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@192.168.100.1:8888",
&u) == 0);
SS_CHECK(streq(u.method, "aes-256-gcm"));
SS_CHECK(streq(u.password, "password"));
SS_CHECK(streq(u.host, "192.168.100.1"));
SS_CHECK(streq(u.port, "8888"));
SS_CHECK(u.plugin == nullptr);
ss_url_free(&u);
}
/* A tag after '#' is descriptive only and must not leak into the fields. */
void test_sip002_tag()
{
ss_url_t u;
SS_CHECK(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@192.168.100.1:8888"
"#Example%20Server", &u) == 0);
SS_CHECK(streq(u.host, "192.168.100.1"));
SS_CHECK(streq(u.tag, "Example Server"));
ss_url_free(&u);
}
/* SIP003 plugin, with its options after the ';'. */
void test_sip002_plugin()
{
ss_url_t u;
SS_CHECK(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@192.168.100.1:8888"
"/?plugin=obfs-local%3Bobfs%3Dhttp#tag", &u) == 0);
SS_CHECK(streq(u.host, "192.168.100.1"));
SS_CHECK(streq(u.port, "8888"));
SS_CHECK(streq(u.plugin, "obfs-local"));
SS_CHECK(streq(u.plugin_opts, "obfs=http"));
SS_CHECK(streq(u.tag, "tag"));
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()
{
ss_url_t u;
SS_CHECK(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@example.com:8388/",
&u) == 0);
SS_CHECK(streq(u.host, "example.com"));
SS_CHECK(streq(u.port, "8388"));
ss_url_free(&u);
}
/* A bracketed IPv6 literal must keep its address and port apart. */
void test_sip002_ipv6()
{
ss_url_t u;
SS_CHECK(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@[2001:db8::1]:8388",
&u) == 0);
SS_CHECK(streq(u.host, "2001:db8::1"));
SS_CHECK(streq(u.port, "8388"));
ss_url_free(&u);
}
/* Some producers write the userinfo in the clear rather than base64. */
void test_sip002_plain_userinfo()
{
ss_url_t u;
SS_CHECK(ss_url_parse("ss://aes-256-gcm:secret@example.com:8388", &u) == 0);
SS_CHECK(streq(u.method, "aes-256-gcm"));
SS_CHECK(streq(u.password, "secret"));
SS_CHECK(streq(u.host, "example.com"));
ss_url_free(&u);
}
/*
* A 2022 pre-shared key is base64 and can contain '+', '/' and '='. Encoded
* into the userinfo it must survive the round trip byte for byte.
*/
void test_sip002_2022_psk()
{
ss_url_t u;
/* base64("2022-blake3-aes-256-gcm:ZG9uJ3QgdXNlIHRoaXMga2V5Pz8/Pz8/Pz8=") */
const char *uri =
"ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wkc5dUozUWdkWE5sSUhSb2FYTWdhMlY1"
"Pz8_Pz8_Pz89@example.com:8388";
SS_CHECK(ss_url_parse(uri, &u) == 0);
SS_CHECK(streq(u.method, "2022-blake3-aes-256-gcm"));
SS_CHECK(streq(u.host, "example.com"));
SS_CHECK(streq(u.port, "8388"));
ss_url_free(&u);
}
/* Legacy form: the whole body is one base64 blob. */
void test_legacy()
{
ss_url_t u;
/* base64("aes-256-cfb:password@192.168.100.1:8888") */
SS_CHECK(ss_url_parse("ss://YWVzLTI1Ni1jZmI6cGFzc3dvcmRAMTkyLjE2OC4xMDAuMTo4ODg4",
&u) == 0);
SS_CHECK(streq(u.method, "aes-256-cfb"));
SS_CHECK(streq(u.password, "password"));
SS_CHECK(streq(u.host, "192.168.100.1"));
SS_CHECK(streq(u.port, "8888"));
ss_url_free(&u);
}
/* Malformed input must be rejected, not half-parsed. */
void test_rejects_malformed()
{
ss_url_t u;
static const char *const bad[] = {
nullptr,
"",
"http://example.com",
"ss://",
"ss://@example.com:8388", /* empty userinfo */
"ss://YWVzOnB3@example.com", /* no port */
"ss://YWVzOnB3@:8388", /* no host */
"ss://YWVzOnB3@example.com:", /* empty port */
"ss://bm9jb2xvbg@example.com:8388", /* userinfo lacks ':' */
"ss://YWVzOnB3@[2001:db8::1", /* unterminated bracket */
};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
std::memset(&u, 0xa5, sizeof(u));
SS_CHECK(ss_url_parse(bad[i], &u) == -1);
/* a rejected parse must leave nothing behind to free */
SS_CHECK(u.method == nullptr && u.host == nullptr && u.port == nullptr);
}
}
/*
* URIs emitted by shadowsocks-rust's `ssurl --encode`, copied verbatim.
* These pin the shapes another implementation actually produces, which is
* what the parser has to accept in the field.
*/
void test_rust_ssurl_output()
{
ss_url_t u;
/* A 2022 cipher: plain userinfo, '=' of the key percent-encoded. */
SS_CHECK(ss_url_parse("ss://2022-blake3-aes-256-gcm:"
"ZG9uJ3QgdXNlIHRoaXMga2V5ISEhISEhISEhISEhISE%3D"
"@192.168.100.1:8888", &u) == 0);
SS_CHECK(streq(u.method, "2022-blake3-aes-256-gcm"));
SS_CHECK(streq(u.password, "ZG9uJ3QgdXNlIHRoaXMga2V5ISEhISEhISEhISEhISE="));
SS_CHECK(streq(u.host, "192.168.100.1"));
SS_CHECK(streq(u.port, "8888"));
ss_url_free(&u);
/* A legacy cipher: unpadded base64 userinfo, plugin in the query. */
SS_CHECK(ss_url_parse("ss://YWVzLTI1Ni1nY206dGVzdC1wYXNzd29yZA"
"@192.168.100.1:8888/?plugin=obfs%2Dlocal%3Bobfs%3Dhttp",
&u) == 0);
SS_CHECK(streq(u.method, "aes-256-gcm"));
SS_CHECK(streq(u.password, "test-password"));
SS_CHECK(streq(u.plugin, "obfs-local"));
SS_CHECK(streq(u.plugin_opts, "obfs=http"));
ss_url_free(&u);
/* An IPv6 server. */
SS_CHECK(ss_url_parse("ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpwdw"
"@[2001:db8::1]:8388", &u) == 0);
SS_CHECK(streq(u.method, "chacha20-ietf-poly1305"));
SS_CHECK(streq(u.password, "pw"));
SS_CHECK(streq(u.host, "2001:db8::1"));
SS_CHECK(streq(u.port, "8388"));
ss_url_free(&u);
}
/* Freeing a zeroed struct, or nullptr, must be safe. */
void test_free_is_safe()
{
ss_url_t u{};
ss_url_free(&u);
ss_url_free(nullptr);
}
} // namespace
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);
SS_RUN(test_sip002_2022_psk);
SS_RUN(test_legacy);
SS_RUN(test_rejects_malformed);
SS_RUN(test_rust_ssurl_output);
SS_RUN(test_free_is_safe);
return 0;
}