diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 3f8872b..77fa4e4 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use std::time::SystemTime; use axum::extract::State; +use axum::http::HeaderMap; use axum::response::Redirect; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; @@ -35,6 +36,7 @@ pub async fn handle_analyze_case( State(analyze_tx): State, State(events_tx): State, CaseIdPath(case_id): CaseIdPath, + headers: HeaderMap, ) -> Result { if !config.llm_configured() { return Err(AppError::ServiceUnavailable( @@ -85,7 +87,36 @@ pub async fn handle_analyze_case( CaseEventKind::AnalysisQueued, ); - Ok(Redirect::to("/web/cases")) + Ok(Redirect::to(&resolve_return_path(&headers))) +} + +/// Resolve the post-action redirect target from the `Referer` header. +/// +/// Extracts only the path component and requires it to start with `/web/`. +/// Schema and host are dropped entirely, so the resulting redirect is always +/// same-origin — a hostile `Referer` cannot cause an open redirect. +fn resolve_return_path(headers: &HeaderMap) -> String { + const FALLBACK: &str = "/web/cases"; + let Some(referer) = headers + .get(axum::http::header::REFERER) + .and_then(|v| v.to_str().ok()) + else { + return FALLBACK.into(); + }; + // Strip `scheme://host` prefix if present; keep everything from the first + // `/` onward. A relative Referer (rare but valid) passes through unchanged. + let path_and_query = match referer.split_once("://") { + Some((_, rest)) => match rest.find('/') { + Some(idx) => &rest[idx..], + None => return FALLBACK.into(), + }, + None => referer, + }; + if path_and_query.starts_with("/web/") { + path_and_query.to_string() + } else { + FALLBACK.into() + } } /// Build an `AnalysisInput` for the given case directory. @@ -426,3 +457,65 @@ async fn restore_batch( } count } + +#[cfg(test)] +mod tests { + use super::resolve_return_path; + use axum::http::{HeaderMap, HeaderValue, header::REFERER}; + + fn headers_with_referer(value: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(REFERER, HeaderValue::from_str(value).unwrap()); + h + } + + #[test] + fn no_referer_falls_back_to_case_list() { + assert_eq!(resolve_return_path(&HeaderMap::new()), "/web/cases"); + } + + #[test] + fn absolute_referer_returns_path_only() { + let h = headers_with_referer("https://doctate.internal/web/cases/ABCD"); + assert_eq!(resolve_return_path(&h), "/web/cases/ABCD"); + } + + #[test] + fn query_string_is_preserved() { + let h = headers_with_referer("https://doctate.internal/web/cases/ABCD?x=1"); + assert_eq!(resolve_return_path(&h), "/web/cases/ABCD?x=1"); + } + + #[test] + fn hostile_host_is_stripped() { + // Open-redirect protection: the host component is discarded; only the + // path is reused. A crafted referer cannot redirect off-site. + let h = headers_with_referer("https://evil.example/web/cases/ABCD"); + assert_eq!(resolve_return_path(&h), "/web/cases/ABCD"); + } + + #[test] + fn non_web_path_falls_back() { + let h = headers_with_referer("https://doctate.internal/admin/secret"); + assert_eq!(resolve_return_path(&h), "/web/cases"); + } + + #[test] + fn relative_referer_passes_through() { + let h = headers_with_referer("/web/cases/XYZ"); + assert_eq!(resolve_return_path(&h), "/web/cases/XYZ"); + } + + #[test] + fn bare_host_without_path_falls_back() { + let h = headers_with_referer("https://doctate.internal"); + assert_eq!(resolve_return_path(&h), "/web/cases"); + } + + #[test] + fn non_ascii_header_falls_back() { + let mut h = HeaderMap::new(); + h.insert(REFERER, HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap()); + assert_eq!(resolve_return_path(&h), "/web/cases"); + } +} diff --git a/server/templates/case_page.html b/server/templates/case_page.html index 18c5ab2..52cd42c 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -9,6 +9,8 @@ header { display: flex; justify-content: space-between; align-items: center; } header form { margin: 0; } .back { color: #4a90e2; text-decoration: none; } h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; } +h1 .title { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; flex: 1; min-width: 0; } +h1 .delete-form { margin: 0 0 0 auto; } .meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; } .status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.6em; font-weight: bold; color: white; vertical-align: middle; } .status-badge.open { background: #4a90e2; } @@ -18,9 +20,9 @@ h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; } .actions button { font-size: 1em; padding: 0.5em 1em; } .actions .analyzing { color: #888; font-style: italic; } .actions .llm-missing { color: #a66; font-style: italic; } -.actions .delete-form { margin-left: auto; } -.actions .delete-btn { padding: 0.4em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; } -.actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; } +.actions:empty { display: none; } +.delete-btn { padding: 0.4em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; font-size: 1em; } +.delete-btn:hover { color: #e24a4a; background: #fff0f0; } .placeholder { color: #888; font-style: italic; margin: 1em 0; } .status-panel { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; } .recordings-link { display: inline-block; margin: 1em 0; color: #4a90e2; text-decoration: none; font-size: 0.95em; } @@ -30,7 +32,9 @@ h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; } .doc-content p:first-child { margin-top: 0; } .doc-content p:last-child { margin-bottom: 0; } .doc-content mark { background: #fff3a8; padding: 0 0.2em; border-radius: 2px; } -.copy-btn { position: absolute; bottom: 0.5em; right: 0.5em; padding: 0.35em; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #888; display: inline-flex; align-items: center; justify-content: center; line-height: 0; opacity: 0.45; transition: opacity 0.15s, color 0.15s; } +.doc-actions { position: absolute; bottom: 0.5em; right: 0.5em; display: flex; gap: 0.2em; } +.doc-actions form { margin: 0; } +.copy-btn { padding: 0.35em; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #888; display: inline-flex; align-items: center; justify-content: center; line-height: 0; opacity: 0.45; transition: opacity 0.15s, color 0.15s; } .copy-btn:hover { color: #4a90e2; opacity: 1; } .copy-btn .icon-check { display: none; } .copy-btn.copied { color: #2d8c2d; opacity: 1; } @@ -58,35 +62,40 @@ try {

+ {% match oneliner %} {% when Some with (t) %}{{ t }} {% when None %}Fall {% endmatch %} {% if has_document %}ausgewertet{% else %}offen{% endif %} + +

{% if is_admin %}
{{ case_id }}
{% endif %}
{% if analyzing %} wird analysiert … -{% else if can_analyze %} -
+{% else if can_analyze && !has_document %} +
{% else if llm_missing && !has_document %} LLM-Analyse nicht konfiguriert {% endif %} {% if is_admin %}
{% endif %} -
{% match document_html %} {% when Some with (html) %}
+
+{% if can_analyze %}
{% endif %} +
{{ html|safe }}