refactor: drop /web/ URL prefix from browser routes

The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.

The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.

Pre-production: no transition redirects.
This commit is contained in:
2026-05-04 18:36:10 +02:00
parent 3d67cbc1c8
commit 2c6062a53e
43 changed files with 313 additions and 266 deletions
+7 -10
View File
@@ -86,7 +86,7 @@ async fn create_with_valid_key_returns_token() {
let app = test_app();
let resp = app
.oneshot(create_request(
r#"{"return_to":"/web/cases/abc"}"#,
r#"{"return_to":"/cases/abc"}"#,
Some(API_KEY),
))
.await
@@ -106,7 +106,7 @@ async fn consume_valid_token_sets_cookie_and_redirects() {
let create_resp = app
.clone()
.oneshot(create_request(
r#"{"return_to":"/web/cases/abc"}"#,
r#"{"return_to":"/cases/abc"}"#,
Some(API_KEY),
))
.await
@@ -125,7 +125,7 @@ async fn consume_valid_token_sets_cookie_and_redirects() {
.unwrap()
.to_str()
.unwrap(),
"/web/cases/abc"
"/cases/abc"
);
assert_eq!(
resp.headers()
@@ -167,16 +167,13 @@ async fn consume_token_is_one_time_use() {
assert_eq!(first.status(), StatusCode::SEE_OTHER);
assert_eq!(
first.headers().get(header::LOCATION).unwrap(),
"/web/cases" // default
"/cases" // default
);
let second = app.oneshot(consume_request(&token)).await.unwrap();
// Second use: token gone, redirect to login.
assert_eq!(second.status(), StatusCode::SEE_OTHER);
assert_eq!(
second.headers().get(header::LOCATION).unwrap(),
"/web/login"
);
assert_eq!(second.headers().get(header::LOCATION).unwrap(), "/login");
}
#[tokio::test]
@@ -217,7 +214,7 @@ async fn consume_expired_token_redirects_to_login() {
PendingMagicLink {
slug: "dr_a".into(),
role: "doctor".into(),
return_to: "/web/cases".into(),
return_to: "/cases".into(),
expires_at: Instant::now() - Duration::from_secs(1),
},
);
@@ -226,7 +223,7 @@ async fn consume_expired_token_redirects_to_login() {
let app = doctate_server::create_router_with_state(state);
let resp = app.oneshot(consume_request(&token)).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/login");
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
// And the token must be removed from the store, even though it expired.
assert!(store.read().await.get(&token).is_none());