Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ tokio-test = "0.4"
toml = "0.9.8"
url = "2.5.8"
urlencoding = "2.1"
subtle = "2"
uuid = { version = "1.18", features = ["v4"] }
validator = { version = "0.20", features = ["derive"] }
which = "8"
1 change: 1 addition & 0 deletions crates/common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ regex = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
subtle = { workspace = true }
tokio = { workspace = true }
toml = { workspace = true }
trusted-server-js = { path = "../js" }
Expand Down
39 changes: 38 additions & 1 deletion crates/common/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use fastly::http::{header, StatusCode};
use fastly::{Request, Response};
use subtle::ConstantTimeEq;

use crate::settings::Settings;

Expand All @@ -14,9 +15,14 @@ pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option<Response
None => return Some(unauthorized_response()),
};

if username == handler.username && password == handler.password {
// Use bitwise & (not &&) so both sides always evaluate — eliminates the
// username-existence oracle that short-circuit evaluation would create.
let username_ok = username.as_bytes().ct_eq(handler.username.as_bytes());
let password_ok = password.as_bytes().ct_eq(handler.password.as_bytes());
if (username_ok & password_ok).into() {
None
} else {
log::warn!("Basic auth failed for path: {}", req.get_path());
Some(unauthorized_response())
}
}
Expand Down Expand Up @@ -109,6 +115,37 @@ 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 = settings_with_handlers();
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 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 = settings_with_handlers();
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 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 = settings_with_handlers();
Expand Down
31 changes: 30 additions & 1 deletion crates/common/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;

use crate::constants::INTERNAL_HEADERS;
use crate::settings::Settings;
Expand Down Expand Up @@ -287,7 +288,8 @@ pub fn sign_clear_url(settings: &Settings, clear_url: &str) -> String {
/// Verify a `tstoken` for the given clear-text URL.
#[must_use]
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);
expected.as_bytes().ct_eq(token.as_bytes()).into()
}

/// Compute tstoken for the new proxy scheme: SHA-256 of the encrypted full URL (including query).
Expand Down Expand Up @@ -354,6 +356,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
27 changes: 26 additions & 1 deletion crates/common/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use fastly::http::{header, HeaderValue, Method, StatusCode};
use fastly::{Request, Response};
use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;

use crate::constants::{
HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_REFERER,
Expand Down Expand Up @@ -1052,7 +1053,8 @@ fn reconstruct_and_validate_signed_target(
};

let expected = compute_encrypted_sha256_token(settings, &full_for_token);
if expected != sig {
let valid: bool = expected.as_bytes().ct_eq(sig.as_bytes()).into();
if !valid {
return Err(Report::new(TrustedServerError::Proxy {
message: "invalid tstoken".to_string(),
}));
Expand Down Expand Up @@ -1238,6 +1240,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::BAD_GATEWAY,
"should return 502 for invalid tstoken"
);
}

#[tokio::test]
async fn click_missing_params_returns_400() {
let settings = create_test_settings();
Expand Down
Loading