security(shared): fix -K TOCTOU, ssh old-args quoting, TLS opts, secret-file perms, sparse dedup
- file: open -K dirlink referents via a race-safe relative O_NOFOLLOW walk from the authorized-root fd instead of re-opening an absolute realpath() result (removes the intermediate-symlink swap TOCTOU). - transport_ssh: always single-quote the server path, including --old-args, so no mode can inject shell metacharacters. - transport_tls: set SSL_OP_NO_COMPRESSION and (guarded) SSL_OP_NO_RENEGOTIATION. - credentials: reject --password-file/--early-input with any group/other permission bit; chmod 0600 the affected test fixtures. - file_store: export file_store_write_sparse() and remove the verbatim file.c duplicate.
This commit is contained in:
@@ -66,6 +66,7 @@ def _pw_hash(password):
|
||||
def _write_client_password_file(path, user, password):
|
||||
with open(path, "w") as f:
|
||||
f.write("%s:%s\n" % (user, password))
|
||||
os.chmod(path, 0o600)
|
||||
return path
|
||||
|
||||
|
||||
@@ -164,6 +165,7 @@ def daemon_env():
|
||||
f.write("# daemon credential store (Wave B)\n")
|
||||
f.write("alice:%s\n" % _pw_hash(ALICE_PASS))
|
||||
f.write("bob:%s\n" % _pw_hash(BOB_PASS))
|
||||
os.chmod(CRED_FILE, 0o600)
|
||||
|
||||
# The config's port is a free port chosen per worker; the `daemon` fixture
|
||||
# boots on it (the config-port path) and the --dparam override test boots a
|
||||
@@ -582,6 +584,7 @@ class TestDaemonAuthentication:
|
||||
cred_path = os.path.join(TEST_DATA_DIR, "client_empty.pw")
|
||||
with open(cred_path, "w") as f:
|
||||
f.write("# nothing here\n")
|
||||
os.chmod(cred_path, 0o600)
|
||||
try:
|
||||
cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR,
|
||||
"--dest-dir", "127.0.0.1::files",
|
||||
|
||||
@@ -33,6 +33,9 @@ static char* make_tmp_file(const char* contents) {
|
||||
return NULL;
|
||||
}
|
||||
fclose(fp);
|
||||
/* Credential/password files are owner-only; the reader rejects group/other
|
||||
* permission bits, so create temp files 0600 like the real ones. */
|
||||
chmod(path, 0600);
|
||||
return strdup(path);
|
||||
}
|
||||
|
||||
@@ -353,6 +356,47 @@ static void test_credentials_gate_allows() {
|
||||
free(path);
|
||||
}
|
||||
|
||||
static void test_credentials_rejects_group_or_other_accessible() {
|
||||
char err[512];
|
||||
char* path =
|
||||
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
|
||||
EXPECT_NOT_NULL(path);
|
||||
|
||||
/* 0600 is accepted by the server store loader. */
|
||||
EXPECT_EQ_INT(chmod(path, 0600), 0);
|
||||
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(store);
|
||||
credentials_free(store);
|
||||
|
||||
/* Group-readable and world-readable are both refused, with a clear error. */
|
||||
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
|
||||
EXPECT_TRUE(strstr(err, "owner-only") != NULL);
|
||||
EXPECT_EQ_INT(chmod(path, 0604), 0);
|
||||
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
|
||||
|
||||
/* The client --password-file reader enforces the same rule. */
|
||||
EXPECT_EQ_INT(chmod(path, 0644), 0);
|
||||
char* user = NULL;
|
||||
char* password = NULL;
|
||||
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), -1);
|
||||
EXPECT_NULL(user);
|
||||
EXPECT_NULL(password);
|
||||
EXPECT_TRUE(strstr(err, "owner-only") != NULL);
|
||||
|
||||
/* An --early-input file is checked too. */
|
||||
char* pw =
|
||||
make_tmp_file("bob:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
|
||||
EXPECT_NOT_NULL(pw);
|
||||
EXPECT_EQ_INT(chmod(path, 0644), 0);
|
||||
EXPECT_NULL(credentials_load(pw, path, err, sizeof(err)));
|
||||
|
||||
rm_temp(pw);
|
||||
rm_temp(path);
|
||||
free(pw);
|
||||
free(path);
|
||||
}
|
||||
|
||||
static void test_credentials_burn() {
|
||||
char secret[32];
|
||||
memcpy(secret, "supersecretvalue", 17);
|
||||
@@ -374,6 +418,7 @@ void test_credentials(void) {
|
||||
test_credentials_early_input_merge();
|
||||
test_credentials_read_secret_file();
|
||||
test_credentials_read_secret_file_bad();
|
||||
test_credentials_rejects_group_or_other_accessible();
|
||||
test_credentials_gate_allows();
|
||||
test_credentials_burn();
|
||||
}
|
||||
|
||||
@@ -1370,6 +1370,123 @@ static void test_dir_time_list() {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* -K/--keep-dirlinks secure open: with an authorized root, a destination path
|
||||
* component that is a symlink to an IN-ROOT directory is used as that directory
|
||||
* (its referent is opened through a relative O_NOFOLLOW walk from the root fd,
|
||||
* not by re-opening an absolute realpath() result), while a symlink resolving
|
||||
* OUTSIDE the root is rejected. With -K off, even the in-root link is not
|
||||
* followed. */
|
||||
static void test_keep_dirlinks_secure_open_impl() {
|
||||
const char* root = "test_keep_dirlinks_root";
|
||||
const char* real = "test_keep_dirlinks_root/realdir";
|
||||
const char* link = "test_keep_dirlinks_root/linkdir";
|
||||
const char* abslink = "test_keep_dirlinks_root/abslink";
|
||||
const char* escape = "test_keep_dirlinks_root/escape";
|
||||
const char* outside = "test_keep_dirlinks_outside";
|
||||
unlink(link);
|
||||
unlink(abslink);
|
||||
unlink(escape);
|
||||
rmdir(real);
|
||||
rmdir(root);
|
||||
rmdir(outside);
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(real, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(outside, 0755), 0);
|
||||
|
||||
char root_abs[PATH_MAX];
|
||||
char real_abs[PATH_MAX];
|
||||
char outside_abs[PATH_MAX];
|
||||
EXPECT_NOT_NULL(realpath(root, root_abs));
|
||||
EXPECT_NOT_NULL(realpath(real, real_abs));
|
||||
EXPECT_NOT_NULL(realpath(outside, outside_abs));
|
||||
EXPECT_EQ_INT(symlink("realdir", link), 0); /* relative, in-root */
|
||||
EXPECT_EQ_INT(symlink(real_abs, abslink), 0); /* absolute, in-root */
|
||||
/* cppcheck-suppress knownConditionTrueFalse */
|
||||
EXPECT_EQ_INT(symlink(outside_abs, escape), 0); /* absolute, outside root */
|
||||
|
||||
int root_fd = open(root_abs, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||
EXPECT_TRUE(root_fd >= 0);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (root_fd < 0) {
|
||||
unlink(link);
|
||||
unlink(abslink);
|
||||
unlink(escape);
|
||||
rmdir(real);
|
||||
rmdir(root);
|
||||
rmdir(outside);
|
||||
return;
|
||||
}
|
||||
EXPECT_TRUE(file_set_authorized_root(root_fd, root_abs));
|
||||
file_set_keep_dirlinks(true);
|
||||
|
||||
struct stat real_st;
|
||||
EXPECT_EQ_INT(fstatat(root_fd, "realdir", &real_st, 0), 0);
|
||||
|
||||
/* Relative in-root symlink-to-directory: followed to the referent dir. */
|
||||
char path[PATH_MAX + 64];
|
||||
snprintf(path, sizeof(path), "%s/linkdir/file.txt", root_abs);
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(path, &leaf, false);
|
||||
EXPECT_TRUE(parent_fd >= 0);
|
||||
EXPECT_NOT_NULL(leaf);
|
||||
if (leaf)
|
||||
EXPECT_EQ_STR(leaf, "file.txt");
|
||||
if (parent_fd >= 0) {
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(fstat(parent_fd, &st), 0);
|
||||
EXPECT_TRUE(st.st_dev == real_st.st_dev && st.st_ino == real_st.st_ino);
|
||||
close(parent_fd);
|
||||
}
|
||||
free(leaf);
|
||||
|
||||
/* Absolute-but-in-root symlink-to-directory is followed the same way. */
|
||||
snprintf(path, sizeof(path), "%s/abslink/file.txt", root_abs);
|
||||
leaf = NULL;
|
||||
parent_fd = file_open_secure_parent(path, &leaf, false);
|
||||
EXPECT_TRUE(parent_fd >= 0);
|
||||
if (parent_fd >= 0) {
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(fstat(parent_fd, &st), 0);
|
||||
EXPECT_TRUE(st.st_dev == real_st.st_dev && st.st_ino == real_st.st_ino);
|
||||
close(parent_fd);
|
||||
}
|
||||
free(leaf);
|
||||
|
||||
/* A symlink resolving outside the authorized root is rejected. */
|
||||
snprintf(path, sizeof(path), "%s/escape/file.txt", root_abs);
|
||||
leaf = NULL;
|
||||
EXPECT_EQ_INT(file_open_secure_parent(path, &leaf, false), -1);
|
||||
free(leaf);
|
||||
|
||||
/* With -K off the in-root symlink is not followed either. */
|
||||
file_set_keep_dirlinks(false);
|
||||
snprintf(path, sizeof(path), "%s/linkdir/file.txt", root_abs);
|
||||
leaf = NULL;
|
||||
EXPECT_EQ_INT(file_open_secure_parent(path, &leaf, false), -1);
|
||||
free(leaf);
|
||||
|
||||
file_set_keep_dirlinks(false);
|
||||
file_set_authorized_root(-1, NULL);
|
||||
close(root_fd);
|
||||
unlink(link);
|
||||
unlink(abslink);
|
||||
unlink(escape);
|
||||
rmdir(real);
|
||||
rmdir(root);
|
||||
rmdir(outside);
|
||||
}
|
||||
|
||||
/* Wrapper guarantees the process-wide keep-dirlinks/authorized-root policy is
|
||||
* cleared even when an EXPECT inside the body returns early (a failing EXPECT
|
||||
* returns from its own function, so the body's trailing resets may be skipped). */
|
||||
static void test_keep_dirlinks_secure_open() {
|
||||
file_set_authorized_root(-1, NULL);
|
||||
file_set_keep_dirlinks(false);
|
||||
test_keep_dirlinks_secure_open_impl();
|
||||
file_set_authorized_root(-1, NULL);
|
||||
file_set_keep_dirlinks(false);
|
||||
}
|
||||
|
||||
void test_file() {
|
||||
test_file_create();
|
||||
test_file_special_rdev_valid();
|
||||
@@ -1411,6 +1528,7 @@ void test_file() {
|
||||
}
|
||||
test_file_metadata_create();
|
||||
test_dir_time_list();
|
||||
test_keep_dirlinks_secure_open();
|
||||
test_inplace_overwrite_clears_special_mode_bits();
|
||||
test_inplace_overwrite_metadata_strips_special_bits();
|
||||
test_inplace_overwrite_truncates_shorter_payload();
|
||||
|
||||
@@ -55,8 +55,14 @@ static void test_ssh_remote_command_argument_modes() {
|
||||
EXPECT_EQ_STR(command, "'fast'\\''sync' --stdio");
|
||||
free(command);
|
||||
|
||||
/* --old-args no longer disables injection-safe quoting: the path is still one
|
||||
single-quoted word, even when it carries shell metacharacters. */
|
||||
command = ssh_build_remote_command("fast sync; touch /tmp/pwned", true, NULL, 0);
|
||||
EXPECT_EQ_STR(command, "fast sync; touch /tmp/pwned --stdio");
|
||||
EXPECT_EQ_STR(command, "'fast sync; touch /tmp/pwned' --stdio");
|
||||
free(command);
|
||||
|
||||
command = ssh_build_remote_command("fast'sync; rm -rf /", true, NULL, 0);
|
||||
EXPECT_EQ_STR(command, "'fast'\\''sync; rm -rf /' --stdio");
|
||||
free(command);
|
||||
}
|
||||
|
||||
@@ -131,9 +137,9 @@ static void test_ssh_remote_command_with_remote_options() {
|
||||
free(command);
|
||||
free(val);
|
||||
|
||||
/* --old-args leaves the server path unquoted but still quotes remote options. */
|
||||
/* --old-args still quotes both the server path and the remote options. */
|
||||
command = ssh_build_remote_command("srv", true, multi, 2);
|
||||
EXPECT_EQ_STR(command, "srv --stdio '-v' '--allow-delete'");
|
||||
EXPECT_EQ_STR(command, "'srv' --stdio '-v' '--allow-delete'");
|
||||
free(command);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "test_utils.h"
|
||||
#include "transport_tcp.h"
|
||||
#include "transport_tls.h"
|
||||
#include <openssl/ssl.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -17,6 +18,12 @@ static void test_server_create_tls_without_certs() {
|
||||
bool ok = server_create_tls(s, NULL, NULL, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_NOT_NULL(s->ssl_ctx);
|
||||
/* The context must disable TLS compression (CRIME) and renegotiation. */
|
||||
SSL_CTX* ctx = (SSL_CTX*)s->ssl_ctx;
|
||||
EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_NO_COMPRESSION) != 0);
|
||||
#ifdef SSL_OP_NO_RENEGOTIATION
|
||||
EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_NO_RENEGOTIATION) != 0);
|
||||
#endif
|
||||
server_delete(&s);
|
||||
EXPECT_NULL(s);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user