28 Commits
Author SHA1 Message Date
TapTap 1acb66628d Merge Wave 2: thread-safety fixes (signals, fd ownership, handler epilogue, logging, scanner leak)
CI / lint (push) Successful in 1m30s
CI / sanitizers (undefined) (push) Successful in 59s
CI / sanitizers (address) (push) Successful in 1m6s
CI / fuzz-build (push) Successful in 28s
CI / coverage (push) Successful in 49s
CI / build-and-test (push) Successful in 4m31s
CI / valgrind (push) Successful in 3m10s
2026-09-13 02:13:27 +02:00
TapTap ba1c7a369f fix(server,log): non-socket shutdown fallback, drop redundant delay cleanup, unlock logging I/O 2026-09-13 02:13:22 +02:00
TapTap d28489d83c Merge branch 'fix/w2-scan' into fix/w2-integration 2026-09-13 01:49:44 +02:00
TapTap 312ed05170 Merge branch 'fix/w2-log' into fix/w2-integration 2026-09-13 01:49:44 +02:00
TapTap fecbe2c90c fix(server): child-safe signals, single fd owner, handler cleanup epilogue 2026-09-13 01:49:27 +02:00
TapTap c8f5d80fcb fix(log): serialize message emission; clear log_fp before close; use logger 2026-09-13 01:44:59 +02:00
TapTap 8147ff7b50 fix(scanner): free chunk_data on chunk-create failure 2026-09-13 01:36:51 +02:00
TapTap b7fbb56289 test(file): silence cppcheck constVariablePointer in empty-path test
CI / lint (push) Successful in 1m32s
CI / sanitizers (undefined) (push) Successful in 57s
CI / sanitizers (address) (push) Successful in 1m4s
CI / fuzz-build (push) Successful in 30s
CI / coverage (push) Successful in 49s
CI / build-and-test (push) Successful in 4m28s
CI / valgrind (push) Successful in 3m10s
2026-09-13 01:25:29 +02:00
TapTap 08063b6d73 Merge Wave 1: critical/High fixes (UAF, DoS caps, leaks, hardening)
CI / lint (push) Failing after 1m32s
CI / build-and-test (push) Skipped
CI / sanitizers (address) (push) Skipped
CI / sanitizers (undefined) (push) Skipped
CI / fuzz-build (push) Skipped
CI / coverage (push) Skipped
CI / valgrind (push) Skipped
2026-09-13 01:18:58 +02:00
TapTap ea2f76cd7a fix(receiver): charge per-entry DirTimeList cost; cap client --skip-compress 2026-09-13 01:18:54 +02:00
TapTap 76eeba1773 Merge branch 'fix/w1d-hardening' into fix/w1-integration 2026-09-13 00:59:35 +02:00
TapTap b72ab298ab Merge branch 'fix/w1c-wire' into fix/w1-integration 2026-09-13 00:59:35 +02:00
TapTap 446a714ef8 Merge branch 'fix/w1b-receiver' into fix/w1-integration 2026-09-13 00:59:35 +02:00
TapTap a90e234eb3 harden: overflow guards, auth-user validation, TLS1.3 policy, build hardening 2026-09-13 00:59:21 +02:00
TapTap f8252cf3e7 fix(protocol): bound pre-auth config string memory 2026-09-13 00:57:32 +02:00
TapTap 4557924972 fix(receiver): cap DirTimeList growth and fix placeholder Data leaks 2026-09-13 00:57:32 +02:00
TapTap 59ce174d22 fix(client-send): UAF in basis preflight and missing_args leak 2026-09-13 00:57:09 +02:00
TapTap 2a8941ee5c Merge feat/ref-integration: SuperMode enum, dir-time gate dedup, parse_args/server_module_gate splits
CI / lint (push) Successful in 1m30s
CI / sanitizers (undefined) (push) Successful in 59s
CI / sanitizers (address) (push) Successful in 1m6s
CI / fuzz-build (push) Successful in 29s
CI / coverage (push) Successful in 49s
CI / valgrind (push) Successful in 2m9s
CI / build-and-test (push) Successful in 4m31s
2026-09-12 21:11:03 +02:00
TapTap 37037a6ee7 refactor(client-cli): drop unused CliParseCtx positional fields (cppcheck) 2026-09-12 21:07:14 +02:00
TapTap 061e9ad43f Merge feat/ref-modulegate: split server_module_gate into helpers 2026-09-12 20:56:43 +02:00
TapTap f928879755 Merge feat/ref-parseargs: split parse_args into focused helpers 2026-09-12 20:56:43 +02:00
TapTap 84b7cb0de3 refactor(server): split server_module_gate into ordered helper stages 2026-09-12 20:56:33 +02:00
TapTap 921472b8b3 refactor(client-cli): split parse_args into focused option handlers
Break the ~700-line parse_args god function into cohesive static helpers
grouped by concern: output controls, pre-negation, range/time options, the
OPTION_TABLE dispatcher, flag/meta handlers, IO/network options, filter and
logging options, checksum/socket options, remote/basis/identity options,
positional handling, and a final lowering step.

A file-local CliParseCtx carries the config, cursor, positional buffers and
the mutable parse flags, so each handler stays focused. The dispatcher calls
the handlers in the original recognition order and preserves the exact
return contract (0/1/negative), error messages, log levels and control flow.

Behavior preserved; no functional changes.
2026-09-12 20:55:04 +02:00
TapTap 082ac2645d Merge feat/ref-dirtime: dir-time capture gate dedup 2026-09-12 20:43:42 +02:00
TapTap eefbd1e849 Merge feat/ref-supermode: SuperMode enum 2026-09-12 20:43:42 +02:00
TapTap 1fd462cca8 refactor(config): replace SUPER_MODE_* macros with SuperMode enum
Type Config.super_mode as SuperMode (a proper C enum) instead of a bare
int.  The wire boundary still carries the mode as an int: send casts the
enum explicitly and receive reads a temporary int, validates the
AUTO..OFF range, then casts.  Emitted bytes and accepted values are
unchanged.  ModuleGateContext.super_mode_override keeps its -1 sentinel
as int with an explicit cast at the apply site.

Behavior preserved.
2026-09-12 20:43:24 +02:00
TapTap 4ac37c4d8a refactor(dir-times): extract dir_times_should_capture predicate
Deduplicate the repeated directory-time capture gate
(`config->use_metadata && !config->omit_dir_times`) used by the
sender-side (multiprocessing.c) and receiver-side (receiver.c) sinks
into a single predicate declared next to the DirTimeList machinery in
file_receive.h and defined in file_receive.c.

Behavior preserved: identical short-circuit condition and semantics,
no signature or protocol changes.
2026-09-12 20:42:47 +02:00
TapTap 08af945bd6 Merge main back into dev after v2.19.0 release
CI / lint (push) Successful in 1m32s
CI / sanitizers (address) (push) Successful in 55s
CI / fuzz-build (push) Successful in 30s
CI / sanitizers (undefined) (push) Successful in 53s
CI / coverage (push) Successful in 47s
CI / build-and-test (push) Successful in 4m28s
CI / valgrind (push) Successful in 2m11s
2026-09-12 20:22:54 +02:00
27 changed files with 1699 additions and 659 deletions

No files matched your search

+6 -6
View File
@@ -12,7 +12,7 @@ jobs:
container: gitea.tap-tap.win/taptap/fastsync-ci:v10 container: gitea.tap-tap.win/taptap/fastsync-ci:v10
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: clang-format check - name: clang-format check
run: find src/ tests/ -name '*.c' -o -name '*.h' | xargs clang-format --dry-run --Werror run: find src/ tests/ -name '*.c' -o -name '*.h' | xargs clang-format --dry-run --Werror
@@ -30,7 +30,7 @@ jobs:
needs: lint needs: lint
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure - name: Configure
run: cmake -B build -S . -DSTRICT_WARNINGS=ON run: cmake -B build -S . -DSTRICT_WARNINGS=ON
@@ -59,7 +59,7 @@ jobs:
sanitizer: [address, undefined] sanitizer: [address, undefined]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure - name: Configure
run: cmake -B build-${{ matrix.sanitizer }} -S . -DSANITIZER=${{ matrix.sanitizer }} run: cmake -B build-${{ matrix.sanitizer }} -S . -DSANITIZER=${{ matrix.sanitizer }}
@@ -77,7 +77,7 @@ jobs:
if: github.event_name == 'push' if: github.event_name == 'push'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure (clang + fuzz) - name: Configure (clang + fuzz)
run: CC=clang CXX=clang++ cmake -B build-fuzz -S . -DENABLE_FUZZ=ON run: CC=clang CXX=clang++ cmake -B build-fuzz -S . -DENABLE_FUZZ=ON
@@ -99,7 +99,7 @@ jobs:
if: github.event_name == 'push' if: github.event_name == 'push'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure - name: Configure
run: cmake -B build -S . -DENABLE_COVERAGE=ON run: cmake -B build -S . -DENABLE_COVERAGE=ON
@@ -123,7 +123,7 @@ jobs:
if: github.event_name == 'push' if: github.event_name == 'push'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure - name: Configure
run: cmake -B build -S . -DSTRICT_WARNINGS=ON run: cmake -B build -S . -DSTRICT_WARNINGS=ON
+41 -1
View File
@@ -38,11 +38,24 @@ if(ENABLE_COVERAGE)
add_link_options(--coverage) add_link_options(--coverage)
endif() endif()
# --- Build hardening option ---
# Production hardening is applied to the shipping server/client binaries only,
# and only when no sanitizer or coverage instrumentation is active: sanitizers
# carry their own instrumentation, and _FORTIFY_SOURCE requires an optimising
# build (never the -O0 used for coverage).
option(ENABLE_HARDENING "Enable compiler/linker hardening for production targets" ON)
set(HARDENING_ACTIVE OFF)
if(ENABLE_HARDENING AND SANITIZER STREQUAL "none" AND NOT ENABLE_COVERAGE)
set(HARDENING_ACTIVE ON)
endif()
include(FetchContent) include(FetchContent)
FetchContent_Declare( FetchContent_Declare(
xxhash xxhash
GIT_REPOSITORY https://github.com/Cyan4973/xxHash GIT_REPOSITORY https://github.com/Cyan4973/xxHash
GIT_TAG v0.8.3 # v0.8.3 is a lightweight tag pointing at this exact commit (no ^{} peel
# entry); pin the commit SHA instead of the mutable tag.
GIT_TAG e626a72bc2321cd320e953a0ccf1584cad60f363 # v0.8.3
SOURCE_SUBDIR cmake_unofficial SOURCE_SUBDIR cmake_unofficial
) )
FetchContent_MakeAvailable(xxhash) FetchContent_MakeAvailable(xxhash)
@@ -73,6 +86,33 @@ add_executable(client ${CLIENT_SRCS} ${SHARED_SRCS} ${FILE_STORE_SRCS} ${SERVER_
target_include_directories(client PRIVATE src/shared src/server src/client) target_include_directories(client PRIVATE src/shared src/server src/client)
target_link_libraries(client PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash) target_link_libraries(client PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
# --- Production hardening ---
# Each compile flag is probed so a compiler/architecture that lacks it still
# configures cleanly. _FORTIFY_SOURCE is guarded separately because it only
# works in an optimising build. xxHash is a static archive built by
# FetchContent, so it must be position-independent for the -pie link.
if(HARDENING_ACTIVE)
set_target_properties(xxhash PROPERTIES POSITION_INDEPENDENT_CODE ON)
include(CheckCCompilerFlag)
foreach(flag -fstack-protector-strong -fstack-clash-protection -fPIE)
string(MAKE_C_IDENTIFIER "HARDEN_${flag}" _harden_var)
check_c_compiler_flag("${flag}" ${_harden_var})
endforeach()
check_c_compiler_flag("-D_FORTIFY_SOURCE=2" HARDEN_FORTIFY_SOURCE)
foreach(target server client)
foreach(flag -fstack-protector-strong -fstack-clash-protection -fPIE)
string(MAKE_C_IDENTIFIER "HARDEN_${flag}" _harden_var)
if(${_harden_var})
target_compile_options(${target} PRIVATE ${flag})
endif()
endforeach()
if(HARDEN_FORTIFY_SOURCE)
target_compile_options(${target} PRIVATE -D_FORTIFY_SOURCE=2)
endif()
target_link_options(${target} PRIVATE -pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack)
endforeach()
endif()
# --- Testing --- # --- Testing ---
enable_testing() enable_testing()
+740 -375
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -338,9 +338,10 @@ static bool basis_oversize_preflight(const Config* config) {
return false; return false;
DirectoryScanner* scanner = DirectoryScanner* scanner =
directory_scanner_create_with_options(config->send_directory, &prepared.options); directory_scanner_create_with_options(config->send_directory, &prepared.options);
if (!scanner) {
prepared_scanner_destroy(&prepared); prepared_scanner_destroy(&prepared);
if (!scanner)
return false; return false;
}
bool ok = true; bool ok = true;
Chunk* chunk; Chunk* chunk;
while ((chunk = directory_scanner_next(scanner)) != NULL) { while ((chunk = directory_scanner_next(scanner)) != NULL) {
@@ -364,7 +365,10 @@ static bool basis_oversize_preflight(const Config* config) {
} }
if (directory_scanner_failed(scanner) || directory_scanner_had_io_error(scanner)) if (directory_scanner_failed(scanner) || directory_scanner_had_io_error(scanner))
ok = false; ok = false;
/* The scanner borrows prepared.options' base_filters/hardlinks pointers, so
prepared must outlive the scanner. */
directory_scanner_destroy(scanner); directory_scanner_destroy(scanner);
prepared_scanner_destroy(&prepared);
return ok; return ok;
} }
@@ -1886,6 +1890,8 @@ int send_files(Config* config) {
if (config->transport == TRANSPORT_TCP) if (config->transport == TRANSPORT_TCP)
log_message(LOG_LEVEL_ERROR, "could not connect to server%s", log_message(LOG_LEVEL_ERROR, "could not connect to server%s",
config->use_tls ? " via TLS" : ""); config->use_tls ? " via TLS" : "");
if (missing_args)
array_list_delete(missing_args);
return 1; return 1;
} }
ProtocolSession session; ProtocolSession session;
+6 -2
View File
@@ -587,12 +587,16 @@ void directory_scanner_destroy(DirectoryScanner* scanner) {
static Chunk* chunk_data_to_chunk(ArrayList* chunk_data) { static Chunk* chunk_data_to_chunk(ArrayList* chunk_data) {
void** chunk_items = array_list_to_array(chunk_data); void** chunk_items = array_list_to_array(chunk_data);
if (!chunk_items) if (!chunk_items) {
array_list_delete(chunk_data);
return NULL; return NULL;
}
Chunk* chunk = chunk_create((File**)chunk_items, chunk_data->size); Chunk* chunk = chunk_create((File**)chunk_items, chunk_data->size);
free(chunk_items); free(chunk_items);
if (!chunk) if (!chunk) {
array_list_delete(chunk_data);
return NULL; return NULL;
}
chunk_data->item_destroyer = NULL; chunk_data->item_destroyer = NULL;
array_list_delete(chunk_data); array_list_delete(chunk_data);
return chunk; return chunk;
+1 -1
View File
@@ -353,7 +353,7 @@ static bool receiver_save_file(File* file, void* context_pointer) {
metadata now and apply it at the end. -O/--omit-dir-times is honored by metadata now and apply it at the end. -O/--omit-dir-times is honored by
dir_time_list_apply's caller (see receiver_send_success_frame). */ dir_time_list_apply's caller (see receiver_send_success_frame). */
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata && if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
context->config->use_metadata && !context->config->omit_dir_times && dir_times_should_capture(context->config) &&
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) { !dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
file_destroy(file); file_destroy(file);
return false; return false;
+233 -189
View File
@@ -23,6 +23,7 @@
#include <string.h> #include <string.h>
#include <unistd.h> #include <unistd.h>
#include <errno.h> #include <errno.h>
#include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <openssl/x509.h> #include <openssl/x509.h>
@@ -251,6 +252,155 @@ static bool configure_authorization(const char* root) {
return true; return true;
} }
/* Discriminates the outcome of the A7 auth gate so the dispatcher can map it
* back to the config_receive_with_validate contract: accepted (including
* "module needs no auth"), a config-level refusal carrying an error string, or
* a handshake that already wrote its own terminal status frame. */
typedef enum {
MODULE_AUTH_ACCEPTED = 0,
MODULE_AUTH_REFUSED,
MODULE_AUTH_TERMINATED,
} ModuleAuthResult;
/* Looks up the daemon module selected by the client's config frame and rejects
* a `read only` one (every FastSync network transfer writes; there is no
* read-only wire operation yet). Returns the module, or NULL with *error set
* to the caller-facing rejection message. */
static const DaemonModule* module_gate_lookup_module(const Config* config, const char** error) {
const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module);
if (module == NULL) {
char* escaped_module = output_escape(config->module, config->eight_bit_output);
log_message(LOG_LEVEL_ERROR, "unknown daemon module '%s' requested",
escaped_module ? escaped_module : "<allocation failed>");
free(escaped_module);
*error = "requested daemon module does not exist";
return NULL;
}
if (module->read_only) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer",
config->module);
*error = "requested daemon module is read only";
return NULL;
}
return module;
}
/* Per-module client-chosen ownership / super-user policy (P7 Wave E hardening):
* a daemon module refuses EVERY ownership-affecting request (--numeric-ids,
* --chown, --usermap/--groupmap, --fake-super, --copy-as, explicit --super)
* unless the operator opted THIS module in with `client owner = yes`.
* Otherwise any client could force arbitrary ownership inside the module root.
* The ownership check is evaluated against the ORIGINAL config so an explicit
* --super is refused even when an operator --no-super veto already forced the
* effective copy to OFF (the veto must not silently convert a refusal into an
* accept); when no ownership flag is present, super-user DEVICE activities are
* forced off for this connection instead. Returns an error string on refusal,
* NULL on acceptance. */
static const char* module_gate_check_ownership(const Config* config, const DaemonModule* module,
ModuleGateContext* gate_ctx) {
if (module->client_owner)
return NULL;
/* Ownership: refuse the whole transfer up front (a clear failure). */
if (identity_ownership_requested(config)) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' refuses client-chosen ownership/super-user activities "
"(no `client owner = yes` opt-in); refusing",
config->module);
return "client-chosen ownership is not permitted by this daemon module";
}
/* Super-user DEVICE activities (char/block mknod and --write-devices) are
permitted under the default AUTO mode, so without this override a root
daemon would still let a non-opted module create arbitrary device nodes
and write raw devices. Force them off for this connection: those entries
are skipped (never mknod'ed) while an ordinary `-a` push still succeeds
without device nodes, matching the operator's least-privilege choice.
The operator-level --no-super veto is already folded into this. */
if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF;
return NULL;
}
/* A7 auth gate: runs the SCRAM challenge/response for an auth-required module
* BEFORE the module root is installed and before any data moves. Returns
* MODULE_AUTH_ACCEPTED when the module needs no auth or the handshake succeeds,
* MODULE_AUTH_REFUSED with *error set on a config-level rejection, or
* MODULE_AUTH_TERMINATED when the handshake already wrote a terminal status. */
static ModuleAuthResult module_gate_authenticate(const Config* config, const DaemonModule* module,
ModuleGateContext* gate_ctx, const char** error) {
if (module->auth_user_count == 0)
return MODULE_AUTH_ACCEPTED;
/* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR). */
if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication but no credential store is "
"configured (--password-file/--early-input); refusing",
config->module);
*error = "requested daemon module requires authentication and no credential "
"store is configured";
return MODULE_AUTH_REFUSED;
}
/* Transport policy (A7-3/S1): an auth-required module only accepts
* credentials over (a) an encrypted, verified TLS connection whose client
* certificate matches --client-cn, or (b) an actual PLAINTEXT connection
* from a loopback peer that the operator explicitly opted into with
* --allow-unauthenticated. A remote plaintext peer, an un-flagged loopback
* plaintext peer, and a loopback TLS peer whose certificate does not match
* --client-cn are all refused HERE, before the challenge is sent, so an
* unverified client never receives a nonce: the loopback allowance requires
* !gate_ctx->ssl, so --tls + --allow-unauthenticated can never be used to
* bypass the client-CN check. The operator flag never permits REMOTE
* plaintext auth: remote peers still require verified TLS regardless. */
bool tls_ok = gate_ctx && gate_ctx->ssl && SSL_get_verify_result(gate_ctx->ssl) == X509_V_OK &&
tls_client_identity_allowed(gate_ctx->ssl);
bool local_ok = allow_unauthenticated && gate_ctx && !gate_ctx->ssl && gate_ctx->fd >= 0 &&
utils_fd_peer_is_local(gate_ctx->fd);
if (!tls_ok && !local_ok) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication over an encrypted, verified TLS "
"connection (or an opted-in loopback plaintext transport); refusing",
config->module);
*error = "daemon module requires authentication over an encrypted, verified TLS "
"connection";
return MODULE_AUTH_REFUSED;
}
/* Belt-and-braces: the transport policy above already guarantees a context
* with a usable socket (verified TLS implies a live SSL object and loopback
* allowance requires gate_ctx->fd >= 0), so this is unreachable today; keep
* the guard so the handshake can never be driven over an invalid fd. */
if (!gate_ctx || gate_ctx->fd < 0) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available", config->module);
*error = "authentication failed for the requested daemon module";
return MODULE_AUTH_REFUSED;
}
/* The handshake writes exactly one terminal status on failure and signals so
* via MODULE_AUTH_TERMINATED; the username may be logged (never the password
* or any derived proof). */
if (!server_auth_handshake(gate_ctx->fd, config, module)) {
char* escaped_user =
config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL;
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
config->module, escaped_user ? escaped_user : "(none)");
free(escaped_user);
return MODULE_AUTH_TERMINATED;
}
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module,
escaped_user ? escaped_user : "<allocation failed>");
free(escaped_user);
return MODULE_AUTH_ACCEPTED;
}
/* Installs the module's configured path as the connection's authorized root.
* Returns an error string when the root is unusable, NULL on success. */
static const char* module_gate_install_root(const Config* config, const DaemonModule* module) {
if (!configure_authorization(module->path)) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' path '%s' is not usable", config->module,
module->path ? module->path : "(null)");
return "requested daemon module root is not usable";
}
return NULL;
}
/* Config-frame gate (runs inside config_receive_with_validate, BEFORE the /* Config-frame gate (runs inside config_receive_with_validate, BEFORE the
* STATUS_OK ack, so a rejected connection is refused at the config handshake * STATUS_OK ack, so a rejected connection is refused at the config handshake
* and no file data is ever exchanged). * and no file data is ever exchanged).
@@ -324,115 +474,23 @@ static const char* server_module_gate(const Config* config, void* context) {
return "daemon connection did not select a module (expected a " return "daemon connection did not select a module (expected a "
"host::module/path destination)"; "host::module/path destination)";
const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module); const char* error = NULL;
if (module == NULL) { const DaemonModule* module = module_gate_lookup_module(config, &error);
char* escaped_module = output_escape(config->module, config->eight_bit_output); if (!module)
log_message(LOG_LEVEL_ERROR, "unknown daemon module '%s' requested", return error;
escaped_module ? escaped_module : "<allocation failed>"); error = module_gate_check_ownership(config, module, gate_ctx);
free(escaped_module); if (error)
return "requested daemon module does not exist"; return error;
} switch (module_gate_authenticate(config, module, gate_ctx, &error)) {
if (module->read_only) { case MODULE_AUTH_REFUSED:
log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer", return error;
config->module); case MODULE_AUTH_TERMINATED:
return "requested daemon module is read only";
}
/* Client-chosen ownership / super-user policy (P7 Wave E hardening): a daemon
module refuses EVERY ownership-affecting request (--numeric-ids, --chown,
--usermap/--groupmap, --fake-super, --copy-as, explicit --super) unless the
operator opted THIS module in with `client owner = yes`. Otherwise any
client could force arbitrary ownership inside the module root. The
standalone/SSH server has a single operator-authorized root and keeps
honoring these. */
if (!module->client_owner) {
/* Ownership: refuse the whole transfer up front (a clear failure).
Evaluated against the ORIGINAL config so an explicit --super is refused
even when an operator --no-super veto already forced the effective copy
to OFF (the veto must not silently convert a refusal into an accept). */
if (identity_ownership_requested(config)) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' refuses client-chosen ownership/super-user activities "
"(no `client owner = yes` opt-in); refusing",
config->module);
return "client-chosen ownership is not permitted by this daemon module";
}
/* Super-user DEVICE activities (char/block mknod and --write-devices) are
permitted under the default AUTO mode, so without this override a root
daemon would still let a non-opted module create arbitrary device nodes
and write raw devices. Force them off for this connection: those entries
are skipped (never mknod'ed) while an ordinary `-a` push still succeeds
without device nodes, matching the operator's least-privilege choice.
The operator-level --no-super veto is already folded into this. */
if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF;
}
if (module->auth_user_count > 0) {
/* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/
* response BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR);
* a handshake that fails before the success response writes exactly one
* STATUS_AUTH_FAILED before signalling ALREADY_TERMINATED (a failure while
* writing the success signature instead just drops the broken connection).
* The username may be logged (never the password or any derived proof). */
if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication but no credential store is "
"configured (--password-file/--early-input); refusing",
config->module);
return "requested daemon module requires authentication and no credential "
"store is configured";
}
/* Transport policy (A7-3/S1): an auth-required module only accepts
* credentials over (a) an encrypted, verified TLS connection whose client
* certificate matches --client-cn, or (b) an actual PLAINTEXT connection
* from a loopback peer that the operator explicitly opted into with
* --allow-unauthenticated. A remote plaintext peer, an un-flagged loopback
* plaintext peer, and a loopback TLS peer whose certificate does not match
* --client-cn are all refused HERE, before the challenge is sent, so an
* unverified client never receives a nonce: the loopback allowance requires
* !gate_ctx->ssl, so --tls + --allow-unauthenticated can never be used to
* bypass the client-CN check. The operator flag never permits REMOTE
* plaintext auth: remote peers still require verified TLS regardless. */
bool tls_ok = gate_ctx && gate_ctx->ssl && SSL_get_verify_result(gate_ctx->ssl) == X509_V_OK &&
tls_client_identity_allowed(gate_ctx->ssl);
bool local_ok = allow_unauthenticated && gate_ctx && !gate_ctx->ssl && gate_ctx->fd >= 0 &&
utils_fd_peer_is_local(gate_ctx->fd);
if (!tls_ok && !local_ok) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication over an encrypted, verified TLS "
"connection (or an opted-in loopback plaintext transport); refusing",
config->module);
return "daemon module requires authentication over an encrypted, verified TLS "
"connection";
}
/* Belt-and-braces: the transport policy above already guarantees a context
* with a usable socket (verified TLS implies a live SSL object and loopback
* allowance requires gate_ctx->fd >= 0), so this is unreachable today; keep
* the guard so the handshake can never be driven over an invalid fd. */
if (!gate_ctx || gate_ctx->fd < 0) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available",
config->module);
return "authentication failed for the requested daemon module";
}
if (!server_auth_handshake(gate_ctx->fd, config, module)) {
char* escaped_user =
config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL;
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
config->module, escaped_user ? escaped_user : "(none)");
free(escaped_user);
return CONFIG_VALIDATE_ALREADY_TERMINATED; return CONFIG_VALIDATE_ALREADY_TERMINATED;
case MODULE_AUTH_ACCEPTED:
break;
} }
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output); /* accepted; the authorized root is now the module's path */
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module, return module_gate_install_root(config, module);
escaped_user ? escaped_user : "<allocation failed>");
free(escaped_user);
}
if (!configure_authorization(module->path)) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' path '%s' is not usable", config->module,
module->path ? module->path : "(null)");
return "requested daemon module root is not usable";
}
return NULL; /* accepted; authorized root is now the module's path */
} }
void handler(int file_descriptor) { void handler(int file_descriptor) {
@@ -445,12 +503,16 @@ void handler(int file_descriptor) {
gate_ctx.ssl = ssl; gate_ctx.ssl = ssl;
gate_ctx.fd = file_descriptor; gate_ctx.fd = file_descriptor;
gate_ctx.super_mode_override = -1; gate_ctx.super_mode_override = -1;
Config* config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx); /* All teardown state starts empty so the single `done` epilogue is safe to
* reach from any error path (including before the config frame arrives). */
Config* config = NULL;
PipelineContextReceiver* context = NULL;
char* joined_destination = NULL;
bool charset_ready = false;
config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx);
if (config == NULL) { if (config == NULL) {
log_message(LOG_LEVEL_ERROR, "Failed to receive config"); log_message(LOG_LEVEL_ERROR, "Failed to receive config");
close(file_descriptor); goto done;
protocol_session_unbind();
return;
} }
/* Apply the super-mode veto the gate decided on (operator --no-super, or a /* Apply the super-mode veto the gate decided on (operator --no-super, or a
* daemon module without the `client owner = yes` opt-in) exactly once, so * daemon module without the `client owner = yes` opt-in) exactly once, so
@@ -458,27 +520,19 @@ void handler(int file_descriptor) {
* device-node creation) sees SUPER_MODE_OFF. The gate never mutated the * device-node creation) sees SUPER_MODE_OFF. The gate never mutated the
* received config. */ * received config. */
if (gate_ctx.super_mode_override != -1) if (gate_ctx.super_mode_override != -1)
config->super_mode = gate_ctx.super_mode_override; config->super_mode = (SuperMode)gate_ctx.super_mode_override;
protocol_set_8_bit_output(config->eight_bit_output); protocol_set_8_bit_output(config->eight_bit_output);
if (!authorized_root) { if (!authorized_root) {
log_message(LOG_LEVEL_ERROR, "No server-side destination root configured"); log_message(LOG_LEVEL_ERROR, "No server-side destination root configured");
config_delete(config); goto done;
close(file_descriptor);
protocol_session_unbind();
return;
} }
if (!allow_unauthenticated && ssl == NULL) { if (!allow_unauthenticated && ssl == NULL) {
log_message(LOG_LEVEL_ERROR, "Rejected unauthenticated plaintext connection"); log_message(LOG_LEVEL_ERROR, "Rejected unauthenticated plaintext connection");
config_delete(config); goto done;
close(file_descriptor);
protocol_session_unbind();
return;
} }
if (ssl && required_client_cn && !tls_client_identity_allowed(ssl)) { if (ssl && required_client_cn && !tls_client_identity_allowed(ssl)) {
log_message(LOG_LEVEL_ERROR, "Rejected TLS client with unauthorized identity"); log_message(LOG_LEVEL_ERROR, "Rejected TLS client with unauthorized identity");
config_delete(config); goto done;
close(file_descriptor);
return;
} }
/* Daemon mode: the module's root is the authorized root (installed by /* Daemon mode: the module's root is the authorized root (installed by
server_module_gate), and the client's destination is a MODULE-RELATIVE server_module_gate), and the client's destination is a MODULE-RELATIVE
@@ -488,13 +542,9 @@ void handler(int file_descriptor) {
if (g_daemon_conf && config->receive_root_directory && config->receive_root_directory[0] == '/') { if (g_daemon_conf && config->receive_root_directory && config->receive_root_directory[0] == '/') {
log_message(LOG_LEVEL_ERROR, "Rejected absolute daemon destination (must be relative to the " log_message(LOG_LEVEL_ERROR, "Rejected absolute daemon destination (must be relative to the "
"selected module root)"); "selected module root)");
config_delete(config); goto done;
close(file_descriptor);
protocol_session_unbind();
return;
} }
char* destination = config->receive_root_directory; char* destination = config->receive_root_directory;
char* joined_destination = NULL;
if (destination && destination[0] != '/') if (destination && destination[0] != '/')
joined_destination = path_cat(authorized_root, destination); joined_destination = path_cat(authorized_root, destination);
if (joined_destination) if (joined_destination)
@@ -503,19 +553,16 @@ void handler(int file_descriptor) {
!path_is_within(authorized_root, destination)) { !path_is_within(authorized_root, destination)) {
log_message(LOG_LEVEL_ERROR, "Rejected destination outside authorized root"); log_message(LOG_LEVEL_ERROR, "Rejected destination outside authorized root");
free(joined_destination); free(joined_destination);
config_delete(config); joined_destination = NULL;
close(file_descriptor); goto done;
return;
} }
if (joined_destination) { if (joined_destination) {
free(config->receive_root_directory); free(config->receive_root_directory);
config->receive_root_directory = joined_destination; config->receive_root_directory = joined_destination;
joined_destination = NULL;
} }
if (!config->receive_root_directory) { if (!config->receive_root_directory) {
config_delete(config); goto done;
close(file_descriptor);
protocol_session_unbind();
return;
} }
config->use_delete = config->use_delete && allow_delete; config->use_delete = config->use_delete && allow_delete;
/* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion /* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion
@@ -524,13 +571,13 @@ void handler(int file_descriptor) {
any) may override the local charset; a spec the client is known to have any) may override the local charset; a spec the client is known to have
validated cannot fail here unless the server's override names an validated cannot fail here unless the server's override names an
unsupported charset. */ unsupported charset. */
if (config->iconv_spec && !charset_wire_init_receiver(config->iconv_spec, server_iconv_spec)) { if (config->iconv_spec) {
if (!charset_wire_init_receiver(config->iconv_spec, server_iconv_spec)) {
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
"--iconv: unsupported charset conversion requested (LOCAL[,REMOTE])"); "--iconv: unsupported charset conversion requested (LOCAL[,REMOTE])");
config_delete(config); goto done;
close(file_descriptor); }
protocol_session_unbind(); charset_ready = true;
return;
} }
/* --delete-missing-args deletes destination mirrors receiver-side, so it is /* --delete-missing-args deletes destination mirrors receiver-side, so it is
deletion and stays gated by the same --allow-delete server policy. When deletion and stays gated by the same --allow-delete server policy. When
@@ -545,10 +592,7 @@ void handler(int file_descriptor) {
log_message(LOG_LEVEL_ERROR, "destination root is not available: %s", log_message(LOG_LEVEL_ERROR, "destination root is not available: %s",
escaped_root ? escaped_root : "<allocation failed>"); escaped_root ? escaped_root : "<allocation failed>");
free(escaped_root); free(escaped_root);
config_delete(config); goto done;
close(file_descriptor);
protocol_session_unbind();
return;
} }
/* A --delay-updates transfer stages under a private 0700 directory inside /* A --delay-updates transfer stages under a private 0700 directory inside
the receive root. Create it up front (wiping leftovers of any previously the receive root. Create it up front (wiping leftovers of any previously
@@ -557,11 +601,7 @@ void handler(int file_descriptor) {
config->delay_context = delay_updates_context_create(config->receive_root_directory); config->delay_context = delay_updates_context_create(config->receive_root_directory);
if (!config->delay_context || !delay_updates_prepare(config->delay_context)) { if (!config->delay_context || !delay_updates_prepare(config->delay_context)) {
log_message(LOG_LEVEL_ERROR, "Failed to initialize --delay-updates staging area"); log_message(LOG_LEVEL_ERROR, "Failed to initialize --delay-updates staging area");
delay_updates_cleanup(config->delay_context); goto done;
config_delete(config);
close(file_descriptor);
protocol_session_unbind();
return;
} }
} }
/* Preserve the negotiated identity policy for the fd-relative ownership /* Preserve the negotiated identity policy for the fd-relative ownership
@@ -571,10 +611,7 @@ void handler(int file_descriptor) {
rather than silently applying the wrong ownership policy. */ rather than silently applying the wrong ownership policy. */
if (!identity_set_active(config)) { if (!identity_set_active(config)) {
log_message(LOG_LEVEL_ERROR, "Failed to activate identity policy"); log_message(LOG_LEVEL_ERROR, "Failed to activate identity policy");
config_delete(config); goto done;
close(file_descriptor);
protocol_session_unbind();
return;
} }
/* Persist the negotiated --keep-dirlinks policy once, here at config-accept, /* Persist the negotiated --keep-dirlinks policy once, here at config-accept,
before any multithreaded receiver/writer threads are spawned, so the before any multithreaded receiver/writer threads are spawned, so the
@@ -605,38 +642,25 @@ void handler(int file_descriptor) {
if (!motd_send(file_descriptor, motd ? motd : "")) { if (!motd_send(file_descriptor, motd ? motd : "")) {
free(motd); free(motd);
log_message(LOG_LEVEL_ERROR, "Failed to send daemon MOTD"); log_message(LOG_LEVEL_ERROR, "Failed to send daemon MOTD");
config_delete(config); goto done;
close(file_descriptor);
protocol_session_unbind();
identity_clear_active();
return;
} }
free(motd); free(motd);
} }
if (config->use_multithreading) { if (config->use_multithreading) {
Queue* q = queue_create(100, file_destroy); Queue* q = queue_create(100, file_destroy);
if (q == NULL) { if (q == NULL)
config_delete(config); goto done;
close(file_descriptor); context = pipeline_context_receiver_create(config, q, file_descriptor, ssl);
protocol_session_unbind();
identity_clear_active();
return;
}
PipelineContextReceiver* context =
pipeline_context_receiver_create(config, q, file_descriptor, ssl);
if (context == NULL) { if (context == NULL) {
queue_destroy(q); queue_destroy(q);
config_delete(config); goto done;
close(file_descriptor);
protocol_session_unbind();
identity_clear_active();
return;
} }
protocol_session_set_max_alloc(&context->session, config->max_alloc); protocol_session_set_max_alloc(&context->session, config->max_alloc);
atomic_store(&context->session.total_allocated_bytes, atomic_store(&context->session.total_allocated_bytes,
atomic_load(&session.total_allocated_bytes)); atomic_load(&session.total_allocated_bytes));
pipeline_context_receiver_set_queue_byte_limit(context, RECEIVER_QUEUE_MAX_BYTES); pipeline_context_receiver_set_queue_byte_limit(context, RECEIVER_QUEUE_MAX_BYTES);
thrd_t receiver, writer; thrd_t receiver = {0};
thrd_t writer = {0};
bool receiver_created = thrd_create(&receiver, receive_thread, context) == thrd_success; bool receiver_created = thrd_create(&receiver, receive_thread, context) == thrd_success;
bool writer_created = false; bool writer_created = false;
if (receiver_created) if (receiver_created)
@@ -649,17 +673,19 @@ void handler(int file_descriptor) {
cnd_broadcast(&context->condition_not_full); cnd_broadcast(&context->condition_not_full);
cnd_broadcast(&context->condition_not_empty); cnd_broadcast(&context->condition_not_empty);
mtx_unlock(&context->mutex); mtx_unlock(&context->mutex);
close(file_descriptor); /* Unblock a worker parked in socket I/O without closing the fd (the
* child owns the single close). shutdown() only affects sockets; for
* the --stdio pipe the receiver's per-message poll timeout still
* bounds the join, so do nothing there rather than close a descriptor
* another thread may still be using. */
struct stat fd_stat;
if (fstat(file_descriptor, &fd_stat) == 0 && S_ISSOCK(fd_stat.st_mode))
shutdown(file_descriptor, SHUT_RDWR);
thrd_join(receiver, NULL); thrd_join(receiver, NULL);
} else {
close(file_descriptor);
} }
if (writer_created) if (writer_created)
thrd_join(writer, NULL); thrd_join(writer, NULL);
pipeline_context_receiver_destroy(context); goto done;
protocol_session_unbind();
identity_clear_active();
return;
} }
int receiver_result; int receiver_result;
int writer_result; int writer_result;
@@ -703,21 +729,36 @@ void handler(int file_descriptor) {
} else { } else {
send_status(file_descriptor, STATUS_ERROR); send_status(file_descriptor, STATUS_ERROR);
} }
if (!transfer_ok) { if (!transfer_ok)
log_message(LOG_LEVEL_ERROR, "Transfer failed"); log_message(LOG_LEVEL_ERROR, "Transfer failed");
if (config->delay_updates && config->delay_context)
delay_updates_cleanup(config->delay_context);
}
pipeline_context_receiver_destroy(context);
} else { } else {
if (receiver_receive_files(config, file_descriptor) != 0) if (receiver_receive_files(config, file_descriptor) != 0)
log_message(LOG_LEVEL_ERROR, "Transfer failed"); log_message(LOG_LEVEL_ERROR, "Transfer failed");
config_delete(config);
} }
protocol_session_unbind();
identity_clear_active(); done:
/* Single cleanup epilogue: every error path jumps here, so the iconv
* receiver conversion is released, the identity snapshot cleared, the
* protocol session unbound and the config freed exactly once. The
* connection fd is deliberately NOT closed here -- the child functions own
* its single close (plain_child_fn / tls_child_fn), and the --stdio call
* site must leave stdin/stdout open. */
if (charset_ready)
charset_wire_free(); charset_wire_free();
close(file_descriptor); /* The delay-updates staging tree is released by config_delete (which the
branch below always reaches), so it is cleaned exactly once. */
identity_clear_active();
protocol_session_unbind();
if (context != NULL) {
/* context owns both the config and the queue it was created with. */
pipeline_context_receiver_destroy(context);
context = NULL;
config = NULL;
} else {
config_delete(config);
config = NULL;
}
free(joined_destination);
} }
#ifndef FASTSYNC_SERVER_AS_LIB #ifndef FASTSYNC_SERVER_AS_LIB
@@ -912,6 +953,9 @@ int main(int argc, char* argv[]) {
return 1; return 1;
} }
io_set_fds(STDIN_FILENO, STDOUT_FILENO); io_set_fds(STDIN_FILENO, STDOUT_FILENO);
/* handler() does not own the stdio fds: it never closes its descriptor
* argument, so STDIN/STDOUT stay open for this (single-shot) SSH session
* and are released by process exit. */
handler(STDIN_FILENO); handler(STDIN_FILENO);
release_authorization(); release_authorization();
server_cli_options_free(&opts); server_cli_options_free(&opts);
+3
View File
@@ -1,6 +1,7 @@
#include "log.h" #include "log.h"
#include "array_list.h" #include "array_list.h"
#include "protocol.h" #include "protocol.h"
#include <limits.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -39,6 +40,8 @@ void array_list_delete(ArrayList* array_list) {
static bool array_list_extend(ArrayList* array_list) { static bool array_list_extend(ArrayList* array_list) {
if (array_list == NULL) if (array_list == NULL)
return false; return false;
if (array_list->capacity > INT_MAX / 2)
return false;
int new_capacity = array_list->capacity * 2; int new_capacity = array_list->capacity * 2;
if (new_capacity == 0) if (new_capacity == 0)
new_capacity = INITIAL_ARRAY_SIZE; new_capacity = INITIAL_ARRAY_SIZE;
+85 -36
View File
@@ -202,6 +202,51 @@ static bool receive_wire_bool(int fd, bool* value) {
return true; return true;
} }
/* Cumulative budget for the strings retained by one received Config (see
* MAX_CONFIG_STRING_BYTES). Config strings are received once per connection
* before authentication and live for its whole lifetime, so the charge is never
* released. */
typedef struct {
unsigned long long used;
} ConfigStringBudget;
/* Charge `bytes` (the retained allocation: string body plus NUL) against the
* aggregate config-string budget. Returns false when the ceiling would be
* exceeded, letting the caller reject the frame with a clear error instead of
* retaining unbounded pre-auth memory. */
static bool config_string_budget_charge(ConfigStringBudget* budget, size_t bytes) {
if ((unsigned long long)bytes > MAX_CONFIG_STRING_BYTES ||
budget->used > MAX_CONFIG_STRING_BYTES - (unsigned long long)bytes) {
log_message(LOG_LEVEL_ERROR, "Config string budget exceeded (%llu + %zu > %llu bytes)",
budget->used, bytes, (unsigned long long)MAX_CONFIG_STRING_BYTES);
return false;
}
budget->used += (unsigned long long)bytes;
return true;
}
static char* config_receive_str(int fd, ConfigStringBudget* budget) {
char* value = receive_str(fd);
if (!value)
return NULL;
if (!config_string_budget_charge(budget, strlen(value) + 1)) {
free(value);
return NULL;
}
return value;
}
static char* config_receive_str_redacted(int fd, ConfigStringBudget* budget) {
char* value = receive_str_redacted(fd);
if (!value)
return NULL;
if (!config_string_budget_charge(budget, strlen(value) + 1)) {
free(value);
return NULL;
}
return value;
}
static bool validate_received_config(const Config* config) { static bool validate_received_config(const Config* config) {
return valid_wire_bool(config->save_to_disk) && valid_wire_bool(config->use_multithreading) && return valid_wire_bool(config->save_to_disk) && valid_wire_bool(config->use_multithreading) &&
valid_wire_bool(config->use_chunk_serialization) && valid_wire_bool(config->use_chunk_serialization) &&
@@ -257,7 +302,7 @@ static bool validate_received_config(const Config* config) {
config->delta_block_size <= DELTA_BLOCK_SIZE_MAX && config->delta_block_size <= DELTA_BLOCK_SIZE_MAX &&
config->delta_max_file_size <= DELTA_MAX_FILE_SIZE && config->modify_window >= 0 && config->delta_max_file_size <= DELTA_MAX_FILE_SIZE && config->modify_window >= 0 &&
config->max_delete >= -1 && config->skip_compress_count >= 0 && config->max_delete >= -1 && config->skip_compress_count >= 0 &&
config->skip_compress_count <= 10000 && config->max_alloc > 0 && config->skip_compress_count <= MAX_SKIP_COMPRESS_SUFFIXES && config->max_alloc > 0 &&
(!config->chmod_spec || !*config->chmod_spec || (!config->chmod_spec || !*config->chmod_spec ||
chmod_apply(0, config->chmod_spec, &(mode_t){0})) && chmod_apply(0, config->chmod_spec, &(mode_t){0})) &&
/* The received --iconv CONVERT_SPEC is untrusted input that drives /* The received --iconv CONVERT_SPEC is untrusted input that drives
@@ -648,6 +693,9 @@ void config_delete(Config* config) {
if (config == NULL) if (config == NULL)
return; return;
if (config->log_file) { if (config->log_file) {
/* The logging subsystem borrows this FILE*; detach it before closing so a
* concurrent log call can never touch the freed handle. */
log_set_file(NULL);
fclose(config->log_file); fclose(config->log_file);
config->log_file = NULL; config->log_file = NULL;
} }
@@ -819,7 +867,7 @@ static bool send_checksum_options(int fd, const Config* c) {
send_n_data(fd, &c->checksum_seed, sizeof(c->checksum_seed)); send_n_data(fd, &c->checksum_seed, sizeof(c->checksum_seed));
} }
static bool receive_core_fields(int fd, Config* c) { static bool receive_core_fields(int fd, Config* c, ConfigStringBudget* budget) {
int value; int value;
if (!receive_wire_bool(fd, &c->eight_bit_output)) if (!receive_wire_bool(fd, &c->eight_bit_output))
return false; return false;
@@ -829,8 +877,8 @@ static bool receive_core_fields(int fd, Config* c) {
if (c->max_alloc > MAX_SERVER_ALLOC) if (c->max_alloc > MAX_SERVER_ALLOC)
c->max_alloc = MAX_SERVER_ALLOC; c->max_alloc = MAX_SERVER_ALLOC;
protocol_session_set_max_alloc(NULL, c->max_alloc); protocol_session_set_max_alloc(NULL, c->max_alloc);
c->send_directory = receive_str(fd); c->send_directory = config_receive_str(fd, budget);
c->receive_root_directory = receive_str(fd); c->receive_root_directory = config_receive_str(fd, budget);
if (!c->send_directory || !c->receive_root_directory) if (!c->send_directory || !c->receive_root_directory)
return false; return false;
if (!receive_wire_bool(fd, &c->save_to_disk) || !receive_wire_bool(fd, &c->use_multithreading) || if (!receive_wire_bool(fd, &c->save_to_disk) || !receive_wire_bool(fd, &c->use_multithreading) ||
@@ -863,10 +911,10 @@ static bool receive_delta_fields(int fd, Config* c) {
receive_n_data(fd, &c->delta_max_file_size, sizeof(unsigned long long)); receive_n_data(fd, &c->delta_max_file_size, sizeof(unsigned long long));
} }
static bool receive_file_options(int fd, Config* c) { static bool receive_file_options(int fd, Config* c, ConfigStringBudget* budget) {
if (!receive_wire_bool(fd, &c->backup)) if (!receive_wire_bool(fd, &c->backup))
return false; return false;
char* backup_dir = receive_str(fd); char* backup_dir = config_receive_str(fd, budget);
if (!backup_dir) if (!backup_dir)
return false; return false;
if (*backup_dir != '\0') { if (*backup_dir != '\0') {
@@ -920,8 +968,8 @@ static bool receive_selection_options(int fd, Config* c) {
return receive_wire_bool(fd, &c->delete_delay); return receive_wire_bool(fd, &c->delete_delay);
} }
static bool receive_resume_options(int fd, Config* c) { static bool receive_resume_options(int fd, Config* c, ConfigStringBudget* budget) {
char* temp_dir = receive_str(fd); char* temp_dir = config_receive_str(fd, budget);
if (!temp_dir) if (!temp_dir)
return false; return false;
if (*temp_dir != '\0') { if (*temp_dir != '\0') {
@@ -935,7 +983,7 @@ static bool receive_resume_options(int fd, Config* c) {
string for "unset". Canonicalize the empty wire value back to NULL so string for "unset". Canonicalize the empty wire value back to NULL so
receivers observe exactly what the client configured (plain --backup, for receivers observe exactly what the client configured (plain --backup, for
example, must not look like --backup-dir ""). */ example, must not look like --backup-dir ""). */
char* partial_dir = receive_str(fd); char* partial_dir = config_receive_str(fd, budget);
if (!partial_dir) if (!partial_dir)
return false; return false;
if (*partial_dir != '\0') { if (*partial_dir != '\0') {
@@ -943,7 +991,7 @@ static bool receive_resume_options(int fd, Config* c) {
} else { } else {
free(partial_dir); free(partial_dir);
} }
char* suffix = receive_str(fd); char* suffix = config_receive_str(fd, budget);
if (!suffix) if (!suffix)
return false; return false;
if (*suffix != '\0') { if (*suffix != '\0') {
@@ -957,20 +1005,20 @@ static bool receive_resume_options(int fd, Config* c) {
return false; return false;
if (!receive_n_data(fd, &c->modify_window, sizeof(c->modify_window))) if (!receive_n_data(fd, &c->modify_window, sizeof(c->modify_window)))
return false; return false;
c->compress_choice = receive_str(fd); c->compress_choice = config_receive_str(fd, budget);
if (!c->compress_choice) if (!c->compress_choice)
return false; return false;
c->chmod_spec = receive_str(fd); c->chmod_spec = config_receive_str(fd, budget);
if (!c->chmod_spec || !receive_wire_bool(fd, &c->skip_compress_set) || if (!c->chmod_spec || !receive_wire_bool(fd, &c->skip_compress_set) ||
!receive_int(fd, &c->skip_compress_count) || c->skip_compress_count < 0 || !receive_int(fd, &c->skip_compress_count) || c->skip_compress_count < 0 ||
c->skip_compress_count > 10000) c->skip_compress_count > MAX_SKIP_COMPRESS_SUFFIXES)
return false; return false;
if (c->skip_compress_count > 0) { if (c->skip_compress_count > 0) {
c->skip_compress_suffixes = calloc((size_t)c->skip_compress_count, sizeof(char*)); c->skip_compress_suffixes = calloc((size_t)c->skip_compress_count, sizeof(char*));
if (!c->skip_compress_suffixes) if (!c->skip_compress_suffixes)
return false; return false;
for (int i = 0; i < c->skip_compress_count; i++) { for (int i = 0; i < c->skip_compress_count; i++) {
c->skip_compress_suffixes[i] = receive_str(fd); c->skip_compress_suffixes[i] = config_receive_str(fd, budget);
if (!c->skip_compress_suffixes[i]) if (!c->skip_compress_suffixes[i])
return false; return false;
} }
@@ -978,7 +1026,7 @@ static bool receive_resume_options(int fd, Config* c) {
return true; return true;
} }
static bool receive_basis_options(int fd, Config* c) { static bool receive_basis_options(int fd, Config* c, ConfigStringBudget* budget) {
int count; int count;
if (!receive_int(fd, &count)) if (!receive_int(fd, &count))
return false; return false;
@@ -988,7 +1036,7 @@ static bool receive_basis_options(int fd, Config* c) {
int type; int type;
if (!receive_int(fd, &type) || type <= BASIS_DEST_NONE || type > BASIS_DEST_LINK) if (!receive_int(fd, &type) || type <= BASIS_DEST_NONE || type > BASIS_DEST_LINK)
return false; return false;
char* path = receive_str(fd); char* path = config_receive_str(fd, budget);
if (!path) if (!path)
return false; return false;
/* config_basis_append validates and canonicalizes the path; a rejected /* config_basis_append validates and canonicalizes the path; a rejected
@@ -1119,8 +1167,8 @@ static bool send_daemon_module(int fd, const Config* c) {
return send_str(fd, c->module ? c->module : ""); return send_str(fd, c->module ? c->module : "");
} }
static bool receive_daemon_module(int fd, Config* c) { static bool receive_daemon_module(int fd, Config* c, ConfigStringBudget* budget) {
char* module = receive_str(fd); char* module = config_receive_str(fd, budget);
if (!module) if (!module)
return false; return false;
/* Guard against a hostile client flooding the log with an over-long module /* Guard against a hostile client flooding the log with an over-long module
@@ -1155,14 +1203,14 @@ static bool send_daemon_auth(int fd, const Config* c) {
return send_str_redacted(fd, c->auth_user); return send_str_redacted(fd, c->auth_user);
} }
static bool receive_daemon_auth(int fd, Config* c) { static bool receive_daemon_auth(int fd, Config* c, ConfigStringBudget* budget) {
int present; int present;
if (!receive_int(fd, &present) || !valid_wire_bool(present)) if (!receive_int(fd, &present) || !valid_wire_bool(present))
return false; return false;
if (!present) if (!present)
return true; return true;
/* Redacted receive: never log the incoming username body. */ /* Redacted receive: never log the incoming username body. */
char* user = receive_str_redacted(fd); char* user = config_receive_str_redacted(fd, budget);
if (!user) if (!user)
return false; return false;
if (!credentials_username_valid(user)) { if (!credentials_username_valid(user)) {
@@ -1272,8 +1320,8 @@ static bool send_iconv_spec(int fd, const Config* c) {
return send_str(fd, c->iconv_spec ? c->iconv_spec : ""); return send_str(fd, c->iconv_spec ? c->iconv_spec : "");
} }
static bool receive_iconv_spec(int fd, Config* c) { static bool receive_iconv_spec(int fd, Config* c, ConfigStringBudget* budget) {
char* spec = receive_str(fd); char* spec = config_receive_str(fd, budget);
if (!spec) if (!spec)
return false; return false;
if (*spec == '\0') { if (*spec == '\0') {
@@ -1293,14 +1341,14 @@ static bool receive_iconv_spec(int fd, Config* c) {
* validated to the SUPER_MODE_AUTO..SUPER_MODE_OFF range (also re-checked by * validated to the SUPER_MODE_AUTO..SUPER_MODE_OFF range (also re-checked by
* validate_received_config). */ * validate_received_config). */
static bool send_privilege_options(int fd, const Config* c) { static bool send_privilege_options(int fd, const Config* c) {
return send_int(fd, c->super_mode); return send_int(fd, (int)c->super_mode);
} }
static bool receive_privilege_options(int fd, Config* c) { static bool receive_privilege_options(int fd, Config* c) {
int mode; int mode;
if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF) if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF)
return false; return false;
c->super_mode = mode; c->super_mode = (SuperMode)mode;
return true; return true;
} }
@@ -1376,47 +1424,48 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
Config* config = config_create(); Config* config = config_create();
if (!config) if (!config)
return NULL; return NULL;
ConfigStringBudget budget = {0};
free(config->version); free(config->version);
config->version = receive_str(file_descriptor); config->version = config_receive_str(file_descriptor, &budget);
if (!config->version) if (!config->version)
goto error; goto error;
if (strcmp(config->version, PROTOCOL_VERSION) != 0) { if (strcmp(config->version, PROTOCOL_VERSION) != 0) {
char* escaped_version = output_escape(config->version, false); char* escaped_version = output_escape(config->version, false);
fprintf(stderr, "Protocol version mismatch: client=%s, server=%s\n", log_message(LOG_LEVEL_ERROR, "Protocol version mismatch: client=%s, server=%s",
escaped_version ? escaped_version : "<allocation failed>", PROTOCOL_VERSION); escaped_version ? escaped_version : "<allocation failed>", PROTOCOL_VERSION);
free(escaped_version); free(escaped_version);
send_status(file_descriptor, STATUS_ERROR); send_status(file_descriptor, STATUS_ERROR);
goto error; goto error;
} }
if (!receive_core_fields(file_descriptor, config) || if (!receive_core_fields(file_descriptor, config, &budget) ||
!receive_delta_fields(file_descriptor, config) || !receive_delta_fields(file_descriptor, config) ||
!receive_file_options(file_descriptor, config) || !receive_file_options(file_descriptor, config, &budget) ||
!receive_selection_options(file_descriptor, config) || !receive_selection_options(file_descriptor, config) ||
!receive_resume_options(file_descriptor, config) || !receive_resume_options(file_descriptor, config, &budget) ||
!receive_basis_options(file_descriptor, config) || !receive_basis_options(file_descriptor, config, &budget) ||
!receive_fuzzy_option(file_descriptor, config) || !receive_fuzzy_option(file_descriptor, config) ||
!receive_checksum_options(file_descriptor, config) || !receive_checksum_options(file_descriptor, config) ||
!receive_identity_options(file_descriptor, config) || !receive_identity_options(file_descriptor, config) ||
!receive_metadata_times_options(file_descriptor, config) || !receive_metadata_times_options(file_descriptor, config) ||
!receive_symlink_trust_options(file_descriptor, config) || !receive_symlink_trust_options(file_descriptor, config) ||
!receive_phase4_xattr_options(file_descriptor, config) || !receive_phase4_xattr_options(file_descriptor, config) ||
!receive_daemon_module(file_descriptor, config) || !receive_daemon_module(file_descriptor, config, &budget) ||
!receive_daemon_auth(file_descriptor, config) || !receive_daemon_auth(file_descriptor, config, &budget) ||
!receive_iconv_spec(file_descriptor, config) || !receive_iconv_spec(file_descriptor, config, &budget) ||
!receive_privilege_options(file_descriptor, config) || !receive_privilege_options(file_descriptor, config) ||
!receive_copy_as_options(file_descriptor, config)) !receive_copy_as_options(file_descriptor, config))
goto error; goto error;
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
strcmp(config->compress_choice, "none") != 0) { strcmp(config->compress_choice, "none") != 0) {
char* escaped_choice = output_escape(config->compress_choice, config->eight_bit_output); char* escaped_choice = output_escape(config->compress_choice, config->eight_bit_output);
fprintf(stderr, "Unsupported compression choice: %s\n", log_message(LOG_LEVEL_ERROR, "Unsupported compression choice: %s",
escaped_choice ? escaped_choice : "<allocation failed>"); escaped_choice ? escaped_choice : "<allocation failed>");
free(escaped_choice); free(escaped_choice);
send_status(file_descriptor, STATUS_ERROR); send_status(file_descriptor, STATUS_ERROR);
goto error; goto error;
} }
if (!validate_received_config(config)) { if (!validate_received_config(config)) {
fprintf(stderr, "Invalid configuration received from client\n"); log_message(LOG_LEVEL_ERROR, "Invalid configuration received from client");
send_status(file_descriptor, STATUS_ERROR); send_status(file_descriptor, STATUS_ERROR);
goto error; goto error;
} }
@@ -1430,7 +1479,7 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
* the CONFIG_VALIDATE_ALREADY_TERMINATED sentinel, so no second status is * the CONFIG_VALIDATE_ALREADY_TERMINATED sentinel, so no second status is
* written. */ * written. */
if (rejection != CONFIG_VALIDATE_ALREADY_TERMINATED) { if (rejection != CONFIG_VALIDATE_ALREADY_TERMINATED) {
fprintf(stderr, "%s\n", rejection); log_message(LOG_LEVEL_ERROR, "%s", rejection);
send_status(file_descriptor, STATUS_ERROR); send_status(file_descriptor, STATUS_ERROR);
} }
goto error; goto error;
+26 -10
View File
@@ -68,6 +68,13 @@ typedef struct {
int value; /* 0/1 for booleans, byte count for SO_RCVBUF/SO_SNDBUF */ int value; /* 0/1 for booleans, byte count for SO_RCVBUF/SO_SNDBUF */
} SockOptEntry; } SockOptEntry;
/* --super / --no-super tri-state (Config->super_mode). AUTO (default) and ON
* both permit a confined super-user attempt (AUTO preserves FastSync's
* historical best-effort behavior; an unprivileged attempt is refused by the
* kernel and skipped per entry); OFF forbids the attempt even for root. See
* privilege_super_mode_permitted() in identity.h. */
typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode;
typedef struct Config { typedef struct Config {
char* version; char* version;
char* send_directory; char* send_directory;
@@ -416,7 +423,7 @@ typedef struct Config {
* as a trailing int so the receiver can enforce the policy. See * as a trailing int so the receiver can enforce the policy. See
* privilege_super_permitted() and identity_ownership_requested() in * privilege_super_permitted() and identity_ownership_requested() in
* identity.h. */ * identity.h. */
int super_mode; SuperMode super_mode;
// Receiver-side runtime staging registry for --delay-updates. Never sent // Receiver-side runtime staging registry for --delay-updates. Never sent
// over the wire and never set on the sender side. // over the wire and never set on the sender side.
@@ -636,6 +643,24 @@ typedef struct Config {
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64 #define MAX_BASIS_DIRS 64
/* Upper bound on the number of --skip-compress suffixes accepted from the wire.
* Each suffix is an independent wire string (up to MAX_STRING_SIZE = 64 KiB), so
* without this a hostile pre-auth client could otherwise retain
* skip_count * MAX_STRING_SIZE bytes on the server before authentication; 256
* covers any realistic suffix list while keeping the worst case small. */
#define MAX_SKIP_COMPRESS_SUFFIXES 256
/* Aggregate ceiling on the bytes retained by ALL strings in one received config
* frame (version, send/receive roots, backup/temp/partial/suffix, compression
* choice, chmod spec, skip-compress suffixes, basis paths, module, auth user,
* iconv spec, ...). The config frame is parsed BEFORE authentication and every
* one of these strings lives for the whole connection, so this cumulative
* (never released) budget bounds the pre-auth memory a single connection can
* pin. MAX_SKIP_COMPRESS_SUFFIXES / MAX_BASIS_DIRS bound the individual
* repeatable counts; this budget bounds their product and any single oversized
* field. */
#define MAX_CONFIG_STRING_BYTES (1ULL * 1024 * 1024)
/* Identity-mapping sentinels and bounds (see identity.h for semantics). /* Identity-mapping sentinels and bounds (see identity.h for semantics).
* IDENTITY_MATCH_ANY is a usermap/groupmap FROM '*' (matches any id); * IDENTITY_MATCH_ANY is a usermap/groupmap FROM '*' (matches any id);
* IDENTITY_CURRENT is a chown / map TO '*' (resolve to the receiver's current * IDENTITY_CURRENT is a chown / map TO '*' (resolve to the receiver's current
@@ -644,15 +669,6 @@ typedef struct Config {
#define IDENTITY_CURRENT (-1) #define IDENTITY_CURRENT (-1)
#define MAX_IDENTITY_MAP 128 #define MAX_IDENTITY_MAP 128
/* --super / --no-super tri-state (Config->super_mode). AUTO (default) and ON
* both permit a confined super-user attempt (AUTO preserves FastSync's
* historical best-effort behavior; an unprivileged attempt is refused by the
* kernel and skipped per entry); OFF forbids the attempt even for root. See
* privilege_super_mode_permitted() in identity.h. */
#define SUPER_MODE_AUTO 0
#define SUPER_MODE_ON 1
#define SUPER_MODE_OFF 2
Config* config_create(void); Config* config_create(void);
void config_delete(Config* config); void config_delete(Config* config);
+7
View File
@@ -1,4 +1,5 @@
#include "daemon_conf.h" #include "daemon_conf.h"
#include "credentials.h"
#include "utils.h" #include "utils.h"
#include <ctype.h> #include <ctype.h>
#include <errno.h> #include <errno.h>
@@ -192,6 +193,12 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
const char* user = trim_ws(token); const char* user = trim_ws(token);
if (*user == '\0') if (*user == '\0')
continue; continue;
if (!credentials_username_valid(user)) {
set_error(err, err_size, "module '%s': invalid 'auth users' entry '%s'", module->name,
user);
free(list);
return false;
}
char** grown = char** grown =
realloc(module->auth_users, (size_t)(module->auth_user_count + 1) * sizeof(char*)); realloc(module->auth_users, (size_t)(module->auth_user_count + 1) * sizeof(char*));
if (!grown) { if (!grown) {
+3 -1
View File
@@ -2,6 +2,7 @@
#include "log.h" #include "log.h"
#include "utils.h" #include "utils.h"
#include <errno.h> #include <errno.h>
#include <limits.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -51,7 +52,8 @@ static int normalize_entry(const char* raw, size_t len, bool strip_line_endings,
if (len == 0) if (len == 0)
return 0; return 0;
if (raw[0] == '/') { if (raw[0] == '/') {
snprintf(err, err_size, "absolute path entries are not allowed: '%.*s'", (int)len, raw); int print_len = len > (size_t)INT_MAX ? INT_MAX : (int)len;
snprintf(err, err_size, "absolute path entries are not allowed: '%.*s'", print_len, raw);
return -1; return -1;
} }
/* Reject NUL bytes inside a token defensively (NUL-delimited mode splits on /* Reject NUL bytes inside a token defensively (NUL-delimited mode splits on
+22 -2
View File
@@ -1696,9 +1696,9 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
return NULL; return NULL;
} }
if (has_path_traversal(check_path)) { if (check_path[0] == '\0' || has_path_traversal(check_path)) {
char* escaped_path = output_escape(check_path, log_get_8_bit_output()); char* escaped_path = output_escape(check_path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Path traversal detected: %s", log_message(LOG_LEVEL_ERROR, "Invalid received check path: %s",
escaped_path ? escaped_path : "<allocation failed>"); escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path); free(escaped_path);
free(check_path); free(check_path);
@@ -1832,6 +1832,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
existing/ignore-existing/update/backup/delay-updates policy. */ existing/ignore-existing/update/backup/delay-updates policy. */
File* materialized = file_create(check_path); File* materialized = file_create(check_path);
if (materialized && basis.content) { if (materialized && basis.content) {
data_destroy(materialized->data);
materialized->data = basis.content; materialized->data = basis.content;
basis.content = NULL; basis.content = NULL;
materialized->metadata = file_metadata_create(NULL, &basis.st, false, false); materialized->metadata = file_metadata_create(NULL, &basis.st, false, false);
@@ -2095,6 +2096,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
file->metadata = meta; file->metadata = meta;
file->xattrs = append_xattrs; file->xattrs = append_xattrs;
append_xattrs = NULL; append_xattrs = NULL;
data_destroy(file->data);
file->data = data_create(full, full_size); file->data = data_create(full, full_size);
if (!file->data) { /* data_create already freed full on failure */ if (!file->data) { /* data_create already freed full on failure */
file_destroy(file); file_destroy(file);
@@ -2236,6 +2238,10 @@ File* file_receive(const Config* config, int file_descriptor) {
/* ---- P7 Wave D: deferred directory times ---- */ /* ---- P7 Wave D: deferred directory times ---- */
bool dir_times_should_capture(const Config* config) {
return config->use_metadata && !config->omit_dir_times;
}
void dir_time_list_init(DirTimeList* list) { void dir_time_list_init(DirTimeList* list) {
if (!list) if (!list)
return; return;
@@ -2243,6 +2249,7 @@ void dir_time_list_init(DirTimeList* list) {
list->entries = NULL; list->entries = NULL;
list->count = 0; list->count = 0;
list->capacity = 0; list->capacity = 0;
list->bytes = 0;
} }
void dir_time_list_free(DirTimeList* list) { void dir_time_list_free(DirTimeList* list) {
@@ -2256,11 +2263,23 @@ void dir_time_list_free(DirTimeList* list) {
list->entries = NULL; list->entries = NULL;
list->count = 0; list->count = 0;
list->capacity = 0; list->capacity = 0;
list->bytes = 0;
} }
bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata) { bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata) {
if (!list || !wire_path || !metadata) if (!list || !wire_path || !metadata)
return true; /* nothing to remember; never a hard error */ return true; /* nothing to remember; never a hard error */
/* Cumulative, not per-frame: the sender may stream a tree across unbounded
STATUS_DIR_TIMES frames, so bound the TOTAL retained here. Reject before
touching the list, leaving it exactly as it was (the caller fails the
transfer, which becomes a clean protocol error). */
size_t path_len = strlen(wire_path);
/* Charge the whole per-entry cost (path copy + pointer slot + metadata
struct), not just the path, so the array growth is bounded by the same
cumulative budget. */
size_t entry_cost = path_len + sizeof(FileMetadata) + sizeof(char*);
if (list->count >= MAX_DIR_TIME_ENTRIES || entry_cost > MAX_DIR_TIME_BYTES - list->bytes)
return false;
if (list->count == list->capacity) { if (list->count == list->capacity) {
size_t new_capacity = list->capacity == 0 ? 16 : list->capacity * 2; size_t new_capacity = list->capacity == 0 ? 16 : list->capacity * 2;
if (new_capacity < list->capacity) if (new_capacity < list->capacity)
@@ -2287,6 +2306,7 @@ bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetad
list->paths[list->count] = copy; list->paths[list->count] = copy;
list->entries[list->count] = *metadata; list->entries[list->count] = *metadata;
list->count++; list->count++;
list->bytes += entry_cost;
return true; return true;
} }
+18 -1
View File
@@ -7,6 +7,15 @@
/* Server-side file receive/save path. */ /* Server-side file receive/save path. */
/* Cumulative caps for the deferred directory-time accumulator. The sender may
* legitimately split a large tree across repeated STATUS_DIR_TIMES frames, so a
* per-frame bound is not enough: the receiver must bound the TOTAL it retains
* against a hostile sender. Mirror the delete-manifest limits
* (MAX_MANIFEST_ENTRIES / MAX_MANIFEST_BYTES): the entry count bounds the
* metadata array and the byte budget bounds the concatenated path strings. */
#define MAX_DIR_TIME_ENTRIES (1024 * 1024)
#define MAX_DIR_TIME_BYTES (16ULL * 1024 * 1024)
File* file_receive(const Config* config, int file_descriptor); File* file_receive(const Config* config, int file_descriptor);
File* file_receive_directory(int file_descriptor, const Config* config); File* file_receive_directory(int file_descriptor, const Config* config);
File* file_receive_dir_time(int file_descriptor, const Config* config); File* file_receive_dir_time(int file_descriptor, const Config* config);
@@ -28,12 +37,20 @@ typedef struct {
FileMetadata* entries; /* owned, parallel to paths */ FileMetadata* entries; /* owned, parallel to paths */
size_t count; size_t count;
size_t capacity; size_t capacity;
size_t bytes; /* cumulative strlen of every retained path */
} DirTimeList; } DirTimeList;
/* Capture gate shared by the sender-side and receiver-side sinks: directory
* metadata is accumulated only when --times/--metadata is in effect and
* -O/--omit-dir-times does not suppress it. Kept here, next to the accumulator
* it guards, so both call sites express the same condition. */
bool dir_times_should_capture(const Config* config);
void dir_time_list_init(DirTimeList* list); void dir_time_list_init(DirTimeList* list);
void dir_time_list_free(DirTimeList* list); void dir_time_list_free(DirTimeList* list);
/* Deep-copy one directory's path + metadata into the list. Returns false on /* Deep-copy one directory's path + metadata into the list. Returns false on
* allocation failure (the caller fails the transfer). */ * allocation failure OR when the cumulative entry/byte caps would be exceeded
* (the caller fails the transfer). */
bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata); bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata);
/* Apply every accumulated directory's mtime (and atime when captured) beneath /* Apply every accumulated directory's mtime (and atime when captured) beneath
* `root_directory`, confined fd-relative. Best-effort per entry: an absent * `root_directory`, confined fd-relative. Best-effort per entry: an absent
+2 -2
View File
@@ -30,7 +30,7 @@ typedef struct {
/* --super / --no-super tri-state (SUPER_MODE_AUTO when unset). Snapshotted /* --super / --no-super tri-state (SUPER_MODE_AUTO when unset). Snapshotted
* per connection so privilege_super_permitted() can gate super-user * per connection so privilege_super_permitted() can gate super-user
* activities without a Config argument. */ * activities without a Config argument. */
int super_mode; SuperMode super_mode;
/* --copy-as=USER[:GROUP]: snapshotted so the ownership resolver can force the /* --copy-as=USER[:GROUP]: snapshotted so the ownership resolver can force the
* target ids without a Config argument. */ * target ids without a Config argument. */
bool copy_as_set; bool copy_as_set;
@@ -128,7 +128,7 @@ bool privilege_super_permitted(void) {
return privilege_super_mode_permitted(g_identity.super_mode); return privilege_super_mode_permitted(g_identity.super_mode);
} }
bool privilege_super_mode_permitted(int mode) { bool privilege_super_mode_permitted(SuperMode mode) {
/* AUTO and ON both attempt the confined operation; OFF forbids it even for a /* AUTO and ON both attempt the confined operation; OFF forbids it even for a
* root receiver. AUTO is the historical FastSync behavior (always attempt * root receiver. AUTO is the historical FastSync behavior (always attempt
* and let the kernel refuse an unprivileged call, which the caller skips), so * and let the kernel refuse an unprivileged call, which the caller skips), so
+1 -1
View File
@@ -128,6 +128,6 @@ bool identity_wire_valid(const Config* config);
* best-effort behavior where an unprivileged attempt is refused by the kernel * best-effort behavior where an unprivileged attempt is refused by the kernel
* and skipped. Neither EVER elevates privileges. */ * and skipped. Neither EVER elevates privileges. */
bool privilege_super_permitted(void); bool privilege_super_permitted(void);
bool privilege_super_mode_permitted(int mode); bool privilege_super_mode_permitted(SuperMode mode);
#endif #endif
+72 -26
View File
@@ -3,7 +3,9 @@
#include <stdbool.h> #include <stdbool.h>
#include <stdarg.h> #include <stdarg.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#include <threads.h>
#include <time.h> #include <time.h>
static const char* log_level_strings[] = {"DEBUG", "INFO", "WARN", "ERROR"}; static const char* log_level_strings[] = {"DEBUG", "INFO", "WARN", "ERROR"};
@@ -15,6 +17,18 @@ static FILE* log_fp = NULL;
static _Thread_local bool eight_bit_output; static _Thread_local bool eight_bit_output;
static LogStderrMode stderr_mode = LOG_STDERR_ERRORS; static LogStderrMode stderr_mode = LOG_STDERR_ERRORS;
/* Serializes access to log_fp and makes each emitted line atomic: the
* timestamp prefix, formatted body, and trailing newline are written as one
* critical section so concurrent threads cannot interleave partial lines.
* Initialized lazily (matching the protocol.c bw_mutex idiom) because logging
* can happen before main() installs any synchronization. */
static mtx_t log_mutex;
static once_flag log_mutex_once = ONCE_FLAG_INIT;
static void log_mutex_init(void) {
mtx_init(&log_mutex, mtx_plain);
}
void set_log_level(LogLevel level) { void set_log_level(LogLevel level) {
current_log_level = level; current_log_level = level;
} }
@@ -41,7 +55,10 @@ uint32_t get_log_info_flags(void) {
} }
void log_set_file(FILE* fp) { void log_set_file(FILE* fp) {
call_once(&log_mutex_once, log_mutex_init);
mtx_lock(&log_mutex);
log_fp = fp; log_fp = fp;
mtx_unlock(&log_mutex);
} }
void log_set_8_bit_output(bool enabled) { void log_set_8_bit_output(bool enabled) {
@@ -60,13 +77,48 @@ LogStderrMode log_get_stderr_mode(void) {
return stderr_mode; return stderr_mode;
} }
static inline void write_message(FILE* dest_io, LogLevel log_level, struct tm t, const char* format, /* Format one complete log line (timestamp prefix + body + newline) into a
* freshly allocated buffer. This is pure CPU/malloc work and must happen
* OUTSIDE the log mutex: the mutex only guards the log_fp pointer, so a
* stalled stderr/stdout pipe cannot block every logging thread. Returns NULL
* on allocation/formatting failure. */
static char* format_log_line(LogLevel log_level, const struct tm* t, const char* format,
va_list args) { va_list args) {
fprintf(dest_io, "%04d-%02d-%02d %02d:%02d:%02d [%s]: ", t.tm_year + 1900, t.tm_mon + 1, char prefix[64];
t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, log_level_strings[log_level]); int prefix_len = snprintf(
prefix, sizeof(prefix), "%04d-%02d-%02d %02d:%02d:%02d [%s]: ", t->tm_year + 1900,
t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, log_level_strings[log_level]);
if (prefix_len < 0 || prefix_len >= (int)sizeof(prefix))
return NULL;
va_list copy;
va_copy(copy, args);
int body_len = vsnprintf(NULL, 0, format, copy);
va_end(copy);
if (body_len < 0)
return NULL;
size_t total = (size_t)prefix_len + (size_t)body_len;
char* line = malloc(total + 2); /* body bytes + '\n' + NUL */
if (!line)
return NULL;
memcpy(line, prefix, (size_t)prefix_len);
vsnprintf(line + prefix_len, (size_t)body_len + 1, format, args);
line[total] = '\n';
line[total + 1] = '\0';
return line;
}
vfprintf(dest_io, format, args); /* Write an already-formatted line to the console and, if configured, the log
fprintf(dest_io, "\n"); * file. Only the log_fp pointer is read under the mutex (so log_set_file /
* config_delete cannot free it while it is in use); the single console fputs
* runs unlocked but is internally atomic per stdio stream. */
static void emit_log_line(FILE* console, const char* line) {
fputs(line, console);
call_once(&log_mutex_once, log_mutex_init);
mtx_lock(&log_mutex);
FILE* file = log_fp;
if (file)
fputs(line, file);
mtx_unlock(&log_mutex);
} }
void log_message(LogLevel log_level, const char* format, ...) { void log_message(LogLevel log_level, const char* format, ...) {
@@ -86,14 +138,12 @@ void log_message(LogLevel log_level, const char* format, ...) {
va_list args; va_list args;
va_start(args, format); va_start(args, format);
write_message(dest_io, log_level, t, format, args); char* line = format_log_line(log_level, &t, format, args);
va_end(args); va_end(args);
if (!line)
if (log_fp) { return;
va_start(args, format); emit_log_line(dest_io, line);
write_message(log_fp, log_level, t, format, args); free(line);
va_end(args);
}
} }
void log_debug_message(LogDebugFlag flag, const char* format, ...) { void log_debug_message(LogDebugFlag flag, const char* format, ...) {
@@ -107,14 +157,12 @@ void log_debug_message(LogDebugFlag flag, const char* format, ...) {
va_list args; va_list args;
va_start(args, format); va_start(args, format);
write_message(stdout, LOG_LEVEL_DEBUG, t, format, args); char* line = format_log_line(LOG_LEVEL_DEBUG, &t, format, args);
va_end(args); va_end(args);
if (!line)
if (log_fp) { return;
va_start(args, format); emit_log_line(stdout, line);
write_message(log_fp, LOG_LEVEL_DEBUG, t, format, args); free(line);
va_end(args);
}
} }
void log_info_message(LogInfoFlag flag, const char* format, ...) { void log_info_message(LogInfoFlag flag, const char* format, ...) {
@@ -129,14 +177,12 @@ void log_info_message(LogInfoFlag flag, const char* format, ...) {
va_list args; va_list args;
va_start(args, format); va_start(args, format);
write_message(stdout, LOG_LEVEL_INFO, t, format, args); char* line = format_log_line(LOG_LEVEL_INFO, &t, format, args);
va_end(args); va_end(args);
if (!line)
if (log_fp) { return;
va_start(args, format); emit_log_line(stdout, line);
write_message(log_fp, LOG_LEVEL_INFO, t, format, args); free(line);
va_end(args);
}
} }
void log_perror(const char* context) { void log_perror(const char* context) {
+2 -1
View File
@@ -6,6 +6,7 @@
#include "config.h" #include "config.h"
#include "data.h" #include "data.h"
#include "file.h" #include "file.h"
#include "file_receive.h"
#include "log.h" #include "log.h"
#include "protocol.h" #include "protocol.h"
#include "queue.h" #include "queue.h"
@@ -331,7 +332,7 @@ int write_thread(void* pipeline_context) {
write would clobber them); accumulate the metadata here and let the write would clobber them); accumulate the metadata here and let the
caller apply it once every writer has drained. */ caller apply it once every writer has drained. */
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata && if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
context->config->use_metadata && !context->config->omit_dir_times && dir_times_should_capture(context->config) &&
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) { !dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
file_destroy(file); file_destroy(file);
pipeline_context_receiver_note_bytes_released(context, file_bytes); pipeline_context_receiver_note_bytes_released(context, file_bytes);
+1 -1
View File
@@ -128,7 +128,7 @@ char* ssh_build_remote_command(const char* server_path, bool old_args, char* con
q++; q++;
len++; len++;
} }
if (len > SIZE_MAX - q * 3 || len + q * 3 + 3 > SIZE_MAX - command_len) if (q > (SIZE_MAX - len) / 3 || len + q * 3 + 3 > SIZE_MAX - command_len)
return NULL; return NULL;
command_len += len + q * 3 + 3; command_len += len + q * 3 + 3;
} }
+13 -1
View File
@@ -152,9 +152,18 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil
log_message(LOG_LEVEL_INFO, "%s", log_fmt); log_message(LOG_LEVEL_INFO, "%s", log_fmt);
pid_t pid = fork(); pid_t pid = fork();
if (pid == 0) { if (pid == 0) {
/* Connection children must not run the parent's global cleanup(): it
* frees state (credentials / daemon conf) that the child's worker
* threads may still be reading and closes fd numbers the child could
* already have reused. Reset the inherited handlers so a signal
* terminates the child directly; SIGCHLD is reset too since a child
* must never reap the parent's children. This runs before the child
* spawns any thread, so it cannot race one. */
signal(SIGINT, SIG_DFL);
signal(SIGTERM, SIG_DFL);
signal(SIGCHLD, SIG_DFL);
close(server->file_descriptor); close(server->file_descriptor);
child_fn(fd, child_ctx); child_fn(fd, child_ctx);
close(fd);
_exit(0); _exit(0);
} else if (pid > 0) { } else if (pid > 0) {
g_active_connections++; g_active_connections++;
@@ -169,6 +178,9 @@ struct plain_ctx {
static void plain_child_fn(int fd, void* ctx) { static void plain_child_fn(int fd, void* ctx) {
((struct plain_ctx*)ctx)->handler(fd); ((struct plain_ctx*)ctx)->handler(fd);
/* handler() never closes the connection fd; the child owns its single
* close here after the handler has fully torn down. */
close(fd);
} }
bool server_listen(Server* server, void (*handler)(int file_descriptor)) { bool server_listen(Server* server, void (*handler)(int file_descriptor)) {
+17
View File
@@ -65,6 +65,18 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
} }
/* TLS 1.3 ciphersuites are configured separately from the TLS 1.2 and below
* cipher list above. Pin the three AEAD suites OpenSSL offers, dropping
* TLS_AES_128_CCM_SHA256 and the CCM_8 variant, and fail closed if the
* library rejects the policy. SSL_CTX_set_ciphersuites needs OpenSSL 1.1.1;
* earlier versions have no TLS 1.3, so the call is compile-guarded. */
#if OPENSSL_VERSION_NUMBER >= 0x10101000L
if (SSL_CTX_set_ciphersuites(
ctx, "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256") != 1) {
SSL_CTX_free(ctx);
return NULL;
}
#endif
if (cert && key) { if (cert && key) {
struct stat key_stat; struct stat key_stat;
@@ -180,13 +192,18 @@ static void tls_child_fn(int fd, void* arg) {
SSL* ssl = wrap_fd_with_ssl(fd, ctx->ssl_ctx, true, NULL); SSL* ssl = wrap_fd_with_ssl(fd, ctx->ssl_ctx, true, NULL);
if (!ssl) { if (!ssl) {
io_set_ssl(NULL); io_set_ssl(NULL);
close(fd);
return; return;
} }
io_set_ssl(ssl); io_set_ssl(ssl);
ctx->handler(fd); ctx->handler(fd);
/* Shut the TLS layer down before releasing the fd: handler() no longer
* closes it, so SSL_shutdown still has a valid socket. The child owns the
* single fd close, performed last. */
SSL_shutdown(ssl); SSL_shutdown(ssl);
SSL_free(ssl); SSL_free(ssl);
io_set_ssl(NULL); io_set_ssl(NULL);
close(fd);
} }
bool server_listen_tls(Server* server, void (*handler)(int file_descriptor)) { bool server_listen_tls(Server* server, void (*handler)(int file_descriptor)) {
+19 -1
View File
@@ -1,6 +1,7 @@
#include "test_array_list.h" #include "test_array_list.h"
#include "array_list.h" #include "array_list.h"
#include "test_utils.h" #include "test_utils.h"
#include <limits.h>
#include <stdlib.h> #include <stdlib.h>
static int destroyer_calls = 0; static int destroyer_calls = 0;
@@ -9,7 +10,7 @@ static void test_destroyer(void* item) {
free(item); free(item);
} }
void test_array_list() { static void test_array_list_basic() {
ArrayList* list = array_list_create(free); ArrayList* list = array_list_create(free);
EXPECT_NOT_NULL(list); EXPECT_NOT_NULL(list);
EXPECT_EQ_INT(list->size, 0); EXPECT_EQ_INT(list->size, 0);
@@ -54,3 +55,20 @@ void test_array_list() {
array_list_delete(list); array_list_delete(list);
EXPECT_EQ_INT(destroyer_calls, 106); EXPECT_EQ_INT(destroyer_calls, 106);
} }
/* A capacity that would overflow `capacity * 2` must be refused instead of
* wrapping into signed-overflow UB; array_list_add surfaces the failure. */
static void test_array_list_extend_overflow_guard() {
ArrayList* list = array_list_create(NULL);
EXPECT_NOT_NULL(list);
list->capacity = INT_MAX / 2 + 1;
list->size = list->capacity;
EXPECT_FALSE(array_list_add(list, NULL));
list->size = 0;
array_list_delete(list);
}
void test_array_list() {
test_array_list_basic();
test_array_list_extend_overflow_guard();
}
+86 -1
View File
@@ -6,6 +6,7 @@
#include "queue.h" #include "queue.h"
#include "test_utils.h" #include "test_utils.h"
#include "utils.h" #include "utils.h"
#include <signal.h>
#include <stdlib.h> #include <stdlib.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <string.h> #include <string.h>
@@ -1672,7 +1673,7 @@ static void test_config_receive_rejects_invalid_iconv_spec() {
static void test_config_super_mode_wire_roundtrip() { static void test_config_super_mode_wire_roundtrip() {
if (is_running_under_valgrind()) if (is_running_under_valgrind())
return; return;
int modes[] = {SUPER_MODE_AUTO, SUPER_MODE_ON, SUPER_MODE_OFF}; SuperMode modes[] = {SUPER_MODE_AUTO, SUPER_MODE_ON, SUPER_MODE_OFF};
for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) { for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) {
int p[2]; int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
@@ -1846,6 +1847,89 @@ static void test_config_receive_rejects_copy_as_without_metadata() {
config_delete(c); config_delete(c);
} }
/* Like roundtrip_config_ok, but the parent is the RECEIVER so the frame can be
rejected MID-way, before the sender finishes writing it. The sender child
ignores SIGPIPE so the receiver closing early cannot kill it; the parent
waits for the child to exit after observing the rejection. */
static bool roundtrip_config_rejected(const Config* send_cfg) {
int p[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0)
return false;
pid_t pid = fork();
if (pid == 0) {
(void)signal(SIGPIPE, SIG_IGN);
close(p[0]);
io_set_fds(p[1], p[1]);
config_send(p[1], send_cfg);
close(p[1]);
_exit(0);
}
close(p[1]);
io_set_fds(p[0], p[0]);
Config* recv = config_receive(p[0]);
bool rejected = recv == NULL;
config_delete(recv);
close(p[0]);
int status;
waitpid(pid, &status, 0);
return rejected;
}
/* Build a Config with `count` --skip-compress suffixes, each `suffix_len` bytes
long, for the pre-auth config-string budget tests. */
static Config* make_skip_compress_config(int count, size_t suffix_len) {
Config* c = config_create();
if (!c)
return NULL;
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->skip_compress_set = true;
c->skip_compress_count = count;
c->skip_compress_suffixes = calloc((size_t)count, sizeof(char*));
if (!c->skip_compress_suffixes) {
config_delete(c);
return NULL;
}
char* suffix = malloc(suffix_len + 1);
if (!suffix) {
config_delete(c);
return NULL;
}
memset(suffix, 'x', suffix_len);
suffix[suffix_len] = '\0';
for (int i = 0; i < count; i++)
c->skip_compress_suffixes[i] = str_dup(suffix);
free(suffix);
return c;
}
/* Pre-auth memory bound: one connection must not retain unbounded config
strings. An over-limit --skip-compress count is refused, and even an
in-range count cannot exceed the aggregate per-connection string budget. */
static void test_config_receive_rejects_oversized_string_budget() {
if (is_running_under_valgrind())
return;
/* Exactly MAX_SKIP_COMPRESS_SUFFIXES tiny suffixes are accepted. */
Config* ok = make_skip_compress_config(MAX_SKIP_COMPRESS_SUFFIXES, 1);
EXPECT_NOT_NULL(ok);
EXPECT_TRUE(roundtrip_config_ok(ok));
config_delete(ok);
/* One suffix over the count cap is rejected before any suffix is read. */
Config* over_count = make_skip_compress_config(MAX_SKIP_COMPRESS_SUFFIXES + 1, 1);
EXPECT_NOT_NULL(over_count);
EXPECT_TRUE(roundtrip_config_rejected(over_count));
config_delete(over_count);
/* In-range count, but the strings together exceed MAX_CONFIG_STRING_BYTES
(64 suffixes * ~64 KiB > 1 MiB), so the aggregate budget rejects it. */
Config* over_bytes = make_skip_compress_config(64, MAX_STRING_SIZE - 1);
EXPECT_NOT_NULL(over_bytes);
EXPECT_TRUE(roundtrip_config_rejected(over_bytes));
config_delete(over_bytes);
}
/* identity_copy_as_refused() is the pure, pre-snapshot refusal predicate: a /* identity_copy_as_refused() is the pure, pre-snapshot refusal predicate: a
--copy-as is refused when the receiver is not root OR the effective super --copy-as is refused when the receiver is not root OR the effective super
mode is OFF (an operator veto), and never when --copy-as is unset. */ mode is OFF (an operator veto), and never when --copy-as is unset. */
@@ -2009,6 +2093,7 @@ void test_config() {
test_config_copy_as_wire_roundtrip(); test_config_copy_as_wire_roundtrip();
test_config_receive_rejects_negative_copy_as(); test_config_receive_rejects_negative_copy_as();
test_config_receive_rejects_copy_as_without_metadata(); test_config_receive_rejects_copy_as_without_metadata();
test_config_receive_rejects_oversized_string_budget();
test_config_receive_with_validate_rejects(); test_config_receive_with_validate_rejects();
} }
test_identity_copy_as_refused(); test_identity_copy_as_refused();
+47
View File
@@ -1,4 +1,5 @@
#include "test_daemon_conf.h" #include "test_daemon_conf.h"
#include "credentials.h"
#include "daemon_conf.h" #include "daemon_conf.h"
#include "test_utils.h" #include "test_utils.h"
#include <stdio.h> #include <stdio.h>
@@ -320,6 +321,51 @@ static void test_daemon_conf_dparam_override() {
daemon_conf_free(conf); daemon_conf_free(conf);
} }
/* Each `auth users` entry is validated with the same username rule as the
* credential store, so invisible whitespace/control characters can never make
* an exact strcmp match ambiguous. */
static void test_daemon_conf_auth_users_validated() {
char* path;
char err[256];
const DaemonConf* conf;
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nauth users = alice, bad user\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "invalid 'auth users' entry") != NULL);
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nauth users = good\tbad\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "invalid 'auth users' entry") != NULL);
/* An over-long name exceeds CREDENTIAL_MAX_USER_LEN and is rejected. */
{
char body[CREDENTIAL_MAX_USER_LEN + 128];
int n = snprintf(body, sizeof(body), "[m]\npath = /x\nauth users = ");
memset(body + n, 'a', CREDENTIAL_MAX_USER_LEN + 1);
body[n + CREDENTIAL_MAX_USER_LEN + 1] = '\n';
body[n + CREDENTIAL_MAX_USER_LEN + 2] = '\0';
EXPECT_EQ_INT(write_conf(body, &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "invalid 'auth users' entry") != NULL);
}
/* Empty entries between commas are skipped, not treated as invalid. */
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nauth users = alice,, bob\n", &path), 0);
DaemonConf* ok_conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(ok_conf);
EXPECT_EQ_INT(ok_conf->modules[0].auth_user_count, 2);
EXPECT_EQ_STR(ok_conf->modules[0].auth_users[0], "alice");
EXPECT_EQ_STR(ok_conf->modules[0].auth_users[1], "bob");
daemon_conf_free(ok_conf);
}
static void test_daemon_module_name_valid() { static void test_daemon_module_name_valid() {
EXPECT_TRUE(daemon_module_name_valid("backup")); EXPECT_TRUE(daemon_module_name_valid("backup"));
EXPECT_TRUE(daemon_module_name_valid("Backup_2")); EXPECT_TRUE(daemon_module_name_valid("Backup_2"));
@@ -351,5 +397,6 @@ void test_daemon_conf() {
test_daemon_conf_missing_file_rejected(); test_daemon_conf_missing_file_rejected();
test_daemon_conf_find_module(); test_daemon_conf_find_module();
test_daemon_conf_dparam_override(); test_daemon_conf_dparam_override();
test_daemon_conf_auth_users_validated();
test_daemon_module_name_valid(); test_daemon_module_name_valid();
} }
+72
View File
@@ -1370,6 +1370,76 @@ static void test_dir_time_list() {
rmdir(root); rmdir(root);
} }
/* A hostile sender can stream unbounded STATUS_DIR_TIMES frames; the
* accumulator must bound the CUMULATIVE path bytes (not just one frame) and
* reject the add that would cross the cap, leaving the list untouched. */
static void test_dir_time_list_cap() {
DirTimeList list;
dir_time_list_init(&list);
EXPECT_EQ_INT((int)list.bytes, 0);
FileMetadata metadata = {.mtime_sec = 1, .mtime_nsec = 0};
size_t path_len = MAX_STRING_SIZE - 1;
char* path = malloc(path_len + 1);
EXPECT_NOT_NULL(path);
memset(path, 'a', path_len);
path[path_len] = '\0';
bool rejected = false;
for (size_t i = 0; i < MAX_DIR_TIME_ENTRIES + 1 && !rejected; i++) {
size_t before_count = list.count;
size_t before_bytes = list.bytes;
if (!dir_time_list_add(&list, path, &metadata)) {
rejected = true;
/* The rejected add must not have partially mutated the list. */
EXPECT_TRUE(list.count == before_count);
EXPECT_TRUE(list.bytes == before_bytes);
} else {
EXPECT_TRUE(list.count == before_count + 1);
EXPECT_TRUE(list.bytes == before_bytes + path_len + sizeof(FileMetadata) + sizeof(char*));
}
}
EXPECT_TRUE(rejected);
EXPECT_TRUE(list.count <= MAX_DIR_TIME_ENTRIES);
EXPECT_TRUE(list.bytes <= MAX_DIR_TIME_BYTES);
/* The retained entries are still intact and freeable after the rejection. */
EXPECT_TRUE(list.count > 0);
EXPECT_TRUE(strcmp(list.paths[0], path) == 0);
dir_time_list_free(&list);
EXPECT_EQ_INT((int)list.bytes, 0);
free(path);
}
/* receive_incremental_check must reject an empty check_path; every other
* receive path rejects path[0]=='\0'. Feed the check header (empty wire path
* + size/mtime/nsec) and assert the check is refused without being skipped. */
static void test_receive_incremental_check_empty_path() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->checksum = false;
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
size_t wire_len = 0;
unsigned long long check_size = 0;
long long check_mtime = 0;
long long check_mtime_nsec = 0;
EXPECT_TRUE(send_n_data(p[1], &wire_len, sizeof(wire_len)));
EXPECT_TRUE(send_n_data(p[1], &check_size, sizeof(check_size)));
EXPECT_TRUE(send_n_data(p[1], &check_mtime, sizeof(check_mtime)));
EXPECT_TRUE(send_n_data(p[1], &check_mtime_nsec, sizeof(check_mtime_nsec)));
bool skipped = true;
const File* file = receive_incremental_check(p[0], cfg, &skipped);
EXPECT_NULL(file);
EXPECT_FALSE(skipped);
close(p[0]);
close(p[1]);
config_delete(cfg);
}
/* -K/--keep-dirlinks secure open: with an authorized root, a destination path /* -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 * 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, * (its referent is opened through a relative O_NOFOLLOW walk from the root fd,
@@ -1531,6 +1601,8 @@ void test_file() {
} }
test_file_metadata_create(); test_file_metadata_create();
test_dir_time_list(); test_dir_time_list();
test_dir_time_list_cap();
test_receive_incremental_check_empty_path();
test_keep_dirlinks_secure_open(); test_keep_dirlinks_secure_open();
test_inplace_overwrite_clears_special_mode_bits(); test_inplace_overwrite_clears_special_mode_bits();
test_inplace_overwrite_metadata_strips_special_bits(); test_inplace_overwrite_metadata_strips_special_bits();
+116
View File
@@ -1,7 +1,9 @@
#include "test_log.h" #include "test_log.h"
#include "log.h" #include "log.h"
#include "test_utils.h" #include "test_utils.h"
#include <fcntl.h>
#include <string.h> #include <string.h>
#include <threads.h>
#include <unistd.h> #include <unistd.h>
/* Test default log level: WARNING and ERROR should print, DEBUG and INFO should not. /* Test default log level: WARNING and ERROR should print, DEBUG and INFO should not.
@@ -147,6 +149,118 @@ static void test_log_debug_enabled_matches_gate() {
set_log_debug_flags(LOG_DEBUG_ALL); set_log_debug_flags(LOG_DEBUG_ALL);
} }
#define LOG_CONCURRENCY_THREADS 8
#define LOG_CONCURRENCY_LINES 250
typedef struct {
int id;
} LogConcurrencyArg;
static int log_concurrency_worker(void* context) {
LogConcurrencyArg* arg = context;
for (int i = 0; i < LOG_CONCURRENCY_LINES; i++) {
log_message(LOG_LEVEL_WARNING, "worker %d line %d", arg->id, i);
}
return 0;
}
static int count_substring(const char* haystack, const char* needle) {
int count = 0;
size_t needle_length = strlen(needle);
const char* cursor = haystack;
while ((cursor = strstr(cursor, needle)) != NULL) {
count++;
cursor += needle_length;
}
return count;
}
/* Concurrent log_message() calls from many threads must never interleave a
* single line: every emitted line has exactly one timestamp prefix and one
* body. Before write_message() was serialized, the three separate fprintf
* calls (prefix, body, newline) let lines tear. */
static void test_log_concurrent_no_torn_lines(void) {
FILE* fp = tmpfile();
EXPECT_NOT_NULL(fp);
/* Mute the console mirror so the workers don't flood the test output. */
fflush(stdout);
fflush(stderr);
int saved_stdout = dup(STDOUT_FILENO);
int saved_stderr = dup(STDERR_FILENO);
int null_fd = open("/dev/null", O_WRONLY);
EXPECT_TRUE(saved_stdout >= 0);
EXPECT_TRUE(saved_stderr >= 0);
EXPECT_TRUE(null_fd >= 0);
EXPECT_TRUE(dup2(null_fd, STDOUT_FILENO) >= 0);
EXPECT_TRUE(dup2(null_fd, STDERR_FILENO) >= 0);
close(null_fd);
set_log_level(LOG_LEVEL_WARNING);
log_set_stderr_mode(LOG_STDERR_ERRORS);
log_set_file(fp);
thrd_t threads[LOG_CONCURRENCY_THREADS];
LogConcurrencyArg args[LOG_CONCURRENCY_THREADS];
int created = 0;
for (int i = 0; i < LOG_CONCURRENCY_THREADS; i++) {
args[i].id = i;
if (thrd_create(&threads[i], log_concurrency_worker, &args[i]) != thrd_success)
break;
created++;
}
for (int i = 0; i < created; i++) {
thrd_join(threads[i], NULL);
}
log_set_file(NULL);
fflush(fp);
fflush(stdout);
fflush(stderr);
dup2(saved_stdout, STDOUT_FILENO);
dup2(saved_stderr, STDERR_FILENO);
close(saved_stdout);
close(saved_stderr);
rewind(fp);
char line[512];
int total_lines = 0;
int malformed_lines = 0;
bool saw_missing_newline = false;
while (fgets(line, sizeof(line), fp) != NULL) {
size_t length = strlen(line);
if (length == 0 || line[length - 1] != '\n')
saw_missing_newline = true;
if (strncmp(line, "20", 2) != 0 || count_substring(line, "[WARN]: worker ") != 1)
malformed_lines++;
total_lines++;
}
fclose(fp);
EXPECT_EQ_INT(created, LOG_CONCURRENCY_THREADS);
EXPECT_FALSE(saw_missing_newline);
EXPECT_EQ_INT(malformed_lines, 0);
EXPECT_EQ_INT(total_lines, LOG_CONCURRENCY_THREADS * LOG_CONCURRENCY_LINES);
log_set_stderr_mode(LOG_STDERR_ERRORS);
}
/* Detaching the logger from a FILE* before it is closed must leave the logging
* subsystem safe: later calls must not touch the freed handle. */
static void test_log_set_file_null_before_fclose(void) {
FILE* fp = tmpfile();
EXPECT_NOT_NULL(fp);
set_log_level(LOG_LEVEL_ERROR);
log_set_file(fp);
log_message(LOG_LEVEL_ERROR, "line before detach");
log_set_file(NULL);
fclose(fp);
log_message(LOG_LEVEL_ERROR, "line after close");
EXPECT_TRUE(true);
}
void test_log() { void test_log() {
test_log_message_debug(); test_log_message_debug();
test_log_message_info(); test_log_message_info();
@@ -159,4 +273,6 @@ void test_log() {
test_log_stderr_mode_all(); test_log_stderr_mode_all();
test_log_message_formats(); test_log_message_formats();
test_log_debug_enabled_matches_gate(); test_log_debug_enabled_matches_gate();
test_log_concurrent_no_torn_lines();
test_log_set_file_null_before_fclose();
} }
+53
View File
@@ -1317,6 +1317,58 @@ static void test_scanner_captures_directory_times() {
rmdir(root); rmdir(root);
} }
/* Ownership guard for chunk_data_to_chunk(): a returned Chunk owns its File
* objects, so destroying the chunk must free them exactly once and the scanner
* must never free them again. chunk_size = 1 forces the mid-directory
* conversion branch (chunk_data_size > chunk_size) for every file, and the
* chunk is destroyed immediately, catching a double free / use-after-free under
* ASan if ownership transfer regressed.
*
* The failure path (array_list_to_array() or chunk_create() returning NULL) is
* not reachable from a unit test: both allocate through protocol_alloc(), and
* each allocation they perform is no larger than the array_list allocations
* that already succeeded while building the list (array_list_to_array() copies
* exactly `size` pointers, which never exceeds the capacity just grown, and
* sizeof(Chunk) is far below the initial 100-entry item array). Binding a
* small --max-alloc session therefore always fails *before* this function, not
* inside it, so fault injection cannot isolate these paths. */
static void test_scanner_chunk_ownership() {
const char* dir = "test_scan_ownership";
const char* file1 = "test_scan_ownership/a.txt";
const char* file2 = "test_scan_ownership/b.txt";
const char* file3 = "test_scan_ownership/c.txt";
EXPECT_EQ_INT(mkdir(dir, 0755), 0);
create_test_file(file1, "aaaa");
create_test_file(file2, "bbbb");
create_test_file(file3, "cccc");
ScannerOptions options = {0};
options.chunk_size = 1;
DirectoryScanner* scanner = directory_scanner_create_with_options(dir, &options);
EXPECT_NOT_NULL(scanner);
int chunks = 0;
int files = 0;
Chunk* chunk;
while ((chunk = directory_scanner_next(scanner)) != NULL) {
chunks++;
files += chunk->element_count;
EXPECT_EQ_INT(chunk->element_count, 1);
chunk_destroy(chunk);
EXPECT_FALSE(directory_scanner_failed(scanner));
}
EXPECT_EQ_INT(files, 3);
EXPECT_EQ_INT(chunks, 3);
EXPECT_FALSE(directory_scanner_failed(scanner));
directory_scanner_destroy(scanner);
unlink(file1);
unlink(file2);
unlink(file3);
rmdir(dir);
}
void test_scanner() { void test_scanner() {
test_scanner_single_file(); test_scanner_single_file();
test_scanner_multiple_files(); test_scanner_multiple_files();
@@ -1353,4 +1405,5 @@ void test_scanner() {
test_dirs_files_from(); test_dirs_files_from();
test_files_from_relative_send_path(); test_files_from_relative_send_path();
test_scanner_captures_directory_times(); test_scanner_captures_directory_times();
test_scanner_chunk_ownership();
} }