feat(daemon): host ACL, configurable max connections, peer audit, auth-failure delay
This commit is contained in:
@@ -51,6 +51,7 @@ READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
|
||||
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
|
||||
TEAM_MODULE = os.path.join(MODULE_ROOT, "team")
|
||||
OWNER_MODULE = os.path.join(MODULE_ROOT, "owner")
|
||||
DENIED_MODULE = os.path.join(MODULE_ROOT, "denied")
|
||||
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
|
||||
CRED_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.passwd")
|
||||
STARTFAIL_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_startfail.conf")
|
||||
@@ -191,7 +192,7 @@ def _config_port(config_path):
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def daemon_env():
|
||||
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE,
|
||||
DETACH_MODULE):
|
||||
DENIED_MODULE, DETACH_MODULE):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
generate_test_files(SOURCE_DIR, full=False)
|
||||
@@ -231,7 +232,12 @@ def daemon_env():
|
||||
"[owner]\n"
|
||||
"path = %s\n"
|
||||
"client owner = yes\n"
|
||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE))
|
||||
"\n"
|
||||
"[denied]\n"
|
||||
"path = %s\n"
|
||||
"hosts deny = 127.0.0.1\n"
|
||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE,
|
||||
DENIED_MODULE))
|
||||
|
||||
# A dedicated config for the fail-closed startup check: an auth-required
|
||||
# module with no credential store must refuse to start. Its own free port
|
||||
@@ -368,6 +374,15 @@ class TestDaemonRejection:
|
||||
result = _push("127.0.0.1::/sub", daemon.port)
|
||||
assert result.returncode != 0
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_hosts_deny_rejects_loopback(self, daemon):
|
||||
"""Host access control: a module with `hosts deny = 127.0.0.1` refuses a
|
||||
loopback client at the config gate, before any data is exchanged."""
|
||||
before = self._tree_files()
|
||||
result = _push("127.0.0.1::denied", daemon.port)
|
||||
assert result.returncode != 0
|
||||
assert self._tree_files() == before, "host-denied connection wrote under the module root"
|
||||
|
||||
def test_dotdot_destination_rejected(self, daemon):
|
||||
"""A '..' path expansion in the module-relative path is refused at parse
|
||||
time so a client cannot escape the module root while it is still on the
|
||||
|
||||
@@ -31,6 +31,10 @@ static void test_daemon_conf_create_defaults() {
|
||||
EXPECT_EQ_INT(conf->global.port, DAEMON_CONF_DEFAULT_PORT);
|
||||
EXPECT_NULL(conf->global.motd_file);
|
||||
EXPECT_NULL(conf->global.address);
|
||||
EXPECT_EQ_INT(conf->global.max_connections, DAEMON_CONF_DEFAULT_MAX_CONNECTIONS);
|
||||
EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS);
|
||||
EXPECT_EQ_INT(conf->global.hosts_allow_count, 0);
|
||||
EXPECT_EQ_INT(conf->global.hosts_deny_count, 0);
|
||||
EXPECT_EQ_INT(conf->module_count, 0);
|
||||
daemon_conf_free(conf);
|
||||
}
|
||||
@@ -310,6 +314,14 @@ static void test_daemon_conf_dparam_override() {
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port = 9000", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.port, 9000);
|
||||
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "max connections=7", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.max_connections, 7);
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "AUTH FAILURE DELAY=1500", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, 1500);
|
||||
EXPECT_EQ_INT(
|
||||
daemon_conf_apply_dparam(conf, "hosts allow=127.0.0.1,10.0.0.0/8", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.hosts_allow_count, 2);
|
||||
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port=notaport", err, sizeof(err)), -1);
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "bogus=1", err, sizeof(err)), -1);
|
||||
EXPECT_TRUE(strstr(err, "unknown global key") != NULL);
|
||||
@@ -366,6 +378,114 @@ static void test_daemon_conf_auth_users_validated() {
|
||||
daemon_conf_free(ok_conf);
|
||||
}
|
||||
|
||||
/* Wave 3 daemon hardening: configurable global/per-module connection caps,
|
||||
* auth-failure throttle and host access lists parse strictly (valid values are
|
||||
* stored, malformed values fail the whole load). */
|
||||
static void test_daemon_conf_limits_and_hosts_parse() {
|
||||
char* path;
|
||||
char err[256];
|
||||
EXPECT_EQ_INT(write_conf("max connections = 25\n"
|
||||
"auth failure delay = 0\n"
|
||||
"hosts allow = 10.0.0.0/8, *.example.com\n"
|
||||
"hosts deny = 192.168.0.1 2001:db8::/32\n"
|
||||
"\n"
|
||||
"[m]\n"
|
||||
"path = /x\n"
|
||||
"max connections = 3\n"
|
||||
"hosts allow = 127.0.0.1\n"
|
||||
"hosts deny = *\n",
|
||||
&path),
|
||||
0);
|
||||
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NOT_NULL(conf);
|
||||
EXPECT_EQ_INT(conf->global.max_connections, 25);
|
||||
EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, 0);
|
||||
EXPECT_EQ_INT(conf->global.hosts_allow_count, 2);
|
||||
EXPECT_EQ_STR(conf->global.hosts_allow[0], "10.0.0.0/8");
|
||||
EXPECT_EQ_STR(conf->global.hosts_allow[1], "*.example.com");
|
||||
EXPECT_EQ_INT(conf->global.hosts_deny_count, 2);
|
||||
EXPECT_EQ_STR(conf->global.hosts_deny[0], "192.168.0.1");
|
||||
EXPECT_EQ_STR(conf->global.hosts_deny[1], "2001:db8::/32");
|
||||
EXPECT_EQ_INT(conf->modules[0].max_connections, 3);
|
||||
EXPECT_EQ_INT(conf->modules[0].hosts_allow_count, 1);
|
||||
EXPECT_EQ_STR(conf->modules[0].hosts_allow[0], "127.0.0.1");
|
||||
EXPECT_EQ_INT(conf->modules[0].hosts_deny_count, 1);
|
||||
EXPECT_EQ_STR(conf->modules[0].hosts_deny[0], "*");
|
||||
daemon_conf_free(conf);
|
||||
|
||||
const char* bad_values[] = {
|
||||
"max connections = 0\n", "max connections = -1\n", "max connections = abc\n",
|
||||
"auth failure delay = -1\n", "auth failure delay = 70000\n", "auth failure delay = soon\n",
|
||||
"hosts allow = 10.0.0.0/99\n", "hosts deny = 2001:db8::/129\n",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(bad_values) / sizeof(bad_values[0]); i++) {
|
||||
EXPECT_EQ_INT(write_conf(bad_values[i], &path), 0);
|
||||
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NULL(rejected);
|
||||
}
|
||||
|
||||
/* The same strictness applies inside a module section. */
|
||||
const char* bad_module[] = {
|
||||
"[m]\npath = /x\nmax connections = 0\n",
|
||||
"[m]\npath = /x\nhosts allow = 10.0.0.0/40\n",
|
||||
"[m]\npath = /x\nhosts deny = 999.1.1.1/8\n",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(bad_module) / sizeof(bad_module[0]); i++) {
|
||||
EXPECT_EQ_INT(write_conf(bad_module[i], &path), 0);
|
||||
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NULL(rejected);
|
||||
EXPECT_TRUE(strstr(err, "invalid") != NULL);
|
||||
}
|
||||
|
||||
/* An empty hosts list is not an error (no patterns are added). */
|
||||
EXPECT_EQ_INT(write_conf("hosts allow = \n[m]\npath = /x\n", &path), 0);
|
||||
conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NOT_NULL(conf);
|
||||
EXPECT_EQ_INT(conf->global.hosts_allow_count, 0);
|
||||
daemon_conf_free(conf);
|
||||
}
|
||||
|
||||
static void test_daemon_hosts_allowed() {
|
||||
/* Pattern forms. */
|
||||
EXPECT_TRUE(daemon_host_pattern_match("*", "203.0.113.9"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("10.0.0.1", "10.0.0.1"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("10.0.0.1", "10.0.0.2"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("10.0.0.0/8", "10.255.1.2"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("10.0.0.0/8", "11.0.0.1"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("2001:db8::/32", "2001:db8:1234::5"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("2001:db8::/32", "2001:db9::1"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("::1", "::1"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("::1", "::2"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("*.example.com", "host.example.com"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("*.example.com", "example.org"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match(NULL, "10.0.0.1"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("10.0.0.1", NULL));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("", "10.0.0.1"));
|
||||
|
||||
char* allow[] = {"10.0.0.0/8"};
|
||||
char* deny[] = {"10.0.0.1"};
|
||||
/* Deny takes precedence over a matching allow. */
|
||||
EXPECT_FALSE(daemon_hosts_allowed("10.0.0.1", allow, 1, deny, 1));
|
||||
EXPECT_TRUE(daemon_hosts_allowed("10.0.0.2", allow, 1, deny, 1));
|
||||
/* A non-empty allow list rejects a peer that matches none of its entries. */
|
||||
EXPECT_FALSE(daemon_hosts_allowed("192.168.1.1", allow, 1, NULL, 0));
|
||||
/* With only a deny list, everything not denied is accepted. */
|
||||
EXPECT_TRUE(daemon_hosts_allowed("192.168.1.1", NULL, 0, deny, 1));
|
||||
EXPECT_FALSE(daemon_hosts_allowed("10.0.0.1", NULL, 0, deny, 1));
|
||||
/* No lists at all accepts everyone. */
|
||||
EXPECT_TRUE(daemon_hosts_allowed("192.168.1.1", NULL, 0, NULL, 0));
|
||||
/* An unprovable peer (NULL) never matches an allow list. */
|
||||
EXPECT_FALSE(daemon_hosts_allowed(NULL, allow, 1, NULL, 0));
|
||||
|
||||
EXPECT_FALSE(daemon_hosts_restricted(NULL, 0, NULL, 0));
|
||||
EXPECT_TRUE(daemon_hosts_restricted(allow, 1, NULL, 0));
|
||||
EXPECT_TRUE(daemon_hosts_restricted(NULL, 0, deny, 1));
|
||||
}
|
||||
|
||||
static void test_daemon_module_name_valid() {
|
||||
EXPECT_TRUE(daemon_module_name_valid("backup"));
|
||||
EXPECT_TRUE(daemon_module_name_valid("Backup_2"));
|
||||
@@ -398,5 +518,7 @@ void test_daemon_conf() {
|
||||
test_daemon_conf_find_module();
|
||||
test_daemon_conf_dparam_override();
|
||||
test_daemon_conf_auth_users_validated();
|
||||
test_daemon_conf_limits_and_hosts_parse();
|
||||
test_daemon_hosts_allowed();
|
||||
test_daemon_module_name_valid();
|
||||
}
|
||||
@@ -356,6 +356,69 @@ static void test_loopback_helpers() {
|
||||
close(listener);
|
||||
}
|
||||
|
||||
/* The daemon host ACL reads the numeric peer address through
|
||||
* utils_fd_peer_ip. A real loopback TCP peer reports "127.0.0.1"; a pipe or an
|
||||
* AF_UNIX socketpair has no INET peer and must return false with an empty
|
||||
* buffer (the fail-closed "cannot tell" result). */
|
||||
static void test_fd_peer_ip() {
|
||||
char ip[INET6_ADDRSTRLEN];
|
||||
EXPECT_FALSE(utils_fd_peer_ip(-1, ip, sizeof(ip)));
|
||||
EXPECT_EQ_STR(ip, "");
|
||||
EXPECT_FALSE(utils_fd_peer_ip(-1, NULL, 0));
|
||||
|
||||
int pipe_fds[2];
|
||||
EXPECT_EQ_INT(pipe(pipe_fds), 0);
|
||||
EXPECT_FALSE(utils_fd_peer_ip(pipe_fds[0], ip, sizeof(ip)));
|
||||
EXPECT_EQ_STR(ip, "");
|
||||
close(pipe_fds[0]);
|
||||
close(pipe_fds[1]);
|
||||
|
||||
int pair_fds[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, pair_fds), 0);
|
||||
EXPECT_FALSE(utils_fd_peer_ip(pair_fds[0], ip, sizeof(ip)));
|
||||
EXPECT_EQ_STR(ip, "");
|
||||
close(pair_fds[0]);
|
||||
close(pair_fds[1]);
|
||||
|
||||
int listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
EXPECT_TRUE(listener >= 0);
|
||||
struct sockaddr_in bind_addr;
|
||||
memset(&bind_addr, 0, sizeof(bind_addr));
|
||||
bind_addr.sin_family = AF_INET;
|
||||
bind_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
bind_addr.sin_port = 0;
|
||||
EXPECT_EQ_INT(bind(listener, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0);
|
||||
EXPECT_EQ_INT(listen(listener, 1), 0);
|
||||
socklen_t addr_len = sizeof(bind_addr);
|
||||
EXPECT_EQ_INT(getsockname(listener, (struct sockaddr*)&bind_addr, &addr_len), 0);
|
||||
int dialer = socket(AF_INET, SOCK_STREAM, 0);
|
||||
EXPECT_TRUE(dialer >= 0);
|
||||
EXPECT_EQ_INT(connect(dialer, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0);
|
||||
int accepted = accept(listener, NULL, NULL);
|
||||
EXPECT_TRUE(accepted >= 0);
|
||||
EXPECT_TRUE(utils_fd_peer_ip(accepted, ip, sizeof(ip)));
|
||||
EXPECT_EQ_STR(ip, "127.0.0.1");
|
||||
|
||||
/* utils_sockaddr_to_string includes the port for a real peer. */
|
||||
struct sockaddr_storage peer;
|
||||
socklen_t peer_len = sizeof(peer);
|
||||
EXPECT_EQ_INT(getpeername(accepted, (struct sockaddr*)&peer, &peer_len), 0);
|
||||
char peer_string[128];
|
||||
EXPECT_TRUE(
|
||||
utils_sockaddr_to_string((const struct sockaddr*)&peer, peer_string, sizeof(peer_string)));
|
||||
EXPECT_TRUE(strncmp(peer_string, "127.0.0.1:", strlen("127.0.0.1:")) == 0);
|
||||
close(accepted);
|
||||
close(dialer);
|
||||
close(listener);
|
||||
|
||||
/* A non-INET family formats to "unknown" at the call site, not a bogus IP. */
|
||||
struct sockaddr sa_unix;
|
||||
memset(&sa_unix, 0, sizeof(sa_unix));
|
||||
sa_unix.sa_family = AF_UNIX;
|
||||
EXPECT_FALSE(utils_sockaddr_to_string(&sa_unix, peer_string, sizeof(peer_string)));
|
||||
EXPECT_EQ_STR(peer_string, "");
|
||||
}
|
||||
|
||||
void test_shared_utils() {
|
||||
test_walker_removes_extras_keeps_manifest_and_protected();
|
||||
test_walker_max_delete_exceeded_deletes_nothing();
|
||||
@@ -363,6 +426,7 @@ void test_shared_utils() {
|
||||
test_walker_unlimited_deletes_all();
|
||||
test_walker_hard_bound_all_or_nothing();
|
||||
test_loopback_helpers();
|
||||
test_fd_peer_ip();
|
||||
|
||||
/* --append / --append-verify tail-resume math: a resume is eligible only for
|
||||
a shorter existing destination, and the tail length is then the difference. */
|
||||
|
||||
Reference in New Issue
Block a user