Skip to content

Commit 35fe27e

Browse files
committed
refactor(oidc) :: centralize route authorization
1 parent f07c27f commit 35fe27e

4 files changed

Lines changed: 372 additions & 142 deletions

File tree

‎src/webserver/http.rs‎

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use crate::filesystem::FileAccess;
3333
use crate::webserver::routing::RoutingAction::{
3434
CustomNotFound, Execute, NotFound, Redirect, Serve,
3535
};
36-
use crate::webserver::routing::{AppFileStore, calculate_route};
36+
use crate::webserver::routing::{AppFileStore, CanonicalRequestPath, ResolvedRoute, resolve_route};
3737
use actix_web::body::MessageBody;
3838
use anyhow::{Context, bail};
3939
use chrono::{DateTime, Utc};
@@ -489,21 +489,20 @@ pub async fn main_handler(
489489
.uri()
490490
.path_and_query()
491491
.ok_or_else(|| ErrorBadRequest("expected valid path with query from request"))?;
492-
let authorized_route = service_request
493-
.extensions_mut()
494-
.remove::<super::routing::RoutingAction>();
495-
let routing_action = match authorized_route {
496-
Some(action) => Ok(action),
497-
None => calculate_route(path_and_query, &store, &app_state.config).await,
498-
};
499-
let routing_action = match routing_action {
500-
Ok(action) => action,
501-
Err(e) => {
502-
let e = e.context(format!(
503-
"The server was unable to fulfill your request. \n\
504-
The following page is not accessible: {path_and_query:?}"
505-
));
506-
return Err(anyhow_err_to_actix(e, app_state));
492+
let authorized_route = { service_request.extensions_mut().remove::<ResolvedRoute>() };
493+
let routing_action = if let Some(route) = authorized_route {
494+
route.into_action()
495+
} else {
496+
let path = CanonicalRequestPath::parse(path_and_query, &app_state.config.site_prefix);
497+
match resolve_route(&path, path_and_query, &store, &app_state.config).await {
498+
Ok(route) => route.into_action(),
499+
Err(e) => {
500+
let e = e.context(format!(
501+
"The server was unable to fulfill your request. \n\
502+
The following page is not accessible: {path_and_query:?}"
503+
));
504+
return Err(anyhow_err_to_actix(e, app_state));
505+
}
507506
}
508507
};
509508
match routing_action {

‎src/webserver/oidc.rs‎

Lines changed: 92 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ use std::{future::Future, pin::Pin, str::FromStr, sync::Arc};
66
use tokio::time::Instant;
77

88
use 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+
};
1012
use crate::{AppState, app_config::AppConfig};
1113
use actix_web::http::header;
1214
use actix_web::{
@@ -140,47 +142,8 @@ impl TryFrom<&AppConfig> for OidcConfig {
140142
impl 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

257238
fn get_app_host(config: &AppConfig) -> String {
@@ -288,6 +269,7 @@ struct OidcSnapshot {
288269

289270
pub 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-
419395
pub 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

Comments
 (0)