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
+5 -5
View File
@@ -52,7 +52,7 @@ async fn api_health_has_all_security_headers() {
#[tokio::test]
async fn web_login_page_has_all_security_headers() {
let resp = get(test_app(), "/web/login").await;
let resp = get(test_app(), "/login").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
assert!(header_opt(&resp, "content-security-policy").is_some());
@@ -60,7 +60,7 @@ async fn web_login_page_has_all_security_headers() {
// ---------- per-attack tests ----------
/// Clickjacking: attacker embeds /web/cases in a hidden iframe on their
/// Clickjacking: attacker embeds /cases in a hidden iframe on their
/// own page and tricks the victim into clicking overlaid elements.
/// Defense: `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`.
#[tokio::test]
@@ -147,9 +147,9 @@ async fn permissions_policy_blocks_sensitive_features() {
/// short-circuit on error paths.
#[tokio::test]
async fn error_redirect_still_carries_security_headers() {
// /web/cases without cookie → 302 redirect (error path from the
// /cases without cookie → 302 redirect (error path from the
// session extractor).
let resp = get(test_app(), "/web/cases").await;
let resp = get(test_app(), "/cases").await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
@@ -162,7 +162,7 @@ async fn error_redirect_still_carries_security_headers() {
/// yet) and must keep passing after the layer lands.
#[tokio::test]
async fn magic_route_referrer_policy_not_duplicated() {
let resp = get(test_app(), "/web/magic?token=definitely-invalid").await;
let resp = get(test_app(), "/magic?token=definitely-invalid").await;
assert_eq!(
count_header_values(&resp, "referrer-policy"),
1,