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:
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(¶ms.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(¶ms.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