kych

OAuth 2.0 API for Swiyu to enable Taler integration of Swiyu for KYC (experimental)
Log | Files | Refs | README

commit 9c58a4c727e36c10fe6e1eec241d2a7530cfbc97
parent 933cde3ff2aee1e275d1e777aad2ccf221a6b553
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date:   Mon, 19 Jan 2026 19:39:49 +0100

Harden authorize flow against XSS and open redirects

Prevent XSS by JSON-encoding all template variables used in JavaScript, ensuring
injected content is safely escaped. Prevent open redirects by validating URLs
before templating: only https:// URLs are allowed for external links, and only
swiyu:// or https:// schemes are allowed for deeplinks; invalid values now
result in an error response.

Diffstat:
Mkych_oauth2_gateway/src/handlers.rs | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mkych_oauth2_gateway/templates/authorize.html | 6+++---
2 files changed, 74 insertions(+), 16 deletions(-)

diff --git a/kych_oauth2_gateway/src/handlers.rs b/kych_oauth2_gateway/src/handlers.rs @@ -10,6 +10,22 @@ use serde_json::json; use crate::{crypto, db::sessions::SessionStatus, models::*, state::AppState}; +fn is_safe_url(url: &str) -> bool { + url.to_lowercase().starts_with("https://") +} + +fn is_safe_deeplink(url: &str) -> bool { + if url.is_empty() { + return true; + } + let url_lower = url.to_lowercase(); + url_lower.starts_with("swiyu://") || url_lower.starts_with("https://") +} + +fn json_encode_string(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()) +} + // Health check endpoint pub async fn health_check() -> impl IntoResponse { tracing::info!("Received Health Request"); @@ -224,17 +240,38 @@ pub async fn authorize( #[derive(Template)] #[template(path = "authorize.html")] struct AuthorizeTemplate { - verification_id: String, verification_url: String, verification_deeplink: String, - state: String, + verification_id_json: String, + verification_url_json: String, + state_json: String, + } + + let verification_url = data.verification_url.clone().unwrap_or_default(); + let verification_deeplink = data.verification_deeplink.clone().unwrap_or_default(); + + if !is_safe_url(&verification_url) { + tracing::error!("Invalid verification_url scheme: {}", verification_url); + return Err(( + StatusCode::BAD_GATEWAY, + Json(ErrorResponse::new("invalid_verification_url")), + )); + } + + if !is_safe_deeplink(&verification_deeplink) { + tracing::error!("Invalid verification_deeplink scheme: {}", verification_deeplink); + return Err(( + StatusCode::BAD_GATEWAY, + Json(ErrorResponse::new("invalid_verification_deeplink")), + )); } let template = AuthorizeTemplate { - verification_id: verification_id.to_string(), - verification_url: data.verification_url.clone().unwrap_or_default(), - verification_deeplink: data.verification_deeplink.clone().unwrap_or_default(), - state: params.state.clone(), + verification_url: verification_url.clone(), + verification_deeplink, + verification_id_json: json_encode_string(&verification_id.to_string()), + verification_url_json: json_encode_string(&verification_url), + state_json: json_encode_string(&params.state), }; let html = template.render().map_err(|e| { @@ -361,23 +398,44 @@ pub async fn authorize( .and_then(|h| h.to_str().ok()) .map_or(false, |v| v.contains("text/html")); + let verification_url = result.verification_url.clone(); + let verification_deeplink = swiyu_response.verification_deeplink.clone().unwrap_or_default(); + + if !is_safe_url(&verification_url) { + tracing::error!("Invalid verification_url scheme: {}", verification_url); + return Err(( + StatusCode::BAD_GATEWAY, + Json(ErrorResponse::new("invalid_verification_url")), + )); + } + + if !is_safe_deeplink(&verification_deeplink) { + tracing::error!("Invalid verification_deeplink scheme: {}", verification_deeplink); + return Err(( + StatusCode::BAD_GATEWAY, + Json(ErrorResponse::new("invalid_verification_deeplink")), + )); + } + if accept_html { use askama::Template; #[derive(Template)] #[template(path = "authorize.html")] struct AuthorizeTemplate { - verification_id: String, verification_url: String, verification_deeplink: String, - state: String, + verification_id_json: String, + verification_url_json: String, + state_json: String, } let template = AuthorizeTemplate { - verification_id: swiyu_response.id.to_string(), - verification_url: result.verification_url.clone(), - verification_deeplink: swiyu_response.verification_deeplink.clone().unwrap_or_default(), - state: params.state.clone(), + verification_url: verification_url.clone(), + verification_deeplink: verification_deeplink.clone(), + verification_id_json: json_encode_string(&swiyu_response.id.to_string()), + verification_url_json: json_encode_string(&verification_url), + state_json: json_encode_string(&params.state), }; let html = template.render().map_err(|e| { @@ -397,7 +455,7 @@ pub async fn authorize( Ok(PrettyJson(AuthorizeResponse { verification_id: swiyu_response.id, - verification_url: result.verification_url, + verification_url, verification_deeplink: swiyu_response.verification_deeplink, state: params.state.clone() }).into_response()) diff --git a/kych_oauth2_gateway/templates/authorize.html b/kych_oauth2_gateway/templates/authorize.html @@ -114,12 +114,12 @@ </div> <script> - const verificationId = "{{ verification_id }}"; - const state = "{{ state }}"; + const verificationId = {{ verification_id_json|safe }}; + const state = {{ state_json|safe }}; try { new QRCode(document.getElementById("qrcode"), { - text: "{{ verification_url }}", + text: {{ verification_url_json|safe }}, width: 256, height: 256, correctLevel: QRCode.CorrectLevel.M