@@ -137,8 +137,51 @@ fn health() -> &'static str {
137137 "OK"
138138}
139139
140+ /// Request guard protecting `/metrics`. When `metrics_token` is configured,
141+ /// the endpoint requires `Authorization: Bearer <token>` (constant-time
142+ /// compared); otherwise it stays open (a startup warning is logged). This
143+ /// auth lives in the app rather than the ingress so the protection travels
144+ /// with cryptify to every deployment, including external hosts we can't
145+ /// firewall.
146+ struct MetricsAuth ;
147+
148+ #[ rocket:: async_trait]
149+ impl < ' r > FromRequest < ' r > for MetricsAuth {
150+ type Error = ( ) ;
151+ async fn from_request ( request : & ' r rocket:: Request < ' _ > ) -> rocket:: request:: Outcome < Self , ( ) > {
152+ let expected = request
153+ . rocket ( )
154+ . state :: < CryptifyConfig > ( )
155+ . and_then ( CryptifyConfig :: metrics_token) ;
156+
157+ // No token configured → metrics is open (warned about at startup).
158+ let Some ( expected) = expected else {
159+ return rocket:: request:: Outcome :: Success ( MetricsAuth ) ;
160+ } ;
161+
162+ let presented = request
163+ . headers ( )
164+ . get_one ( "Authorization" )
165+ . and_then ( |h| {
166+ h. strip_prefix ( "Bearer " )
167+ . or_else ( || h. strip_prefix ( "bearer " ) )
168+ } )
169+ . map ( str:: trim) ;
170+
171+ match presented {
172+ Some ( token) if constant_time_eq ( token, expected) => {
173+ rocket:: request:: Outcome :: Success ( MetricsAuth )
174+ }
175+ _ => rocket:: request:: Outcome :: Error ( ( rocket:: http:: Status :: Unauthorized , ( ) ) ) ,
176+ }
177+ }
178+ }
179+
140180#[ get( "/metrics" ) ]
141- fn metrics_endpoint ( metrics : & State < Arc < Metrics > > ) -> rocket:: response:: content:: RawText < String > {
181+ fn metrics_endpoint (
182+ _auth : MetricsAuth ,
183+ metrics : & State < Arc < Metrics > > ,
184+ ) -> rocket:: response:: content:: RawText < String > {
142185 rocket:: response:: content:: RawText ( metrics. render ( ) )
143186}
144187
@@ -991,11 +1034,11 @@ struct UploadStatusResponse {
9911034 prev_offset : Option < u64 > ,
9921035}
9931036
994- /// Constant-time compare of the recovery token. Hex-encoded equal-length
995- /// strings, but `subtle::ConstantTimeEq` makes the timing independent of
996- /// where the bytes start to differ — defeats timing oracles even though
997- /// 32 bytes of high-entropy random aren't realistically guessable .
998- fn recovery_tokens_match ( presented : & str , expected : & str ) -> bool {
1037+ /// Constant-time string equality. `subtle::ConstantTimeEq` makes the timing
1038+ /// independent of where the bytes start to differ — defeats timing oracles
1039+ /// on secret comparisons. Used for the recovery token and the `/metrics`
1040+ /// bearer token. A length difference returns early (lengths aren't secret) .
1041+ fn constant_time_eq ( presented : & str , expected : & str ) -> bool {
9991042 use subtle:: ConstantTimeEq ;
10001043 if presented. len ( ) != expected. len ( ) {
10011044 return false ;
@@ -1021,7 +1064,7 @@ async fn upload_status(
10211064 . ok_or_else ( || Error :: upload_session_not_found ( uuid, "expired_or_unknown" ) ) ?;
10221065 let state = state. lock ( ) . await ;
10231066
1024- if !recovery_tokens_match ( & recovery_token. 0 , & state. recovery_token ) {
1067+ if !constant_time_eq ( & recovery_token. 0 , & state. recovery_token ) {
10251068 // Same body shape as evicted/unknown so the response doesn't leak
10261069 // session existence to a token-guessing attacker.
10271070 return Err ( Error :: upload_session_not_found ( uuid, "expired_or_unknown" ) ) ;
@@ -1479,6 +1522,13 @@ async fn rocket() -> _ {
14791522 . extract :: < CryptifyConfig > ( )
14801523 . expect ( "Missing configuration" ) ;
14811524
1525+ if config. metrics_token ( ) . is_none ( ) {
1526+ log:: warn!(
1527+ "metrics_token is not set — /metrics is publicly accessible without authentication. \
1528+ Set `metrics_token` in config (or ROCKET_METRICS_TOKEN) to require a Bearer token."
1529+ ) ;
1530+ }
1531+
14821532 let pkg_params_url = format ! (
14831533 "{}/v2/sign/parameters" ,
14841534 config. pkg_url( ) . trim_end_matches( '/' )
@@ -1982,16 +2032,16 @@ mod tests {
19822032 }
19832033
19842034 #[ rocket:: async_test]
1985- async fn recovery_tokens_match_constant_time_helper ( ) {
2035+ async fn constant_time_eq_helper ( ) {
19862036 // The function under test is the constant-time wrapper itself —
19872037 // we can't observe timing in a unit test, but we can pin the
19882038 // value-equality semantics so a future refactor doesn't silently
19892039 // turn it into `presented == expected`.
1990- assert ! ( recovery_tokens_match ( "abc123" , "abc123" ) ) ;
1991- assert ! ( !recovery_tokens_match ( "abc123" , "abc124" ) ) ;
1992- assert ! ( !recovery_tokens_match ( "abc123" , "abc12" ) ) ; // length mismatch
1993- assert ! ( !recovery_tokens_match ( "" , "abc" ) ) ;
1994- assert ! ( recovery_tokens_match ( "" , "" ) ) ;
2040+ assert ! ( constant_time_eq ( "abc123" , "abc123" ) ) ;
2041+ assert ! ( !constant_time_eq ( "abc123" , "abc124" ) ) ;
2042+ assert ! ( !constant_time_eq ( "abc123" , "abc12" ) ) ; // length mismatch
2043+ assert ! ( !constant_time_eq ( "" , "abc" ) ) ;
2044+ assert ! ( constant_time_eq ( "" , "" ) ) ;
19952045 }
19962046
19972047 // Browser preflight regression: design AC for #146 explicitly required
@@ -3074,6 +3124,66 @@ mod integration {
30743124 let _ = std:: fs:: remove_dir_all ( dir) ;
30753125 }
30763126
3127+ // Minimal Rocket exposing only /metrics, with the given config managed so
3128+ // the MetricsAuth guard can read `metrics_token`. Avoids needing a real
3129+ // VerifyingKey / TestSetup.
3130+ fn metrics_only_config ( with_token : bool ) -> CryptifyConfig {
3131+ let ( figment, _dir) = test_figment ( ) ;
3132+ let figment = if with_token {
3133+ figment. merge ( ( "metrics_token" , "s3cret" ) )
3134+ } else {
3135+ figment
3136+ } ;
3137+ figment. extract :: < CryptifyConfig > ( ) . expect ( "extract config" )
3138+ }
3139+
3140+ async fn metrics_only_client ( config : CryptifyConfig ) -> Client {
3141+ let rocket = rocket:: build ( )
3142+ . mount ( "/" , routes ! [ metrics_endpoint] )
3143+ . manage ( config)
3144+ . manage ( std:: sync:: Arc :: new ( Metrics :: new ( ) ) ) ;
3145+ Client :: tracked ( rocket) . await . expect ( "valid rocket" )
3146+ }
3147+
3148+ #[ rocket:: async_test]
3149+ async fn metrics_requires_bearer_when_token_configured ( ) {
3150+ let client = metrics_only_client ( metrics_only_config ( true ) ) . await ;
3151+
3152+ // No Authorization header → 401.
3153+ assert_eq ! (
3154+ client. get( "/metrics" ) . dispatch( ) . await . status( ) ,
3155+ Status :: Unauthorized
3156+ ) ;
3157+ // Wrong token → 401.
3158+ assert_eq ! (
3159+ client
3160+ . get( "/metrics" )
3161+ . header( Header :: new( "Authorization" , "Bearer wrong" ) )
3162+ . dispatch( )
3163+ . await
3164+ . status( ) ,
3165+ Status :: Unauthorized
3166+ ) ;
3167+ // Correct token → 200 with the metrics body.
3168+ let ok = client
3169+ . get ( "/metrics" )
3170+ . header ( Header :: new ( "Authorization" , "Bearer s3cret" ) )
3171+ . dispatch ( )
3172+ . await ;
3173+ assert_eq ! ( ok. status( ) , Status :: Ok ) ;
3174+ assert ! ( ok
3175+ . into_string( )
3176+ . await
3177+ . unwrap_or_default( )
3178+ . contains( "cryptify_uploads_total" ) ) ;
3179+ }
3180+
3181+ #[ rocket:: async_test]
3182+ async fn metrics_open_when_token_unset ( ) {
3183+ let client = metrics_only_client ( metrics_only_config ( false ) ) . await ;
3184+ assert_eq ! ( client. get( "/metrics" ) . dispatch( ) . await . status( ) , Status :: Ok ) ;
3185+ }
3186+
30773187 #[ rocket:: async_test]
30783188 async fn upload_happy_path_multi_chunk ( ) {
30793189 // Two chunks >1 MiB to exercise the rolling token chain across
0 commit comments