diff --git a/Cargo.lock b/Cargo.lock index 0005bff..a0027c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1844,6 +1844,7 @@ dependencies = [ "sha2 0.11.0", "sqlparser", "sqlx", + "subtle", "thiserror 2.0.18", "tokio", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index 127d634..7980575 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ blake3 = "1" hkdf = "0.13" hmac = "0.13" sqlparser = "0.62.0" +subtle = "2" [[bin]] name = "migrate-lua-sql" diff --git a/src/constant_time.rs b/src/constant_time.rs new file mode 100644 index 0000000..eb0e13a --- /dev/null +++ b/src/constant_time.rs @@ -0,0 +1,49 @@ +//! Constant-time comparison helpers for secrets and their hashes. +//! +//! Comparing secret material (or its digest) with `==` can leak how many +//! leading bytes matched via early-exit timing. These helpers compare in time +//! independent of the *content* of equal-length inputs. Input length is not +//! treated as secret — the values compared here are fixed-length hashes or +//! attacker-known challenges — so an early length-mismatch return is fine. + +/// Constant-time equality over two byte slices. `subtle`'s slice comparison +/// short-circuits only on a length mismatch (length is not secret here); for +/// equal-length inputs it compares every byte regardless of where they differ. +pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { + use subtle::ConstantTimeEq; + a.ct_eq(b).into() +} + +/// Constant-time equality over two strings (compares their UTF-8 bytes). +pub fn ct_eq_str(a: &str, b: &str) -> bool { + ct_eq(a.as_bytes(), b.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn equal_values_match() { + assert!(ct_eq_str("a1b2c3", "a1b2c3")); + assert!(ct_eq(b"\x00\x01\x02", b"\x00\x01\x02")); + } + + #[test] + fn different_same_length_do_not_match() { + assert!(!ct_eq_str("a1b2c3", "a1b2c4")); + // Differing only in the first byte must also be rejected. + assert!(!ct_eq_str("X1b2c3", "a1b2c3")); + } + + #[test] + fn different_length_does_not_match() { + assert!(!ct_eq_str("abc", "abcd")); + assert!(!ct_eq_str("abcd", "abc")); + } + + #[test] + fn empty_values_match() { + assert!(ct_eq_str("", "")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 51711f1..6386fa4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod auth; pub mod config; +pub mod constant_time; pub mod db; pub mod delegation; pub mod dev_happyview; diff --git a/src/oauth/client_auth.rs b/src/oauth/client_auth.rs index 36841c1..728dbc6 100644 --- a/src/oauth/client_auth.rs +++ b/src/oauth/client_auth.rs @@ -36,7 +36,7 @@ pub async fn authenticate_confidential( let (id, key, client_type, scopes, origins_json, stored_hash) = row.ok_or_else(|| AppError::Auth("invalid client credentials".into()))?; - if stored_hash != secret_hash { + if !crate::constant_time::ct_eq_str(&stored_hash, &secret_hash) { return Err(AppError::Auth("invalid client credentials".into())); } @@ -290,7 +290,7 @@ pub fn verify_pkce(challenge: &str, verifier: &str) -> bool { use base64::engine::general_purpose::URL_SAFE_NO_PAD; let hash = Sha256::digest(verifier.as_bytes()); let computed = URL_SAFE_NO_PAD.encode(hash); - computed == challenge + crate::constant_time::ct_eq_str(&computed, challenge) } #[cfg(test)] diff --git a/src/rate_limit.rs b/src/rate_limit.rs index af571c2..ccc3476 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -198,7 +198,7 @@ impl RateLimiter { use sha2::{Digest, Sha256}; if let Some(identity) = self.client_identities.get(client_key) { let hash = hex::encode(Sha256::digest(secret.as_bytes())); - hash == identity.secret_hash + crate::constant_time::ct_eq_str(&hash, &identity.secret_hash) } else { false } -- 2.51.2 From f3fbc2b1c692eadcf7f53e12deafaaeb28fb888b Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 8 Jul 2026 18:13:45 -0500 Subject: [PATCH 2/2] fix: move drop to app user after setup Signed-off-by: Trezy --- Dockerfile | 15 +++++++++------ entrypoint.sh | 10 ++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 971edbc..d273e86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,10 +28,14 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ + gosu \ && rm -rf /var/lib/apt/lists/* -# Run the service as a non-root system user; the binary needs no root privileges -# at runtime and binds an unprivileged port (3000). +# The service runs as a non-root system user (uid/gid 10001); the binary needs no +# root privileges at runtime and binds an unprivileged port (3000). The container +# starts as root only so the entrypoint can fix ownership of a mounted data volume +# (e.g. a SQLite volume carried over from a root-era install) before dropping to +# the app user via gosu. RUN groupadd --system --gid 10001 app \ && useradd --system --uid 10001 --gid app --home-dir /app --no-create-home \ --shell /usr/sbin/nologin app @@ -48,14 +52,13 @@ COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh && touch /srv/static/.base-path-pending # Data dir for the default SQLite backend (DATABASE_URL=sqlite://data/...). -# A named volume mounted here inherits this ownership on first creation; a bind -# mount must be chown'd to uid 10001 by the operator. +# The entrypoint re-chowns this to the app user at startup, so a volume mounted +# here — including one created by an older root-era install — becomes writable. RUN mkdir -p /app/data && chown app:app /app/data ENV STATIC_DIR=/srv/static -USER app - EXPOSE 3000 +# Starts as root; entrypoint chowns /app/data and drops to the app user via gosu. ENTRYPOINT ["/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh index 56cd88f..6250c58 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -29,4 +29,14 @@ if [ -f "$MARKER" ]; then rm "$MARKER" fi +# When started as root (the default), make the data directory writable by the +# app user — this fixes SQLite volumes carried over from a root-era install +# (files owned by root) that would otherwise be read-only to uid 10001 — then +# drop privileges and run the server as the unprivileged app user. If the +# container was launched with an explicit --user, skip straight to exec. +if [ "$(id -u)" = "0" ]; then + chown -R app:app /app/data 2>/dev/null || true + exec gosu app happyview "$@" +fi + exec happyview "$@"