Skip to content

Commit 0fc9d7f

Browse files
authored
Merge branch 'main' into feature/edgezero-pr2-platform-traits
2 parents b458d64 + 56e99a1 commit 0fc9d7f

12 files changed

Lines changed: 1110 additions & 923 deletions

crates/trusted-server-core/src/auth.rs

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ use crate::settings::Settings;
1010

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

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

152+
#[test]
153+
fn challenge_when_username_wrong_password_correct() {
154+
// Validates that both fields are always evaluated — no short-circuit username oracle.
155+
let settings = create_test_settings();
156+
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
157+
let token = STANDARD.encode("wrong-user:pass");
158+
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
159+
160+
let response = enforce_basic_auth(&settings, &req)
161+
.expect("should evaluate auth")
162+
.expect("should challenge");
163+
assert_eq!(
164+
response.get_status(),
165+
StatusCode::UNAUTHORIZED,
166+
"should reject wrong username even with correct password"
167+
);
168+
}
169+
170+
#[test]
171+
fn challenge_when_username_correct_password_wrong() {
172+
let settings = create_test_settings();
173+
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
174+
let token = STANDARD.encode("user:wrong-pass");
175+
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
176+
177+
let response = enforce_basic_auth(&settings, &req)
178+
.expect("should evaluate auth")
179+
.expect("should challenge");
180+
assert_eq!(
181+
response.get_status(),
182+
StatusCode::UNAUTHORIZED,
183+
"should reject correct username with wrong password"
184+
);
185+
}
186+
146187
#[test]
147188
fn challenge_when_scheme_is_not_basic() {
148189
let settings = create_test_settings();
@@ -204,30 +245,4 @@ mod tests {
204245
.expect("should challenge admin path with missing credentials");
205246
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
206247
}
207-
208-
#[test]
209-
fn challenge_when_username_wrong_password_correct() {
210-
let settings = create_test_settings();
211-
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
212-
let token = STANDARD.encode("wrong:pass");
213-
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
214-
215-
let response = enforce_basic_auth(&settings, &req)
216-
.expect("should evaluate auth")
217-
.expect("should challenge when only username is wrong");
218-
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
219-
}
220-
221-
#[test]
222-
fn challenge_when_username_correct_password_wrong() {
223-
let settings = create_test_settings();
224-
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
225-
let token = STANDARD.encode("user:wrong");
226-
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
227-
228-
let response = enforce_basic_auth(&settings, &req)
229-
.expect("should evaluate auth")
230-
.expect("should challenge when only password is wrong");
231-
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
232-
}
233248
}

crates/trusted-server-core/src/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ pub enum TrustedServerError {
6060
#[display("Proxy error: {message}")]
6161
Proxy { message: String },
6262

63+
/// Request understood but not permitted — results in a 403 Forbidden response.
64+
#[display("Forbidden: {message}")]
65+
Forbidden { message: String },
66+
6367
/// A redirect destination was blocked by the proxy allowlist.
6468
#[display("Redirect to `{host}` blocked: host not in proxy allowed_domains")]
6569
AllowlistViolation { host: String },
@@ -103,6 +107,7 @@ impl IntoHttpResponse for TrustedServerError {
103107
Self::Prebid { .. } => StatusCode::BAD_GATEWAY,
104108
Self::Integration { .. } => StatusCode::BAD_GATEWAY,
105109
Self::Proxy { .. } => StatusCode::BAD_GATEWAY,
110+
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
106111
Self::AllowlistViolation { .. } => StatusCode::FORBIDDEN,
107112
Self::SyntheticId { .. } => StatusCode::INTERNAL_SERVER_ERROR,
108113
Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR,

crates/trusted-server-core/src/http_util.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use chacha20poly1305::{aead::Aead, aead::KeyInit, XChaCha20Poly1305, XNonce};
33
use fastly::http::{header, StatusCode};
44
use fastly::{Request, Response};
55
use sha2::{Digest, Sha256};
6+
use subtle::ConstantTimeEq as _;
67

78
use crate::constants::INTERNAL_HEADERS;
89
use crate::settings::Settings;
@@ -318,10 +319,34 @@ pub fn sign_clear_url(settings: &Settings, clear_url: &str) -> String {
318319
URL_SAFE_NO_PAD.encode(digest)
319320
}
320321

322+
/// Constant-time string comparison.
323+
///
324+
/// The explicit length check documents the invariant that both values have known,
325+
/// non-secret lengths. Both checks always run — the short-circuit `&&` is safe
326+
/// here because token lengths are public information, not secrets.
327+
///
328+
/// # Security
329+
///
330+
/// The length equality check short-circuits (via `&&`), which reveals whether the
331+
/// two strings have equal length via timing. This is safe when both strings have
332+
/// **publicly known, fixed lengths** (e.g. base64url-encoded SHA-256 digests are
333+
/// always 43 bytes). Do **not** use this function to compare secrets of
334+
/// variable or confidential length — use a constant-time comparison that
335+
/// also hides length, such as comparing HMAC outputs.
336+
#[must_use]
337+
pub(crate) fn ct_str_eq(a: &str, b: &str) -> bool {
338+
a.len() == b.len() && bool::from(a.as_bytes().ct_eq(b.as_bytes()))
339+
}
340+
321341
/// Verify a `tstoken` for the given clear-text URL.
342+
///
343+
/// Uses constant-time comparison to prevent timing side-channel attacks.
344+
/// Length is not secret (always 43 bytes for base64url-encoded SHA-256),
345+
/// but we check explicitly to document the invariant.
322346
#[must_use]
323347
pub fn verify_clear_url_signature(settings: &Settings, clear_url: &str, token: &str) -> bool {
324-
sign_clear_url(settings, clear_url) == token
348+
let expected = sign_clear_url(settings, clear_url);
349+
ct_str_eq(&expected, token)
325350
}
326351

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

416+
#[test]
417+
fn verify_clear_url_rejects_tampered_token() {
418+
let settings = crate::test_support::tests::create_test_settings();
419+
let url = "https://cdn.example/a.png?x=1";
420+
let valid_token = sign_clear_url(&settings, url);
421+
422+
// Flip one bit in the first byte — same URL, same length, wrong bytes
423+
let mut tampered = valid_token.into_bytes();
424+
tampered[0] ^= 0x01;
425+
let tampered =
426+
String::from_utf8(tampered).expect("should be valid utf8 after single-bit flip");
427+
428+
assert!(
429+
!verify_clear_url_signature(&settings, url, &tampered),
430+
"should reject token with tampered bytes"
431+
);
432+
}
433+
434+
#[test]
435+
fn verify_clear_url_rejects_empty_token() {
436+
let settings = crate::test_support::tests::create_test_settings();
437+
assert!(
438+
!verify_clear_url_signature(&settings, "https://cdn.example/a.png", ""),
439+
"should reject empty token"
440+
);
441+
}
442+
391443
// RequestInfo tests
392444

393445
#[test]
@@ -561,6 +613,24 @@ mod tests {
561613
);
562614
}
563615

616+
#[test]
617+
fn test_ct_str_eq() {
618+
assert!(ct_str_eq("hello", "hello"), "should match equal strings");
619+
assert!(
620+
!ct_str_eq("hello", "world"),
621+
"should not match different strings"
622+
);
623+
assert!(
624+
!ct_str_eq("hello", "hell"),
625+
"should not match different lengths"
626+
);
627+
assert!(
628+
!ct_str_eq("hell", "hello"),
629+
"should not match when first is shorter"
630+
);
631+
assert!(ct_str_eq("", ""), "should match empty strings");
632+
}
633+
564634
#[test]
565635
fn test_copy_custom_headers_filters_internal() {
566636
let mut req = Request::new(fastly::http::Method::GET, "https://example.com");

crates/trusted-server-core/src/proxy.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::http_util::compute_encrypted_sha256_token;
1+
use crate::http_util::{compute_encrypted_sha256_token, ct_str_eq};
22
use error_stack::{Report, ResultExt};
33
use fastly::http::{header, HeaderValue, Method, StatusCode};
44
use fastly::{Request, Response};
@@ -1148,8 +1148,11 @@ fn reconstruct_and_validate_signed_target(
11481148
};
11491149

11501150
let expected = compute_encrypted_sha256_token(settings, &full_for_token);
1151-
if expected != sig {
1152-
return Err(Report::new(TrustedServerError::Proxy {
1151+
// Constant-time comparison to prevent timing side-channel attacks on the token.
1152+
// Length is not secret (always 43 bytes for base64url-encoded SHA-256),
1153+
// but we check explicitly to document the invariant.
1154+
if !ct_str_eq(&expected, &sig) {
1155+
return Err(Report::new(TrustedServerError::Forbidden {
11531156
message: "invalid tstoken".to_string(),
11541157
}));
11551158
}
@@ -1350,6 +1353,29 @@ mod tests {
13501353
assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY);
13511354
}
13521355

1356+
#[tokio::test]
1357+
async fn reconstruct_rejects_tampered_tstoken() {
1358+
let settings = create_test_settings();
1359+
let tsurl = "https://cdn.example/asset.js";
1360+
let tsurl_encoded =
1361+
url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::<String>();
1362+
// Syntactically valid base64url token of the right length, but not the correct signature
1363+
let bad_token = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1364+
let url = format!(
1365+
"https://edge.example/first-party/proxy?tsurl={}&tstoken={}",
1366+
tsurl_encoded, bad_token
1367+
);
1368+
1369+
let err: Report<TrustedServerError> =
1370+
reconstruct_and_validate_signed_target(&settings, &url)
1371+
.expect_err("should reject tampered token");
1372+
assert_eq!(
1373+
err.current_context().status_code(),
1374+
StatusCode::FORBIDDEN,
1375+
"should return 403 for invalid tstoken"
1376+
);
1377+
}
1378+
13531379
#[tokio::test]
13541380
async fn click_missing_params_returns_400() {
13551381
let settings = create_test_settings();

crates/trusted-server-core/src/redacted.rs

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,6 @@ impl<T> fmt::Display for Redacted<T> {
6262
}
6363
}
6464

65-
impl<T: PartialEq> PartialEq for Redacted<T> {
66-
fn eq(&self, other: &Self) -> bool {
67-
self.0 == other.0
68-
}
69-
}
70-
71-
impl<T: PartialEq> PartialEq<T> for Redacted<T> {
72-
fn eq(&self, other: &T) -> bool {
73-
self.0 == *other
74-
}
75-
}
76-
7765
impl From<String> for Redacted<String> {
7866
fn from(value: String) -> Self {
7967
Self(value)
@@ -152,28 +140,6 @@ mod tests {
152140
);
153141
}
154142

155-
#[test]
156-
fn partial_eq_with_inner_type() {
157-
let secret = Redacted::new("my-secret".to_string());
158-
assert!(
159-
secret == "my-secret".to_string(),
160-
"should equal the inner value"
161-
);
162-
assert!(
163-
secret != "other".to_string(),
164-
"should not equal a different value"
165-
);
166-
}
167-
168-
#[test]
169-
fn partial_eq_between_redacted() {
170-
let a = Redacted::new("same".to_string());
171-
let b = Redacted::new("same".to_string());
172-
let c = Redacted::new("different".to_string());
173-
assert!(a == b, "should equal when inner values match");
174-
assert!(a != c, "should not equal when inner values differ");
175-
}
176-
177143
#[test]
178144
fn struct_with_redacted_field_debug() {
179145
#[derive(Debug)]

0 commit comments

Comments
 (0)