@@ -6,7 +6,9 @@ use std::{future::Future, pin::Pin, str::FromStr, sync::Arc};
66use tokio:: time:: Instant ;
77
88use crate :: webserver:: http_client:: get_http_client_from_appdata;
9- use crate :: webserver:: routing:: { AppFileStore , RoutingAction , calculate_route} ;
9+ use crate :: webserver:: routing:: {
10+ AppFileStore , CanonicalRequestPath , RoutingAction , canonical_url_path, resolve_route,
11+ } ;
1012use crate :: { AppState , app_config:: AppConfig } ;
1113use actix_web:: http:: header;
1214use actix_web:: {
@@ -140,47 +142,8 @@ impl TryFrom<&AppConfig> for OidcConfig {
140142impl OidcConfig {
141143 #[ must_use]
142144 pub fn is_public_path ( & self , path : & str ) -> bool {
143- let Some ( path) = normalize_oidc_path ( path) else {
144- return false ;
145- } ;
146- self . is_public_normalized_path ( & path)
147- }
148-
149- fn is_public_normalized_path ( & self , path : & str ) -> bool {
150- // Filesystems can resolve differently cased paths to the same file.
151- // Protect all ASCII case aliases; a public override must still match
152- // the configured spelling exactly.
153- !self . protected_paths . iter ( ) . any ( |p| {
154- normalize_oidc_path ( p) . is_none_or ( |prefix| {
155- path. to_ascii_lowercase ( )
156- . starts_with ( & prefix. to_ascii_lowercase ( ) )
157- } )
158- } ) || self
159- . public_paths
160- . iter ( )
161- . any ( |p| normalize_oidc_path ( p) . is_some_and ( |prefix| path. starts_with ( & prefix) ) )
162- }
163-
164- fn is_public_route ( & self , action : & RoutingAction ) -> bool {
165- let ( RoutingAction :: Execute ( path) | RoutingAction :: CustomNotFound ( path) ) = action else {
166- return true ;
167- } ;
168- let Some ( site_prefix) = normalize_oidc_path ( & self . site_prefix ) else {
169- return false ;
170- } ;
171- let mut route = site_prefix. trim_end_matches ( '/' ) . to_string ( ) ;
172- for component in path. components ( ) {
173- if let std:: path:: Component :: Normal ( name) = component {
174- let Some ( name) = name. to_str ( ) else {
175- return false ;
176- } ;
177- route. push ( '/' ) ;
178- route. push_str ( name) ;
179- } else {
180- return false ;
181- }
182- }
183- self . is_public_normalized_path ( & route)
145+ let path = canonical_url_path ( path) ;
146+ OidcPathPolicy :: new ( self ) . is_public ( path. as_deref ( ) )
184147 }
185148
186149 /// Creates a custom ID token verifier that supports multiple issuers
@@ -223,35 +186,53 @@ impl OidcConfig {
223186 }
224187}
225188
226- /// Match the router's decoded filesystem path components before applying URL
227- /// prefix rules. Preserve a final slash: `/public` and `/public/` are different
228- /// routes. Invalid or ambiguous paths must never be classified as public.
229- fn normalize_oidc_path ( path : & str ) -> Option < String > {
230- use std:: path:: { Component , Path } ;
189+ /// Prefixes are decoded once when the OIDC state starts. The protected
190+ /// spelling is case-folded conservatively for case-insensitive file stores;
191+ /// public exceptions still require their configured spelling.
192+ struct OidcPathPolicy {
193+ protected : Option < Vec < String > > ,
194+ public : Vec < String > ,
195+ }
231196
232- let decoded = percent_encoding:: percent_decode_str ( path)
233- . decode_utf8 ( )
234- . ok ( ) ?;
235- if !decoded. starts_with ( '/' ) || decoded. contains ( '\\' ) {
236- return None ;
197+ impl OidcPathPolicy {
198+ fn new ( config : & OidcConfig ) -> Self {
199+ Self {
200+ protected : config
201+ . protected_paths
202+ . iter ( )
203+ . map ( |path| canonical_url_path ( path) . map ( |path| path. to_ascii_lowercase ( ) ) )
204+ . collect ( ) ,
205+ public : config
206+ . public_paths
207+ . iter ( )
208+ . filter_map ( |path| canonical_url_path ( path) )
209+ . collect ( ) ,
210+ }
237211 }
238- let mut normalized = String :: from ( "/" ) ;
239- for component in Path :: new ( decoded. as_ref ( ) ) . components ( ) {
240- match component {
241- Component :: RootDir | Component :: CurDir => { }
242- Component :: Normal ( name) => {
243- if normalized. len ( ) > 1 {
244- normalized. push ( '/' ) ;
245- }
246- normalized. push_str ( name. to_str ( ) ?) ;
212+
213+ fn is_public ( & self , path : Option < & str > ) -> bool {
214+ let ( Some ( path) , Some ( protected) ) = ( path, self . protected . as_ref ( ) ) else {
215+ return false ;
216+ } ;
217+ let folded = path. to_ascii_lowercase ( ) ;
218+ !protected. iter ( ) . any ( |prefix| folded. starts_with ( prefix) )
219+ || self . public . iter ( ) . any ( |prefix| path. starts_with ( prefix) )
220+ }
221+
222+ fn is_public_route ( & self , action : & RoutingAction , site_prefix : & str ) -> bool {
223+ match action {
224+ RoutingAction :: Execute ( _)
225+ | RoutingAction :: CustomNotFound ( _)
226+ | RoutingAction :: Serve ( _) => {
227+ let resource = crate :: webserver:: routing:: canonical_resource_url_for_action (
228+ site_prefix,
229+ action,
230+ ) ;
231+ self . is_public ( resource. as_deref ( ) )
247232 }
248- Component :: ParentDir | Component :: Prefix ( _ ) => return None ,
233+ RoutingAction :: Redirect ( _ ) | RoutingAction :: NotFound => true ,
249234 }
250235 }
251- if decoded. ends_with ( '/' ) && normalized != "/" {
252- normalized. push ( '/' ) ;
253- }
254- Some ( normalized)
255236}
256237
257238fn get_app_host ( config : & AppConfig ) -> String {
@@ -288,6 +269,7 @@ struct OidcSnapshot {
288269
289270pub struct OidcState {
290271 pub config : OidcConfig ,
272+ path_policy : OidcPathPolicy ,
291273 /// Current snapshot. The lock is only held for the instant
292274 /// needed to clone/swap the Arc — never across await points.
293275 snapshot : std:: sync:: RwLock < Arc < OidcSnapshot > > ,
@@ -301,6 +283,7 @@ impl OidcState {
301283 let ( client, end_session_endpoint) = build_oidc_client ( & oidc_cfg, & http_client) . await ?;
302284
303285 Ok ( Self {
286+ path_policy : OidcPathPolicy :: new ( & oidc_cfg) ,
304287 config : oidc_cfg,
305288 snapshot : std:: sync:: RwLock :: new ( Arc :: new ( OidcSnapshot {
306289 client,
@@ -409,13 +392,6 @@ impl OidcState {
409392 }
410393}
411394
412- fn request_is_builtin_static_path ( request : & ServiceRequest , config : & OidcConfig ) -> bool {
413- request
414- . path ( )
415- . strip_prefix ( & config. site_prefix )
416- . is_some_and ( |path| path. starts_with ( "sqlpage/" ) )
417- }
418-
419395pub async fn initialize_oidc_state (
420396 app_config : & AppConfig ,
421397) -> anyhow:: Result < Option < Arc < OidcState > > > {
@@ -566,26 +542,38 @@ async fn handle_unauthenticated_request(
566542) -> MiddlewareResponse {
567543 log:: debug!( "Handling unauthenticated request to {}" , request. path( ) ) ;
568544
569- if oidc_state. config . is_public_path ( request. path ( ) ) {
545+ let path_and_query = request. uri ( ) . path_and_query ( ) ;
546+ let canonical = path_and_query
547+ . map ( |path| CanonicalRequestPath :: parse ( path, & oidc_state. config . site_prefix ) ) ;
548+ if canonical
549+ . as_ref ( )
550+ . is_some_and ( |path| oidc_state. path_policy . is_public ( path. normalized_url ( ) ) )
551+ {
552+ let canonical = canonical. expect ( "checked above" ) ;
553+ // Built-in assets are handled by their own Actix services, not the
554+ // SQL-file router. Classify them explicitly before route resolution.
555+ if canonical. is_builtin_static ( ) {
556+ return MiddlewareResponse :: Forward ( request) ;
557+ }
570558 let route = {
571559 let app_state = request. app_data :: < web:: Data < AppState > > ( ) ;
572- let path_and_query = request. uri ( ) . path_and_query ( ) ;
573560 match ( app_state, path_and_query) {
574561 ( Some ( state) , Some ( path_and_query) ) => {
575562 let store = AppFileStore :: new ( & state. sql_file_cache , & state. file_system , state) ;
576- Some ( calculate_route ( path_and_query, & store, & state. config ) . await )
563+ Some ( resolve_route ( & canonical , path_and_query, & store, & state. config ) . await )
577564 }
578565 _ => None ,
579566 }
580567 } ;
581568 match route {
582- Some ( Ok ( action) ) if oidc_state. config . is_public_route ( & action) => {
569+ Some ( Ok ( route) )
570+ if oidc_state
571+ . path_policy
572+ . is_public_route ( route. action ( ) , & oidc_state. config . site_prefix ) =>
573+ {
583574 // Reuse the authorized route in the main handler so a file-store
584575 // change between this check and dispatch cannot change its identity.
585- request. extensions_mut ( ) . insert ( action) ;
586- return MiddlewareResponse :: Forward ( request) ;
587- }
588- Some ( Err ( _) ) if request_is_builtin_static_path ( & request, & oidc_state. config ) => {
576+ request. extensions_mut ( ) . insert ( route) ;
589577 return MiddlewareResponse :: Forward ( request) ;
590578 }
591579 _ => { }
@@ -1691,10 +1679,22 @@ mod tests {
16911679 assert ! ( config. is_public_path( "/users/profile.sql" ) ) ;
16921680 }
16931681
1682+ #[ test]
1683+ fn compiled_policy_checks_the_resolved_static_target_and_fails_closed ( ) {
1684+ let config = test_oidc_config_with_paths ( vec ! [ "/private/secret.txt" . to_string( ) ] , vec ! [ ] ) ;
1685+ let policy = OidcPathPolicy :: new ( & config) ;
1686+ assert ! ( policy. is_public( Some ( "/private/alias.txt" ) ) ) ;
1687+ let action = RoutingAction :: Serve ( "private/secret.txt" . into ( ) ) ;
1688+ assert ! ( !policy. is_public_route( & action, & config. site_prefix) ) ;
1689+
1690+ let invalid = test_oidc_config_with_paths ( vec ! [ "/%ff" . to_string( ) ] , vec ! [ ] ) ;
1691+ assert ! ( !OidcPathPolicy :: new( & invalid) . is_public( Some ( "/public/page.sql" ) ) ) ;
1692+ }
1693+
16941694 #[ tokio:: test]
16951695 async fn resolved_sql_file_aliases_do_not_bypass_oidc_protection ( ) {
16961696 use crate :: filesystem:: FileAccess ;
1697- use crate :: webserver:: routing:: FileStore ;
1697+ use crate :: webserver:: routing:: { FileStore , calculate_route } ;
16981698 use awc:: http:: uri:: PathAndQuery ;
16991699
17001700 struct OneFile ( & ' static str ) ;
@@ -1713,6 +1713,7 @@ mod tests {
17131713 ( "/private/missing" , "private/404.sql" ) ,
17141714 ] {
17151715 let oidc = test_oidc_config_with_paths ( vec ! [ format!( "/{file}" ) ] , vec ! [ ] ) ;
1716+ let policy = OidcPathPolicy :: new ( & oidc) ;
17161717 assert ! ( oidc. is_public_path( url) , "{url} is a distinct URL spelling" ) ;
17171718 let route = calculate_route (
17181719 & PathAndQuery :: try_from ( url) . unwrap ( ) ,
@@ -1728,25 +1729,27 @@ mod tests {
17281729 ) ;
17291730 }
17301731 assert ! (
1731- !oidc . is_public_route( & route) ,
1732+ !policy . is_public_route( & route, & oidc . site_prefix ) ,
17321733 "{url} resolves to a protected SQL file"
17331734 ) ;
17341735 }
17351736
17361737 let mut prefixed_oidc =
17371738 test_oidc_config_with_paths ( vec ! [ "/my%20app/private/secret.sql" . to_string( ) ] , vec ! [ ] ) ;
17381739 prefixed_oidc. site_prefix = "/my%20app/" . to_string ( ) ;
1739- assert ! (
1740- !prefixed_oidc. is_public_route( & RoutingAction :: Execute ( "private/secret.sql" . into( ) ) )
1741- ) ;
1740+ assert ! ( !OidcPathPolicy :: new( & prefixed_oidc) . is_public_route(
1741+ & RoutingAction :: Execute ( "private/secret.sql" . into( ) ) ,
1742+ & prefixed_oidc. site_prefix
1743+ ) ) ;
17421744
17431745 let public_oidc = test_oidc_config_with_paths (
17441746 vec ! [ "/private" . to_string( ) ] ,
17451747 vec ! [ "/private/public/" . to_string( ) ] ,
17461748 ) ;
1747- assert ! (
1748- public_oidc. is_public_route( & RoutingAction :: Execute ( "private/public/index.sql" . into( ) ) )
1749- ) ;
1749+ assert ! ( OidcPathPolicy :: new( & public_oidc) . is_public_route(
1750+ & RoutingAction :: Execute ( "private/public/index.sql" . into( ) ) ,
1751+ & public_oidc. site_prefix
1752+ ) ) ;
17501753 }
17511754
17521755 #[ test]
0 commit comments