Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6177f5e
Use constant-time comparison for token and credential verification
prk-Jr Mar 16, 2026
0d5376e
Address PR review feedback on constant-time comparison hardening
prk-Jr Mar 17, 2026
2523a92
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 17, 2026
ff4b49d
Merge branch 'main' into hardening/constant-time-comparisons
aram356 Mar 18, 2026
15c346e
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 19, 2026
ae85c9d
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 20, 2026
7f1cba5
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 21, 2026
2a4d19c
Resolve pr review findings
prk-Jr Mar 21, 2026
7bcd6ba
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 21, 2026
98d9991
Merge main into hardening/constant-time-comparisons
prk-Jr Mar 23, 2026
53846f8
Rename Unauthorized error variant to Forbidden to match 403 status code
prk-Jr Mar 25, 2026
a86b78a
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 25, 2026
364de4f
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 26, 2026
ebc803b
Restrict ct_str_eq to pub(crate) and convert doctest to unit test
prk-Jr Mar 30, 2026
5bd27f3
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 30, 2026
98a9151
fix lint format issue
prk-Jr Mar 30, 2026
07382b6
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 31, 2026
6967a55
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Apr 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 42 additions & 27 deletions crates/trusted-server-core/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ use crate::settings::Settings;

const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#;

/// Enforce HTTP basic auth for the matched handler, if any.
/// Enforces HTTP Basic authentication for configured handler paths.
///
/// Returns `Ok(None)` when the request does not target a protected handler or
/// when the supplied credentials are valid. Returns `Ok(Some(Response))` with
/// the auth challenge when credentials are missing or invalid.
///
/// Admin endpoints are protected by requiring a handler at build time; see
/// [`Settings::from_toml_and_env`]. Credential checks use constant-time
/// comparison for both username and password, and evaluate both regardless of
/// individual match results to avoid timing oracles.
///
/// # Errors
///
/// Returns an error when handler configuration is invalid, such as an
Expand Down Expand Up @@ -48,6 +53,7 @@ pub fn enforce_basic_auth(
if bool::from(username_match & password_match) {
Ok(None)
} else {
log::warn!("Basic auth failed for path: {}", req.get_path());
Ok(Some(unauthorized_response()))
}
}
Expand Down Expand Up @@ -143,6 +149,41 @@ mod tests {
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}

#[test]
fn challenge_when_username_wrong_password_correct() {
// Validates that both fields are always evaluated — no short-circuit username oracle.
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
let token = STANDARD.encode("wrong-user:pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
assert_eq!(
response.get_status(),
StatusCode::UNAUTHORIZED,
"should reject wrong username even with correct password"
);
}

#[test]
fn challenge_when_username_correct_password_wrong() {
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
let token = STANDARD.encode("user:wrong-pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
assert_eq!(
response.get_status(),
StatusCode::UNAUTHORIZED,
"should reject correct username with wrong password"
);
}

#[test]
fn challenge_when_scheme_is_not_basic() {
let settings = create_test_settings();
Expand Down Expand Up @@ -204,30 +245,4 @@ mod tests {
.expect("should challenge admin path with missing credentials");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}

#[test]
fn challenge_when_username_wrong_password_correct() {
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
let token = STANDARD.encode("wrong:pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge when only username is wrong");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}

#[test]
fn challenge_when_username_correct_password_wrong() {
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
let token = STANDARD.encode("user:wrong");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge when only password is wrong");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}
}
5 changes: 5 additions & 0 deletions crates/trusted-server-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ pub enum TrustedServerError {
#[display("Proxy error: {message}")]
Proxy { message: String },

/// Request understood but not permitted — results in a 403 Forbidden response.
#[display("Forbidden: {message}")]
Forbidden { message: String },

/// A redirect destination was blocked by the proxy allowlist.
#[display("Redirect to `{host}` blocked: host not in proxy allowed_domains")]
AllowlistViolation { host: String },
Expand Down Expand Up @@ -103,6 +107,7 @@ impl IntoHttpResponse for TrustedServerError {
Self::Prebid { .. } => StatusCode::BAD_GATEWAY,
Self::Integration { .. } => StatusCode::BAD_GATEWAY,
Self::Proxy { .. } => StatusCode::BAD_GATEWAY,
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
Self::AllowlistViolation { .. } => StatusCode::FORBIDDEN,
Self::SyntheticId { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Expand Down
72 changes: 71 additions & 1 deletion crates/trusted-server-core/src/http_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use chacha20poly1305::{aead::Aead, aead::KeyInit, XChaCha20Poly1305, XNonce};
use fastly::http::{header, StatusCode};
use fastly::{Request, Response};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq as _;

use crate::constants::INTERNAL_HEADERS;
use crate::settings::Settings;
Expand Down Expand Up @@ -318,10 +319,34 @@ pub fn sign_clear_url(settings: &Settings, clear_url: &str) -> String {
URL_SAFE_NO_PAD.encode(digest)
}
Comment thread
prk-Jr marked this conversation as resolved.

/// Constant-time string comparison.
///
/// The explicit length check documents the invariant that both values have known,
/// non-secret lengths. Both checks always run — the short-circuit `&&` is safe
/// here because token lengths are public information, not secrets.
///
/// # Security
///
/// The length equality check short-circuits (via `&&`), which reveals whether the
/// two strings have equal length via timing. This is safe when both strings have
/// **publicly known, fixed lengths** (e.g. base64url-encoded SHA-256 digests are
/// always 43 bytes). Do **not** use this function to compare secrets of
/// variable or confidential length — use a constant-time comparison that
/// also hides length, such as comparing HMAC outputs.
#[must_use]
pub(crate) fn ct_str_eq(a: &str, b: &str) -> bool {
a.len() == b.len() && bool::from(a.as_bytes().ct_eq(b.as_bytes()))
Comment thread
prk-Jr marked this conversation as resolved.
}

/// Verify a `tstoken` for the given clear-text URL.
///
/// Uses constant-time comparison to prevent timing side-channel attacks.
/// Length is not secret (always 43 bytes for base64url-encoded SHA-256),
/// but we check explicitly to document the invariant.
#[must_use]
Comment thread
prk-Jr marked this conversation as resolved.
pub fn verify_clear_url_signature(settings: &Settings, clear_url: &str, token: &str) -> bool {
sign_clear_url(settings, clear_url) == token
let expected = sign_clear_url(settings, clear_url);
ct_str_eq(&expected, token)
}

/// Compute tstoken for the new proxy scheme: SHA-256 of the encrypted full URL (including query).
Expand Down Expand Up @@ -388,6 +413,33 @@ mod tests {
));
}

#[test]
fn verify_clear_url_rejects_tampered_token() {
let settings = crate::test_support::tests::create_test_settings();
let url = "https://cdn.example/a.png?x=1";
let valid_token = sign_clear_url(&settings, url);

// Flip one bit in the first byte — same URL, same length, wrong bytes
let mut tampered = valid_token.into_bytes();
tampered[0] ^= 0x01;
let tampered =
String::from_utf8(tampered).expect("should be valid utf8 after single-bit flip");

assert!(
!verify_clear_url_signature(&settings, url, &tampered),
"should reject token with tampered bytes"
);
}

#[test]
fn verify_clear_url_rejects_empty_token() {
let settings = crate::test_support::tests::create_test_settings();
assert!(
!verify_clear_url_signature(&settings, "https://cdn.example/a.png", ""),
"should reject empty token"
);
}

// RequestInfo tests

#[test]
Expand Down Expand Up @@ -561,6 +613,24 @@ mod tests {
);
}

#[test]
fn test_ct_str_eq() {
assert!(ct_str_eq("hello", "hello"), "should match equal strings");
assert!(
!ct_str_eq("hello", "world"),
"should not match different strings"
);
assert!(
!ct_str_eq("hello", "hell"),
"should not match different lengths"
);
assert!(
!ct_str_eq("hell", "hello"),
"should not match when first is shorter"
);
assert!(ct_str_eq("", ""), "should match empty strings");
}

#[test]
fn test_copy_custom_headers_filters_internal() {
let mut req = Request::new(fastly::http::Method::GET, "https://example.com");
Expand Down
32 changes: 29 additions & 3 deletions crates/trusted-server-core/src/proxy.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::http_util::compute_encrypted_sha256_token;
use crate::http_util::{compute_encrypted_sha256_token, ct_str_eq};
use error_stack::{Report, ResultExt};
use fastly::http::{header, HeaderValue, Method, StatusCode};
use fastly::{Request, Response};
Expand Down Expand Up @@ -1148,8 +1148,11 @@ fn reconstruct_and_validate_signed_target(
};

let expected = compute_encrypted_sha256_token(settings, &full_for_token);
if expected != sig {
return Err(Report::new(TrustedServerError::Proxy {
// Constant-time comparison to prevent timing side-channel attacks on the token.
// Length is not secret (always 43 bytes for base64url-encoded SHA-256),
// but we check explicitly to document the invariant.
if !ct_str_eq(&expected, &sig) {
Comment thread
prk-Jr marked this conversation as resolved.
Comment thread
prk-Jr marked this conversation as resolved.
return Err(Report::new(TrustedServerError::Forbidden {
message: "invalid tstoken".to_string(),
}));
}
Expand Down Expand Up @@ -1350,6 +1353,29 @@ mod tests {
assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY);
}

#[tokio::test]
async fn reconstruct_rejects_tampered_tstoken() {
let settings = create_test_settings();
let tsurl = "https://cdn.example/asset.js";
let tsurl_encoded =
url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::<String>();
// Syntactically valid base64url token of the right length, but not the correct signature
let bad_token = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let url = format!(
"https://edge.example/first-party/proxy?tsurl={}&tstoken={}",
tsurl_encoded, bad_token
);

let err: Report<TrustedServerError> =
reconstruct_and_validate_signed_target(&settings, &url)
.expect_err("should reject tampered token");
assert_eq!(
err.current_context().status_code(),
StatusCode::FORBIDDEN,
"should return 403 for invalid tstoken"
);
}

#[tokio::test]
async fn click_missing_params_returns_400() {
let settings = create_test_settings();
Expand Down
34 changes: 0 additions & 34 deletions crates/trusted-server-core/src/redacted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,6 @@ impl<T> fmt::Display for Redacted<T> {
}
}

impl<T: PartialEq> PartialEq for Redacted<T> {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}

impl<T: PartialEq> PartialEq<T> for Redacted<T> {
fn eq(&self, other: &T) -> bool {
self.0 == *other
}
}

impl From<String> for Redacted<String> {
fn from(value: String) -> Self {
Self(value)
Expand Down Expand Up @@ -152,28 +140,6 @@ mod tests {
);
}

#[test]
fn partial_eq_with_inner_type() {
let secret = Redacted::new("my-secret".to_string());
assert!(
secret == "my-secret".to_string(),
"should equal the inner value"
);
assert!(
secret != "other".to_string(),
"should not equal a different value"
);
}

#[test]
fn partial_eq_between_redacted() {
let a = Redacted::new("same".to_string());
let b = Redacted::new("same".to_string());
let c = Redacted::new("different".to_string());
assert!(a == b, "should equal when inner values match");
assert!(a != c, "should not equal when inner values differ");
}

#[test]
fn struct_with_redacted_field_debug() {
#[derive(Debug)]
Expand Down
Loading