diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3730c2bd..f66bb6b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -90,8 +90,6 @@ jobs: - name: Release on tag push if: ${{ github.event_name == 'push' }} uses: softprops/action-gh-release@v2.4.0 - env: - NODE_TLS_REJECT_UNAUTHORIZED: "0" with: files: ./filtered-bin/release/* tag_name: ${{ github.ref_name }} @@ -106,8 +104,6 @@ jobs: - name: Release on workflow dispatch if: ${{ github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@v2.4.0 - env: - NODE_TLS_REJECT_UNAUTHORIZED: "0" with: files: ./filtered-bin/release/* tag_name: ${{ needs.preparation.outputs.version }} diff --git a/tachyon/files/usr/lib/cgi-bin/tachyon-agent b/tachyon/files/usr/lib/cgi-bin/tachyon-agent index 71518c99..8a9c5104 100644 --- a/tachyon/files/usr/lib/cgi-bin/tachyon-agent +++ b/tachyon/files/usr/lib/cgi-bin/tachyon-agent @@ -19,17 +19,31 @@ API_PATH="/tachyon/agent/v1${PATH_INFO}" METHOD="${REQUEST_METHOD:-GET}" AUTH="${HTTP_AUTHORIZATION:-}" +# Reject oversized bodies before reading them: CONTENT_LENGTH is client- +# controlled and dd would happily buffer an arbitrarily large request. +MAX_BODY_BYTES=65536 BODY="{}" if [ "$METHOD" = "POST" ] || [ "$METHOD" = "PUT" ]; then + if [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt "$MAX_BODY_BYTES" ] 2>/dev/null; then + printf 'Status: 413 Payload Too Large\r\n' + printf 'Content-Type: application/json\r\n' + printf '\r\n' + printf '{"success":false,"error":"Request body too large","code":413}\n' + exit 0 + fi if [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt 0 ] 2>/dev/null; then BODY=$(dd bs=1 count="$CONTENT_LENGTH" 2>/dev/null) fi fi -OUTPUT=$(ucode -L "$TACHYON_LIB" "$AGENT_UC" "$API_PATH" "$METHOD" "$BODY" "$AUTH" 2>/dev/null) +# Bearer token is passed through the environment: argv entries are visible to +# every local user via ps, environ is restricted to the owning user and root. +OUTPUT=$(TACHYON_AGENT_BEARER="$AUTH" ucode -L "$TACHYON_LIB" "$AGENT_UC" "$API_PATH" "$METHOD" "$BODY" 2>/dev/null) +# Deliberately no Access-Control-Allow-Origin: a wildcard would let any +# website visited from the LAN read router state cross-origin (including via +# DNS rebinding). Same-origin LuCI calls and non-browser agents need no CORS. printf 'Content-Type: application/json\r\n' -printf 'Access-Control-Allow-Origin: *\r\n' printf 'Cache-Control: no-cache\r\n' printf '\r\n' printf '%s\n' "$OUTPUT" \ No newline at end of file diff --git a/tachyon/files/usr/lib/config/migration.uc b/tachyon/files/usr/lib/config/migration.uc index 0c2ec383..d85a2fe9 100644 --- a/tachyon/files/usr/lib/config/migration.uc +++ b/tachyon/files/usr/lib/config/migration.uc @@ -1649,13 +1649,18 @@ function migrate_runtime(source) { for (let path in ctx.removed_caches) remove_cache_path(path); + // UCI rewrote the config file; keep the token-bearing mode tight. + system("chmod 0600 /etc/config/" + CONFIG_NAME + " 2>/dev/null"); + return committed; } function commit_runtime() { let cursor = runtime_cursor(); cursor.load(CONFIG_NAME); - return commit_cursor(cursor); + let committed = commit_cursor(cursor); + system("chmod 0600 /etc/config/" + CONFIG_NAME + " 2>/dev/null"); + return committed; } function migrate_fixture(path, source) { diff --git a/tachyon/files/usr/lib/core/uci.uc b/tachyon/files/usr/lib/core/uci.uc index 8ed8066c..a29d59db 100644 --- a/tachyon/files/usr/lib/core/uci.uc +++ b/tachyon/files/usr/lib/core/uci.uc @@ -545,6 +545,18 @@ function del_list(path, value) { } } +// The tachyon config holds the Telegram bot token and subscription URLs with +// embedded credentials. UCI rewrites /etc/config/ on every commit and +// does not preserve a 0600 mode, so re-harden after each commit. +function harden_config_mode(package_name) { + if (as_string(package_name) != "tachyon") + return; + let path = "/etc/config/" + as_string(package_name); + if (fs.stat(path) == null) + return; + system("chmod 0600 " + common.shell_quote(path) + " 2>/dev/null"); +} + function commit(package_name) { if (fixture_enabled()) return state_commit(package_name); @@ -554,7 +566,10 @@ function commit(package_name) { return false; try { - return c.commit(package_name) != false; + let result = c.commit(package_name) != false; + if (result) + harden_config_mode(package_name); + return result; } catch (e) { return false; diff --git a/tachyon/files/usr/lib/service/agent_api.uc b/tachyon/files/usr/lib/service/agent_api.uc index 752169dc..c934940f 100644 --- a/tachyon/files/usr/lib/service/agent_api.uc +++ b/tachyon/files/usr/lib/service/agent_api.uc @@ -81,7 +81,23 @@ function get_agent_token() { function check_write_auth(bearer) { let token = get_agent_token(); if (token == "") return false; - return ("Bearer " + token) == as_string(bearer); + return constant_time_eq("Bearer " + token, bearer); +} + +// Byte-wise comparison without early exit so response timing does not leak +// how many leading token characters matched. +function constant_time_eq(a, b) { + a = as_string(a); + b = as_string(b); + let diff = length(a) ^ length(b); + let n = length(a) > length(b) ? length(a) : length(b); + for (let i = 0; i < n; i++) { + let ca = ord(a, i); + let cb = ord(b, i); + if (ca != cb) + diff |= 1; + } + return diff == 0; } // ─── READ endpoints ─────────────────────────────────────────────────────────── @@ -638,7 +654,9 @@ function handle_ai_doctor_fix(body) { let path_arg = as_string(ARGV[0] || ""); let method = uc(as_string(ARGV[1] || "GET")); let body = parse_json_safe(ARGV[2] || "{}") || {}; -let bearer = as_string(ARGV[3] || ""); +// The CGI wrapper hands the Authorization header over via the environment: +// argv is world-readable through ps, environ is not. +let bearer = as_string(getenv("TACHYON_AGENT_BEARER") || ""); let route = replace(path_arg, /^\/tachyon\/agent\/v1/, ""); if (route == "") route = "/"; diff --git a/tachyon/files/usr/lib/service/initd.uc b/tachyon/files/usr/lib/service/initd.uc index 2a61b079..98e189f8 100644 --- a/tachyon/files/usr/lib/service/initd.uc +++ b/tachyon/files/usr/lib/service/initd.uc @@ -189,20 +189,36 @@ function acquire_runtime_dir_lock(lock_dir, owner_pid) { let lock_stat = fs.stat(lock_dir + "/pid") || fs.stat(lock_dir); let lock_age = (lock_stat && lock_stat.mtime) ? (int(clock()[0]) - lock_stat.mtime) : 9999; + let recorded_pid = first_line_value(lock_dir + "/pid"); - if (pid_alive(first_line_value(lock_dir + "/pid")) && lock_age < 300) + if (pid_alive(recorded_pid) && lock_age < 300) return false; - command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); - if (!command_success_from_args([ "rmdir", lock_dir ])) - return false; - if (!command_success_from_args([ "mkdir", lock_dir ])) + // pid file missing entirely: either another waiter is mid-takeover or a + // previous owner crashed before writing it. Only steal once the + // directory is old enough that a live holder cannot still be starting. + if (recorded_pid == "" && lock_age < 300) return false; - if (lock_dir_write_owner(lock_dir, owner_pid)) - return true; + // Takeover must be atomic: rm+rmdir+mkdir lets two concurrent waiters + // each believe they own the lock. Renaming the directory away succeeds + // for exactly one contender; the losers re-race on mkdir and either win + // a fresh lock or observe the winner's pid file. + let stale_dir = lock_dir + ".stale." + owner_pid + "." + int(clock()[0]); + if (!command_success_from_args([ "mv", lock_dir, stale_dir ])) + return false; - release_runtime_dir_lock(lock_dir); + command_success_from_args([ "rm", "-rf", stale_dir ]); + + if (command_success_from_args([ "mkdir", lock_dir ])) { + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + release_runtime_dir_lock(lock_dir); + return false; + } + + // Another contender re-created the lock between our rename and mkdir: + // they own it now. return false; } diff --git a/tachyon/files/usr/lib/service/state.uc b/tachyon/files/usr/lib/service/state.uc index 218545bd..d74f647e 100644 --- a/tachyon/files/usr/lib/service/state.uc +++ b/tachyon/files/usr/lib/service/state.uc @@ -270,20 +270,36 @@ function acquire_runtime_dir_lock(lock_dir, owner_pid) { let lock_stat = fs.stat(lock_dir + "/pid") || fs.stat(lock_dir); let lock_age = (lock_stat && lock_stat.mtime) ? (int(clock()[0]) - lock_stat.mtime) : 9999; + let recorded_pid = first_line_value(lock_dir + "/pid"); - if (pid_alive(first_line_value(lock_dir + "/pid")) && lock_age < 300) + if (pid_alive(recorded_pid) && lock_age < 300) return false; - command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); - if (!command_success_from_args([ "rmdir", lock_dir ])) - return false; - if (!command_success_from_args([ "mkdir", lock_dir ])) + // pid file missing entirely: either another waiter is mid-takeover or a + // previous owner crashed before writing it. Only steal once the + // directory is old enough that a live holder cannot still be starting. + if (recorded_pid == "" && lock_age < 300) return false; - if (lock_dir_write_owner(lock_dir, owner_pid)) - return true; + // Takeover must be atomic: rm+rmdir+mkdir lets two concurrent waiters + // each believe they own the lock. Renaming the directory away succeeds + // for exactly one contender; the losers re-race on mkdir and either win + // a fresh lock or observe the winner's pid file. + let stale_dir = lock_dir + ".stale." + owner_pid + "." + int(clock()[0]); + if (!command_success_from_args([ "mv", lock_dir, stale_dir ])) + return false; - release_runtime_dir_lock(lock_dir); + command_success_from_args([ "rm", "-rf", stale_dir ]); + + if (command_success_from_args([ "mkdir", lock_dir ])) { + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + release_runtime_dir_lock(lock_dir); + return false; + } + + // Another contender re-created the lock between our rename and mkdir: + // they own it now. return false; }