Harden DNS cache, privileges, and native loading
ober
7beece56afaa6ff5cfc8329dc2bb0042d4c7cc2f
--- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install system tools run: | --- a/.github/workflows/security-baseline.yml +++ b/.github/workflows/security-baseline.yml @@ -13,7 +13,7 @@ jobs: baseline: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Required release files run: | --- a/Makefile +++ b/Makefile @@ -142,6 +142,17 @@ release-evidence: verify grep -q '^abuse_policy_status=present$$' "$(DIST_DIR)/abuse-policy.txt" grep -q '^any_query_policy_status=minimized-refused$$' "$(DIST_DIR)/abuse-policy.txt" grep -q '^edge_rate_limit_status=external-required-until-native-limit$$' "$(DIST_DIR)/abuse-policy.txt" + { \ + printf 'cdb_snapshot_reload_status=validated-atomic-swap\n'; \ + printf 'cdb_request_path_status=in-memory-only\n'; \ + printf 'cdb_retired_reader_status=lease-drained\n'; \ + printf 'privilege_root_policy_status=uid-gid-required\n'; \ + printf 'supplementary_groups_status=clear-and-verify\n'; \ + printf 'saved_identity_status=verified-or-regain-probed\n'; \ + printf 'source=docs/runtime-hardening.md\n'; \ + } > "$(DIST_DIR)/runtime-hardening.txt" + grep -q '^cdb_request_path_status=in-memory-only$$' "$(DIST_DIR)/runtime-hardening.txt" + grep -q '^supplementary_groups_status=clear-and-verify$$' "$(DIST_DIR)/runtime-hardening.txt" sh scripts/sanitize-evidence.sh "$(DIST_DIR)" sh scripts/sanitize-evidence.sh "$(SBOM_DIR)" sh scripts/sanitize-evidence.sh "$(REPRO_DIR)" --- a/README.md +++ b/README.md @@ -42,9 +42,14 @@ make static-freebsd ## Runtime Security Defaults -`jdns` binds the UDP and TCP sockets first, then chroots to `ROOT` (default `.`), drops -supplementary groups with `setgroups(0, NULL)`, applies `setgid`/`setuid` when -configured, and then applies platform filesystem restrictions. +`jdns` binds the UDP and TCP sockets first, then chroots to `ROOT` (default +`.`), drops supplementary groups with `setgroups(0, NULL)`, applies +`setgid`/`setuid`, verifies the resulting identities, and then applies platform +filesystem restrictions. A root launch requires both a non-zero `UID` and +non-zero `GID`. Root retention is denied unless the development-only +`JDNS_RETAIN_PRIVILEGES=1` / `--retain-privileges` override is explicit; that +override is audited in the log and is not a production configuration. See +`docs/runtime-hardening.md`. TCP handling uses Jerboa fibers over nonblocking sockets when the fiber runtime is available before chroot. Embedded static builds can fall back to bounded @@ -75,8 +80,12 @@ JDNS_REQUIRE_WASM_CDB=1 ## Zone Updates `jdns-data` writes a temporary CDB and atomically renames it into place. The -server validates the CDB at startup, then reopens it per query so an atomic -`data.cdb` replacement is visible without restarting the server. +server validates one immutable in-memory snapshot at startup. A low-frequency +watcher detects device/inode/time/size changes, builds and validates a candidate +off the request path, and atomically swaps it; in-flight queries finish against +their old snapshot. Queries never reopen the CDB. The default one-second poll +can be changed with `JDNS_CDB_RELOAD_MS` (100–60000 ms), and malformed updates +leave the last valid snapshot serving. ## Zone Formats --- a/SECURITY.md +++ b/SECURITY.md @@ -35,8 +35,10 @@ not raw binaries, generated libraries, or scratch build logs. - `ANY` queries must remain minimized instead of enumerating full zone records. - Filesystem sandbox fallback must fail closed unless explicitly enabled for local development. -- Privilege drop, supplementary group clearing, timeouts, and packet-size caps - are release gates. +- The CDB request path, validated atomic snapshot reload, privilege drop, + supplementary group clearing/verification, timeouts, and packet-size caps + are release gates. Exact runtime invariants are tracked in + `docs/runtime-hardening.md`. - Do not publish operational zone data, deployment paths, hostnames, or keys. - Public production deployment still requires UDP/TCP soak/load evidence, coverage-guided fuzzing evidence, SBOM/reproducibility evidence, target edge --- a/docs/release-evidence.md +++ b/docs/release-evidence.md @@ -42,6 +42,9 @@ artifacts under `dist/release-evidence/`: - `abuse-policy.txt`, generated from `docs/rate-limit-amplification.md`, with authoritative-only, recursion-disabled, minimized `ANY`, UDP payload, TCP resource, and external edge-rate-limit policy markers. +- `runtime-hardening.txt`, generated from `docs/runtime-hardening.md`, with the + CDB snapshot/reload, lease-drain, root-policy, supplementary-group, and saved + identity verification markers. To record a current production-length soak, run: @@ -111,7 +114,8 @@ Edge rate-limit evidence is claimed only when the release packet contains reviewed target configuration/evidence and `target_rate_limit_proof_status=present`. The external review packet must include this file, `SECURITY.md`, -`docs/threat-model.md`, `docs/rate-limit-amplification.md`, `docs/fuzzing.md`, +`docs/threat-model.md`, `docs/runtime-hardening.md`, +`docs/rate-limit-amplification.md`, `docs/fuzzing.md`, the corpus under `fuzz/corpus/`, the latest UDP/TCP soak and load-test logs, `dist/release-evidence/soak/`, `dist/release-evidence/fuzz-evidence/`, `dist/release-evidence/abuse-policy.txt`, and any accepted RustSec or native new file mode 100644 --- /dev/null +++ b/docs/runtime-hardening.md @@ -0,0 +1,59 @@ +# Runtime CDB And Privilege Hardening + +## Release Checklist Markers + +- cdb_snapshot_reload_status: validated-atomic-swap +- cdb_request_path_status: in-memory-only +- cdb_retired_reader_status: lease-drained +- privilege_root_policy_status: uid-gid-required +- supplementary_groups_status: clear-and-verify +- saved_identity_status: verified-or-regain-probed + +These markers are checked by `make security` and copied into release evidence. +Changing one requires matching implementation, regression tests, and renewed +target soak evidence. + +## CDB Snapshot And Reload Policy + +The daemon reads and validates `data.cdb` once at startup. UDP and TCP query +handlers acquire a short-lived lease on the current immutable in-memory reader; +they do not `stat`, open, read, allocate, or instantiate a CDB from disk. + +A control-plane thread checks the CDB pathname once per second by default. Its +version includes device, inode, size, modification time, and change time. When +the version changes, the thread reads and validates the candidate away from the +request path, confirms that its identity stayed stable during the read, and +atomically publishes it. A failed, malformed, truncated, oversized, or unstable +candidate is logged and the last valid snapshot continues serving. A retired +reader is closed only after its last in-flight query releases its lease. + +`JDNS_CDB_RELOAD_MS` may set the polling interval from 100 through 60000 ms. +The supported CDB size is capped at 16 MiB, matching the WASM CDB sandbox +capacity. Zone updates must continue to use same-directory atomic rename; the +checked-in zone compiler already follows that rule. + +`tests/cdb-cache-test.ss` gates the request-path load/allocation behavior, +concurrent old-or-new answers during atomic replacement, exactly-once reload, +lease draining, and rejection of malformed replacements. + +## Privilege Policy + +When the daemon starts with effective UID 0, both a non-zero `UID` and non-zero +`GID` are mandatory. Before either identity changes, the daemon calls +`setgroups(0, NULL)`, then `setgid`, then `setuid`. It verifies that no +supplementary groups remain, checks real/effective IDs and saved IDs where the +platform exposes `getresuid`/`getresgid`, and probes that UID 0 and GID 0 cannot +be regained. + +For an explicitly audited local development run only, root retention can be +enabled with one of: + +```sh +JDNS_RETAIN_PRIVILEGES=1 jdns ... +jdns --retain-privileges ... +``` + +The override conflicts with configured UID/GID values and must never be used +for a production release. `tests/privilege-policy-test.ss` gates the root +decision matrix and ensures even a UID-only non-root plan begins by clearing +supplementary groups. --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -28,7 +28,11 @@ cases must be modeled before release. - Filesystem boundary: startup reads config and CDB files before sandboxing; runtime should operate inside `ROOT`. - Privilege boundary: sockets are bound before chroot, supplementary groups are - cleared, and configured `setgid`/`setuid` are applied before serving. + cleared, configured `setgid`/`setuid` are applied, and resulting identities + plus inability to regain root are verified before serving. +- Reload boundary: packet handlers lease an immutable validated CDB snapshot; + a low-frequency control-plane task builds replacements and atomically swaps + them only after stable file identity and complete validation. ## Threat Actors @@ -68,8 +72,12 @@ cases must be modeled before release. - Filesystem sandbox fallback must be denied unless explicitly enabled for local development with `JDNS_ALLOW_SANDBOX_FALLBACK=1` or `--allow-sandbox-fallback`. -- CDB updates must remain atomic: write temp, validate, rename, reopen per - query, and never serve partially written data. +- CDB updates must remain atomic: write temp, rename, validate a stable + off-path candidate, atomically swap snapshots, drain old leases, and never + serve partially written data. Packet handlers must not reopen the zone. +- Effective-root startup must require non-root UID/GID targets or the explicit + development-only retain override. Every identity change must first clear + supplementary groups and verify the resulting credentials. - Public deployments must follow `docs/rate-limit-amplification.md` for authoritative-only behavior, edge rate limits, response-size review, and abuse evidence. new file mode 100644 --- /dev/null +++ b/lib/jerboa-dns/cdb-cache.ss @@ -0,0 +1,205 @@ +#!chezscheme +;;; (jerboa-dns cdb-cache) — immutable CDB snapshots with atomic hot reload. +;;; +;;; The request path only acquires a reference to the current in-memory +;;; snapshot. A low-frequency control-plane task calls cdb-cache-refresh!, +;;; which identifies the pathname by device/inode/size/timestamps, builds and +;;; validates a replacement off-path, then swaps it under a short mutex. +;;; Retired readers remain live until their last in-flight query releases them. + +(library (jerboa-dns cdb-cache) + (export + open-cdb-cache cdb-cache? cdb-cache-close! + call-with-cdb-snapshot + cdb-cache-refresh! + cdb-cache-current-using-sandbox? + cdb-cache-load-count cdb-cache-reload-count) + + (import + (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (only (std os posix) + posix-stat free-stat stat-dev stat-ino) + (only (jerboa-dns wasm-cdb) + open-sandboxed-cdb + sandboxed-cdb-close! + sandboxed-cdb-reader-using-sandbox?)) + + ;; version is an immutable vector: + ;; #(device inode size mtime-sec mtime-ns ctime-sec ctime-ns) + (defstruct cdb-generation (reader version active retired closed)) + (defstruct cdb-cache + (path lock refresh-lock current closed load-count reload-count)) + + (def (call-with-mutex mutex thunk) + (dynamic-wind + (lambda () (mutex-acquire mutex)) + thunk + (lambda () (mutex-release mutex)))) + + (def (path-device+inode path) + (let ([st (posix-stat path)]) + (dynamic-wind + void + (lambda () (values (stat-dev st) (stat-ino st))) + (lambda () (free-stat st))))) + + (def (cdb-file-version path) + (let-values ([(device inode) (path-device+inode path)]) + (let ([mtime (file-modification-time path)] + [ctime (file-change-time path)] + [size (call-with-port (open-file-input-port path) + port-length)]) + (vector device inode size + (time-second mtime) (time-nanosecond mtime) + (time-second ctime) (time-nanosecond ctime))))) + + (def (load-stable-generation path) + ;; Retry a bounded number of times if an operator replaces the pathname + ;; while a candidate is being read. No unstable candidate is published. + (let loop ([attempt 0]) + (let* ([before (cdb-file-version path)] + [reader (open-sandboxed-cdb path)] + [after (guard (e [#t + (sandboxed-cdb-close! reader) + (raise e)]) + (cdb-file-version path))]) + (if (equal? before after) + (make-cdb-generation reader after 0 #f #f) + (begin + (sandboxed-cdb-close! reader) + (if (< attempt 2) + (loop (+ attempt 1)) + (error 'open-cdb-cache + "CDB kept changing while a stable snapshot was built" + path))))))) + + (def (open-cdb-cache path) + (make-cdb-cache path + (make-mutex) + (make-mutex) + (load-stable-generation path) + #f + 1 + 0)) + + (def (acquire-generation cache) + (call-with-mutex (cdb-cache-lock cache) + (lambda () + (when (cdb-cache-closed cache) + (error 'call-with-cdb-snapshot "CDB cache is closed")) + (let ([generation (cdb-cache-current cache)]) + (cdb-generation-active-set! + generation (+ (cdb-generation-active generation) 1)) + generation)))) + + (def (release-generation! cache generation) + (let ([reader-to-close #f]) + (call-with-mutex (cdb-cache-lock cache) + (lambda () + (let ([active (- (cdb-generation-active generation) 1)]) + (when (negative? active) + (error 'release-generation! "CDB generation reference underflow")) + (cdb-generation-active-set! generation active) + (when (and (zero? active) + (cdb-generation-retired generation) + (not (cdb-generation-closed generation))) + (cdb-generation-closed-set! generation #t) + (set! reader-to-close (cdb-generation-reader generation)))))) + (when reader-to-close + (sandboxed-cdb-close! reader-to-close)))) + + (def (call-with-cdb-snapshot cache proc) + (let ([generation (acquire-generation cache)]) + (dynamic-wind + void + (lambda () (proc (cdb-generation-reader generation))) + (lambda () (release-generation! cache generation))))) + + (def (cdb-cache-current-version cache) + (call-with-mutex (cdb-cache-lock cache) + (lambda () + (when (cdb-cache-closed cache) + (error 'cdb-cache-refresh! "CDB cache is closed")) + (cdb-generation-version (cdb-cache-current cache))))) + + (def (retire-generation/locked! generation) + ;; Caller holds the cache lock. Return a reader that is immediately safe + ;; to close, or #f when in-flight queries still own the generation. + (cdb-generation-retired-set! generation #t) + (and (zero? (cdb-generation-active generation)) + (not (cdb-generation-closed generation)) + (begin + (cdb-generation-closed-set! generation #t) + (cdb-generation-reader generation)))) + + (def (cdb-cache-refresh! cache) + ;; Only refreshers serialize on refresh-lock. Queries never take it. + (call-with-mutex (cdb-cache-refresh-lock cache) + (lambda () + (let* ([current-version (cdb-cache-current-version cache)] + [observed-version (cdb-file-version (cdb-cache-path cache))]) + (if (equal? current-version observed-version) + 'unchanged + (let ([candidate (load-stable-generation (cdb-cache-path cache))] + [reader-to-close #f] + [candidate-unused? #f] + [candidate-installed? #f] + [result 'unchanged]) + (guard (e [#t + (unless (or candidate-installed? + (cdb-generation-closed candidate)) + (cdb-generation-closed-set! candidate #t) + (sandboxed-cdb-close! + (cdb-generation-reader candidate))) + (raise e)]) + (call-with-mutex (cdb-cache-lock cache) + (lambda () + (when (cdb-cache-closed cache) + (error 'cdb-cache-refresh! "CDB cache is closed")) + (let ([old (cdb-cache-current cache)]) + (if (equal? (cdb-generation-version old) + (cdb-generation-version candidate)) + (set! candidate-unused? #t) + (begin + (cdb-cache-current-set! cache candidate) + (set! candidate-installed? #t) + (cdb-cache-load-count-set! + cache (+ (cdb-cache-load-count cache) 1)) + (cdb-cache-reload-count-set! + cache (+ (cdb-cache-reload-count cache) 1)) + (set! reader-to-close + (retire-generation/locked! old)) + (set! result 'reloaded)))))) + (when candidate-unused? + (cdb-generation-closed-set! candidate #t) + (sandboxed-cdb-close! (cdb-generation-reader candidate))) + (when reader-to-close + (sandboxed-cdb-close! reader-to-close)) + result))))))) + + (def (cdb-cache-current-using-sandbox? cache) + (call-with-cdb-snapshot cache sandboxed-cdb-reader-using-sandbox?)) + + (def (cdb-cache-close! cache) + (let ([reader-to-close #f]) + (call-with-mutex (cdb-cache-refresh-lock cache) + (lambda () + (call-with-mutex (cdb-cache-lock cache) + (lambda () + (unless (cdb-cache-closed cache) + (cdb-cache-closed-set! cache #t) + (set! reader-to-close + (retire-generation/locked! (cdb-cache-current cache)))))))) + (when reader-to-close + (sandboxed-cdb-close! reader-to-close)))) + + ) ;; end library --- a/lib/jerboa-dns/main.ss +++ b/lib/jerboa-dns/main.ss @@ -24,6 +24,12 @@ (def (truthy-env? s) (and s (not (member (string-downcase s) '("" "0" "false" "no"))))) + (def (parse-id who value) + (let ([n (and value (string->number value))]) + (unless (and n (integer? n) (<= 0 n #xffffffff)) + (error who "expected an unsigned numeric identity" value)) + n)) + (def (run-jdns! args) ;; Configuration from environment variables (djbdns convention) ;; or command-line args: [ip] [port] [root-dir] [uid] [gid] @@ -31,8 +37,8 @@ [port (or (and (getenv "PORT") (string->number (getenv "PORT"))) 53)] [root-dir (or (getenv "ROOT") ".")] - [uid (and (getenv "UID") (string->number (getenv "UID")))] - [gid (and (getenv "GID") (string->number (getenv "GID")))] + [uid (and (getenv "UID") (parse-id 'UID (getenv "UID")))] + [gid (and (getenv "GID") (parse-id 'GID (getenv "GID")))] [data-file (or (getenv "DATA") "data.cdb")]) (when (truthy-env? (getenv "JDNS_LOG_QUERIES")) @@ -52,9 +58,9 @@ ((cons "--root" (cons v more)) (process-args more ip port v uid gid data-file)) ((cons "--uid" (cons v more)) - (process-args more ip port root-dir (string->number v) gid data-file)) + (process-args more ip port root-dir (parse-id '--uid v) gid data-file)) ((cons "--gid" (cons v more)) - (process-args more ip port root-dir uid (string->number v) data-file)) + (process-args more ip port root-dir uid (parse-id '--gid v) data-file)) ((cons "--data" (cons v more)) (process-args more ip port root-dir uid gid v)) ((cons "--log-queries" more) @@ -63,10 +69,13 @@ ((cons "--allow-sandbox-fallback" more) (putenv "JDNS_ALLOW_SANDBOX_FALLBACK" "1") (process-args more ip port root-dir uid gid data-file)) + ((cons "--retain-privileges" more) + (putenv "JDNS_RETAIN_PRIVILEGES" "1") + (process-args more ip port root-dir uid gid data-file)) ((cons opt _) (display (format "jdns: unknown option ~a\n" opt) (current-error-port)) - (display "Usage: jdns [--ip IP] [--port PORT] [--root DIR] [--uid UID] [--gid GID] [--data FILE] [--log-queries] [--allow-sandbox-fallback]\n" + (display "Usage: jdns [--ip IP] [--port PORT] [--root DIR] [--uid UID] [--gid GID] [--data FILE] [--log-queries] [--allow-sandbox-fallback] [--retain-privileges]\n" (current-error-port)) (exit 1)))))) --- a/lib/jerboa-dns/server.ss +++ b/lib/jerboa-dns/server.ss @@ -25,6 +25,7 @@ server-config-ip server-config-port server-config-root-dir server-config-uid server-config-gid server-config-data-file + privilege-drop-plan run-server! use-wasm-parser? set-use-wasm-parser!) @@ -46,12 +47,8 @@ (jerboa-dns lookup) (jerboa-dns wasm-dns) (jerboa-dns log) - ;; The wasm-cdb wrapper exposes the same predicate/flag names as - ;; wasm-dns; we only need its reader API here, so import that subset. - (only (jerboa-dns wasm-cdb) - open-sandboxed-cdb - sandboxed-cdb-close! - sandboxed-cdb-reader-using-sandbox?)) + (jerboa-dns cdb-cache) + (only (std native-loader) native-loader-ensure-libc-symbol!)) ;; ========== Platform Detection ========== @@ -67,15 +64,12 @@ ;; data-file: CDB filename (default "data.cdb") (defstruct server-config (ip port root-dir uid gid data-file)) - ;; ========== Load libc ========== - ;; On FreeBSD, socket/network functions require explicit libc loading. + ;; ========== Resolve libc ========== + ;; Static registrations take precedence. Dynamic builds may load libc only + ;; from the core policy's fixed operating-system paths, never by a bare name. (def _libc - (or (guard (e [#t #f]) (load-shared-object "libc.so.7")) - (guard (e [#t #f]) (load-shared-object "libc.so.6")) - (guard (e [#t #f]) (load-shared-object "libc.so")) - (guard (e [#t #f]) (load-shared-object "libSystem.B.dylib")) - (guard (e [#t #f]) (load-shared-object "")))) + (native-loader-ensure-libc-symbol! 'jerboa-dns/server "socket")) ;; ========== FFI for socket operations ========== @@ -97,6 +91,17 @@ (def c-setuid (foreign-procedure "setuid" (unsigned) int)) (def c-setgid (foreign-procedure "setgid" (unsigned) int)) (def c-setgroups (foreign-procedure "setgroups" (int void*) int)) + (def c-getuid (foreign-procedure "getuid" () unsigned)) + (def c-geteuid (foreign-procedure "geteuid" () unsigned)) + (def c-getgid (foreign-procedure "getgid" () unsigned)) + (def c-getegid (foreign-procedure "getegid" () unsigned)) + (def c-getgroups (foreign-procedure "getgroups" (int void*) int)) + (def c-getresuid + (and (foreign-entry? "getresuid") + (foreign-procedure "getresuid" (void* void* void*) int))) + (def c-getresgid + (and (foreign-entry? "getresgid") + (foreign-procedure "getresgid" (void* void* void*) int))) (def c-chdir (foreign-procedure "chdir" (string) int)) (def c-errno-location (cond @@ -150,6 +155,7 @@ (def TCP-IDLE-TIMEOUT-NS (* 15 1000000000)) (def TCP-FIBER-POLL-MS 1) (def TCP-IO-TIMEOUT -2) + (def DEFAULT-CDB-RELOAD-MS 1000) ;; Loaded dynamically so static jdns binaries can still run without ;; needing external Jerboa library files at startup. @@ -336,6 +342,149 @@ (def (allow-sandbox-fallback?) (truthy-env? "JDNS_ALLOW_SANDBOX_FALLBACK")) + (def (retain-privileges?) + (truthy-env? "JDNS_RETAIN_PRIVILEGES")) + + (def (configured-cdb-reload-ms) + (let ([raw (getenv "JDNS_CDB_RELOAD_MS")]) + (if raw + (let ([n (string->number raw)]) + (unless (and n (integer? n) (<= 100 n 60000)) + (error 'run-server! + "JDNS_CDB_RELOAD_MS must be an integer from 100 to 60000" + raw)) + n) + DEFAULT-CDB-RELOAD-MS))) + + ;; ========== Privilege policy and verification ========== + + (def (valid-id? id) + (and (integer? id) (<= 0 id #xffffffff))) + + (def (privilege-drop-plan effective-uid uid gid retain?) + ;; This pure planner is exported so policy/order can be regression-tested + ;; without changing the test runner's process credentials. + (unless (valid-id? effective-uid) + (error 'privilege-drop-plan "invalid effective UID" effective-uid)) + (when (and uid (not (valid-id? uid))) + (error 'privilege-drop-plan "invalid target UID" uid)) + (when (and gid (not (valid-id? gid))) + (error 'privilege-drop-plan "invalid target GID" gid)) + (when (and retain? (or uid gid)) + (error 'privilege-drop-plan + "retain-privileges conflicts with a UID/GID drop")) + (cond + [(zero? effective-uid) + (cond + [(and (not uid) (not gid)) + (if retain? + 'retain + (error 'privilege-drop-plan + "root startup requires non-root UID and GID; JDNS_RETAIN_PRIVILEGES=1 is development-only"))] + [(or (not uid) (not gid) (zero? uid) (zero? gid)) + (error 'privilege-drop-plan + "root startup requires both a non-root UID and non-root GID" + uid gid)] + [else '(clear-groups setgid setuid verify)])] + [(and (not uid) (not gid)) + ;; On a non-root launch, the retain flag is harmless but still logged. + (if retain? 'retain-nonroot 'none)] + [else + (append '(clear-groups) + (if gid '(setgid) '()) + (if uid '(setuid) '()) + '(verify))])) + + (def (read-res-ids proc) + (and proc + (let ([real (foreign-alloc 4)] + [effective (foreign-alloc 4)] + [saved (foreign-alloc 4)]) + (dynamic-wind + void + (lambda () + (unless (zero? (proc real effective saved)) + (error 'run-server! "cannot read saved process identities")) + (vector (foreign-ref 'unsigned real 0) + (foreign-ref 'unsigned effective 0) + (foreign-ref 'unsigned saved 0))) + (lambda () + (foreign-free real) + (foreign-free effective) + (foreign-free saved)))))) + + (def (all-vector-values=? ids expected) + (or (not ids) + (and (= (vector-ref ids 0) expected) + (= (vector-ref ids 1) expected) + (= (vector-ref ids 2) expected)))) + + (def (verify-privilege-drop! original-euid uid gid) + (when (not (zero? (c-getgroups 0 0))) + (error 'run-server! "supplementary groups remain after setgroups")) + (when uid + (unless (and (= (c-getuid) uid) + (= (c-geteuid) uid) + (all-vector-values=? (read-res-ids c-getresuid) uid)) + (error 'run-server! "UID drop verification failed" uid))) + (when gid + (unless (and (= (c-getgid) gid) + (= (c-getegid) gid) + (all-vector-values=? (read-res-ids c-getresgid) gid)) + (error 'run-server! "GID drop verification failed" gid))) + + ;; A root-origin process must be unable to regain either identity. If a + ;; platform unexpectedly permits it, immediately drop again and fail. + (when (zero? original-euid) + (when (zero? (c-setuid 0)) + (when uid (c-setuid uid)) + (error 'run-server! "process could regain root UID after drop")) + (when (zero? (c-setgid 0)) + (when gid (c-setgid gid)) + (error 'run-server! "process could regain root GID after drop")))) + + (def (apply-privilege-plan! plan original-euid uid gid) + (case plan + [(none) (void)] + [(retain) + (log-info 'privileges_retained + 'reason "explicit_development_override" + 'uid (c-geteuid) 'gid (c-getegid))] + [(retain-nonroot) + (log-info 'retain_privileges_ignored + 'reason "process_already_nonroot" + 'uid (c-geteuid) 'gid (c-getegid))] + [else + ;; Every configured identity change begins by clearing supplementary + ;; groups, including the historically unsafe UID-only case. + (when (= (c-setgroups 0 0) -1) + (error 'run-server! "cannot clear supplementary groups")) + (when gid + (when (= (c-setgid gid) -1) + (error 'run-server! "cannot setgid" gid))) + (when uid + (when (= (c-setuid uid) -1) + (error 'run-server! "cannot setuid" uid))) + (verify-privilege-drop! original-euid uid gid) + (log-info 'privdrop_verified 'uid (or uid "unchanged") + 'gid (or gid "unchanged") + 'supplementary_groups 0)])) + + ;; ========== CDB reload control plane ========== + + (def (start-cdb-reloader! cache interval-ms) + (fork-thread + (lambda () + (let loop () + (sleep-ms interval-ms) + (guard (e [#t + (log-error 'cdb_reload_failed + 'reason (condition-reason e))]) + (when (eq? (cdb-cache-refresh! cache) 'reloaded) + (log-info 'cdb_reloaded + 'reload_count (cdb-cache-reload-count cache)))) + (loop))))) + ;; ========== Capsicum fd Restriction (FreeBSD) ========== (def (pack-cap-rights idx0-bits idx1-bits) @@ -430,7 +579,7 @@ [(message-condition? e) (condition-message e)] [else "unknown"])) - (def (process-query! rs pkt-buf pkt-len client-addr data-file send-response! max-response-len) + (def (process-query! rs pkt-buf pkt-len client-addr cdb-cache send-response! max-response-len) ;; Parse query, look up in CDB, send response. (let ([pkt (make-bytevector pkt-len)] [t0 (now-monotonic-ns)] @@ -497,24 +646,14 @@ (response-query! rs qname qtype qclass) (response-id! rs id) - ;; Open per query so an atomic data.cdb rename is visible - ;; immediately, matching tinydns live-update behavior. (let ([client-ip (make-bytevector 4 0)]) ;; Extract from sockaddr (sin_addr at offset 4) (do ([i 0 (+ i 1)]) ((= i 4)) (bytevector-u8-set! client-ip i (foreign-ref 'unsigned-8 (+ client-addr 4) i))) - (let ([cdb (open-sandboxed-cdb data-file)] - [closed? #f]) - (def (close-cdb!) - (unless closed? - (set! closed? #t) - (sandboxed-cdb-close! cdb))) - (guard (e [#t - (close-cdb!) - (raise e)]) - (dns-respond rs cdb qname qtype client-ip) - (close-cdb!)))) + (call-with-cdb-snapshot cdb-cache + (lambda (cdb) + (dns-respond rs cdb qname qtype client-ip)))) ;; UDP truncates to the question section at 512 bytes; TCP can ;; carry the full DNS message up to the protocol maximum. @@ -610,7 +749,7 @@ (+ (bitwise-arithmetic-shift-left (foreign-ref 'unsigned-8 len-buf 0) 8) (foreign-ref 'unsigned-8 len-buf 1))) - (def (handle-tcp-client! client-fd client-addr data-file) + (def (handle-tcp-client! client-fd client-addr cdb-cache) (let ([len-buf (foreign-alloc 2)]) (let ([cleaned? #f]) (def (cleanup!) @@ -649,7 +788,7 @@ 'src (client-src-str client-addr) 'reason (condition-reason e))]) (process-query! (new-response-state) - pkt-buf pkt-len client-addr data-file + pkt-buf pkt-len client-addr cdb-cache (lambda (rs) (set! send-ok? (send-tcp-response! client-fd rs @@ -662,7 +801,7 @@ (when keep-going? (loop)))]))))) (cleanup!))))) - (def (run-tcp-client-task! client-fd client-addr data-file tcp-limiter) + (def (run-tcp-client-task! client-fd client-addr cdb-cache tcp-limiter) (let ([cleaned? #f]) (def (cleanup!) (unless cleaned? @@ -674,10 +813,10 @@ 'src (client-src-str client-addr) 'reason (condition-reason e)) (cleanup!)]) - (handle-tcp-client! client-fd client-addr data-file) + (handle-tcp-client! client-fd client-addr cdb-cache) (cleanup!)))) - (def (accept-tcp-loop! tcp-sock data-file tcp-limiter spawn-client!) + (def (accept-tcp-loop! tcp-sock cdb-cache tcp-limiter spawn-client!) (let loop () (let ([client-addr (foreign-alloc SOCKADDR_IN_SIZE)] [addrlen-buf (foreign-alloc 4)]) @@ -701,7 +840,7 @@ (foreign-ref 'unsigned-8 client-addr i))) (spawn-client! (lambda () - (run-tcp-client-task! client-fd addr-copy data-file tcp-limiter)) + (run-tcp-client-task! client-fd addr-copy cdb-cache tcp-limiter)) "tcp-client"))) (c-close client-fd)))] [(= (get-errno) EINTR) (void)] @@ -716,12 +855,17 @@ ;; ========== Main Server Loop ========== (def (run-server! config) - (let ([ip (server-config-ip config)] - [port (server-config-port config)] - [root-dir (server-config-root-dir config)] - [uid (server-config-uid config)] - [gid (server-config-gid config)] - [data-file (server-config-data-file config)]) + (let* ([ip (server-config-ip config)] + [port (server-config-port config)] + [root-dir (server-config-root-dir config)] + [uid (server-config-uid config)] + [gid (server-config-gid config)] + [data-file (server-config-data-file config)] + [original-euid (c-geteuid)] + [retain? (retain-privileges?)] + [privilege-plan + (privilege-drop-plan original-euid uid gid retain?)] + [reload-ms (configured-cdb-reload-ms)]) (install-sigpipe-handler!) @@ -756,21 +900,11 @@ "chroot failed; set JDNS_ALLOW_SANDBOX_FALLBACK=1 to allow chdir fallback" root-dir))))) - ;; 5. Drop privileges (clear groups before gid/uid, and gid - ;; before uid as setuid may remove - ;; the ability to call setgid) - (when gid - (when (= (c-setgroups 0 0) -1) - (error 'run-server! "cannot clear supplementary groups"))) - (when gid - (when (= (c-setgid gid) -1) - (error 'run-server! "cannot setgid" gid))) - (when uid - (when (= (c-setuid uid) -1) - (error 'run-server! "cannot setuid" uid))) - - (when (or uid gid) - (log-info 'privdrop 'uid (or uid "unchanged") 'gid (or gid "unchanged"))) + ;; 5. Apply the fail-closed privilege plan. Supplementary groups + ;; are always cleared before any configured UID/GID change; + ;; root startup requires both non-root IDs unless the explicit + ;; development-only retain override was selected. + (apply-privilege-plan! privilege-plan original-euid uid gid) ;; 6. Apply OS-level sandbox (defense in depth) (cond @@ -782,14 +916,16 @@ ;; Landlock: restrict filesystem access (if available) (enter-landlock-sandbox!)]) - ;; 7. Validate the CDB at startup, then reopen per query so - ;; atomic data.cdb replacement is observed without restart. - (let ([startup-cdb (open-sandboxed-cdb data-file)]) + ;; 7. Build one validated immutable snapshot. A low-frequency + ;; control-plane task detects atomic replacement and swaps a fully + ;; built candidate; packet handlers only acquire/release snapshots. + (let ([cdb-cache (open-cdb-cache data-file)]) (log-info 'cdb_opened 'file data-file - 'parser (if (sandboxed-cdb-reader-using-sandbox? startup-cdb) + 'parser (if (cdb-cache-current-using-sandbox? cdb-cache) "sandboxed" "in-process")) - (sandboxed-cdb-close! startup-cdb) + (start-cdb-reloader! cdb-cache reload-ms) + (log-info 'cdb_reload_watcher 'interval_ms reload-ms) ;; 8. TCP uses Jerboa fibers when available; embedded static ;; builds can fall back to bounded nonblocking OS-thread tasks. @@ -798,7 +934,7 @@ (let ([tcp-rt (*make-fiber-runtime* TCP-FIBER-WORKERS)]) (*fiber-spawn* tcp-rt (lambda () - (accept-tcp-loop! tcp-sock data-file tcp-limiter + (accept-tcp-loop! tcp-sock cdb-cache tcp-limiter (lambda (thunk name) (*fiber-spawn* tcp-rt thunk name)))) "tcp-accept") @@ -807,7 +943,7 @@ (begin (fork-thread (lambda () - (accept-tcp-loop! tcp-sock data-file tcp-limiter + (accept-tcp-loop! tcp-sock cdb-cache tcp-limiter (lambda (thunk name) (fork-thread thunk))))) (log-info 'tcp_listening 'ip ip 'port port 'mode "threads")))) @@ -828,7 +964,7 @@ (guard (e [#t (log-error 'recv_loop_error 'reason (condition-reason e))]) - (process-query! rs recv-buf n client-addr data-file + (process-query! rs recv-buf n client-addr cdb-cache (lambda (out-rs) (send-udp-response! udp-sock out-rs client-addr)) MAX-PACKET)))) --- a/lib/jerboa-dns/wasm-cdb.ss +++ b/lib/jerboa-dns/wasm-cdb.ss @@ -78,6 +78,10 @@ ;; terminating zero already counted in dns-domain-length. (def MAX-KEY 256) + ;; Keep host allocation bounded before the sandbox gets a chance to + ;; inspect the CDB. This matches wasm/cdb/src/lib.rs::CDB_CAP. + (def MAX-CDB-BYTES (* 16 1024 1024)) + (def (resolve-wasm-path) (or (getenv "JDNS_CDB_WASM") (find file-exists? @@ -141,23 +145,46 @@ ;; bytevector + (jerboa-dns cdb) reader for graceful fallback. ;; instance: wasm instance handle, or #f for fallback-only; data: raw CDB - ;; bytes; cdb-fallback: (jerboa-dns cdb) reader on the same bytes; closed. - (defstruct sandboxed-cdb-reader (instance data cdb-fallback closed)) + ;; bytes; cdb-fallback: (jerboa-dns cdb) reader on the same bytes; closed; + ;; lock: serializes the mutable per-query wasm arena and close. + (defstruct sandboxed-cdb-reader (instance data cdb-fallback closed lock)) + + (def (call-with-mutex mutex thunk) + (dynamic-wind + (lambda () (mutex-acquire mutex)) + thunk + (lambda () (mutex-release mutex)))) ;; ---------- Construction ---------- (def (open-sandboxed-cdb path) - (let* ([size (get-file-size path)] - [bv (make-bytevector size)]) - (call-with-port (open-file-input-port path (file-options) (buffer-mode block)) - (lambda (p) - (let loop ([off 0]) - (when (< off size) - (let ([n (get-bytevector-n! p bv off (- size off))]) - (loop (+ off n))))))) - (open-sandboxed-cdb/bytevector bv))) + ;; Size and read the same open file description. Atomic rename can swap + ;; the pathname while this runs, but it cannot splice two CDB versions + ;; into this bytevector. The cache performs a before/after identity check + ;; before publishing the resulting reader. + (call-with-port + (open-file-input-port path (file-options) (buffer-mode block)) + (lambda (p) + (let ([size (port-length p)]) + (when (or (< size 2048) (> size MAX-CDB-BYTES)) + (error 'open-sandboxed-cdb + "CDB size outside supported range" + size MAX-CDB-BYTES)) + (let ([bv (make-bytevector size)]) + (let loop ([off 0]) + (when (< off size) + (let ([n (get-bytevector-n! p bv off (- size off))]) + (when (or (eof-object? n) (zero? n)) + (error 'open-sandboxed-cdb + "CDB changed or truncated while reading" path)) + (loop (+ off n))))) + (open-sandboxed-cdb/bytevector bv)))))) (def (open-sandboxed-cdb/bytevector bv) + ;; Validate the complete CDB envelope even when the optional WASM runtime + ;; is unavailable. A malformed hot-reload candidate must never replace a + ;; known-good reader merely because fallback mode is active. + (validate-cdb-bytevector! bv) (let* ([fallback (open-cdb-reader/bytevector bv)] [inst (try-load-into-sandbox bv)]) (when (and (not inst) (require-sandbox?)) @@ -169,7 +196,7 @@ [(not (wasm-sandbox-available?)) "libjerboa_native not loaded"] [(not (file-exists? (resolve-wasm-path))) "wasm module missing on disk"] [else "cdb_finalize rejected the file"]))) - (make-sandboxed-cdb-reader inst bv fallback #f))) + (make-sandboxed-cdb-reader inst bv fallback #f (make-mutex)))) (def (try-load-into-sandbox bv) (and (sandbox-available?) @@ -205,11 +232,62 @@ (catch (e) (wasm-sandbox-free inst) #f))) (catch (e) #f))) - (def (get-file-size path) - (let ([p (open-file-input-port path)]) - (let ([len (port-length p)]) - (close-port p) - len))) + ;; ---------- CDB envelope validation ---------- + + (def (cdb-le32 bv off) + (let ([len (bytevector-length bv)]) + (when (or (negative? off) (> (+ off 4) len)) + (error 'validate-cdb-bytevector! "truncated uint32" off len)) + (bitwise-ior + (bytevector-u8-ref bv off) + (bitwise-arithmetic-shift-left (bytevector-u8-ref bv (+ off 1)) 8) + (bitwise-arithmetic-shift-left (bytevector-u8-ref bv (+ off 2)) 16) + (bitwise-arithmetic-shift-left (bytevector-u8-ref bv (+ off 3)) 24)))) + + (def (validate-cdb-bytevector! bv) + (let ([len (bytevector-length bv)]) + (when (or (< len 2048) (> len MAX-CDB-BYTES)) + (error 'validate-cdb-bytevector! + "CDB size outside supported range" len MAX-CDB-BYTES)) + + ;; The first non-empty hash table marks the end of the record area. + ;; Every table and every referenced record must remain within the file. + (let ([records-end len]) + (do ([i 0 (+ i 1)]) ((= i 256)) + (let* ([header-off (* i 8)] + [table-pos (cdb-le32 bv header-off)] + [table-count (cdb-le32 bv (+ header-off 4))] + [table-bytes (* table-count 8)]) + (when (or (> table-pos len) (> table-bytes (- len table-pos))) + (error 'validate-cdb-bytevector! + "CDB hash table outside file" i table-pos table-count len)) + (when (> table-count 0) + (set! records-end (min records-end table-pos))))) + + (when (< records-end 2048) + (error 'validate-cdb-bytevector! + "CDB record area overlaps header" records-end)) + + (do ([i 0 (+ i 1)]) ((= i 256) #t) + (let* ([header-off (* i 8)] + [table-pos (cdb-le32 bv header-off)] + [table-count (cdb-le32 bv (+ header-off 4))]) + (do ([slot 0 (+ slot 1)]) ((= slot table-count)) + (let* ([entry-off (+ table-pos (* slot 8))] + [entry-pos (cdb-le32 bv (+ entry-off 4))]) + (unless (zero? entry-pos) + (when (or (< entry-pos 2048) (> (+ entry-pos 8) records-end)) + (error 'validate-cdb-bytevector! + "CDB record header outside record area" + i slot entry-pos records-end))