@@ -3,6 +3,7 @@ use chacha20poly1305::{aead::Aead, aead::KeyInit, XChaCha20Poly1305, XNonce};
33use fastly:: http:: { header, StatusCode } ;
44use fastly:: { Request , Response } ;
55use sha2:: { Digest , Sha256 } ;
6+ use subtle:: ConstantTimeEq as _;
67
78use crate :: constants:: INTERNAL_HEADERS ;
89use 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]
323347pub 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" ) ;
0 commit comments