use axum::{
extract::{Path, Query},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use tracing::{info, warn};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Deserialize)]
pub struct ClickQuery {
pub u: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedClickRef {
pub kref: String,
pub verified: bool,
}
const SIG_LEN: usize = 8;
fn split_signed(raw: &[u8]) -> Option<(&[u8], &[u8])> {
if raw.len() < SIG_LEN + 1 {
return None;
}
let sep_idx = raw.len() - SIG_LEN - 1;
if raw[sep_idx] != b':' {
return None;
}
Some((&raw[..sep_idx], &raw[sep_idx + 1..]))
}
pub fn decode_kref(token: &str, secret: Option<&str>) -> Result<DecodedClickRef, String> {
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(token.as_bytes())
.map_err(|e| format!("base64 decode failed: {e}"))?;
let signed_split = split_signed(&raw);
let Some(secret) = secret else {
let kref = String::from_utf8_lossy(&raw).into_owned();
return Ok(DecodedClickRef {
kref,
verified: false,
});
};
let Some((body_bytes, sig_bytes)) = signed_split else {
let kref = String::from_utf8_lossy(&raw).into_owned();
return Ok(DecodedClickRef {
kref,
verified: false,
});
};
let kref =
String::from_utf8(body_bytes.to_vec()).map_err(|e| format!("kref is not utf-8: {e}"))?;
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| format!("hmac key init: {e}"))?;
mac.update(body_bytes);
let full = mac.finalize().into_bytes();
let expected = &full[..SIG_LEN];
let verified = constant_time_eq(expected, sig_bytes);
Ok(DecodedClickRef { kref, verified })
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn click_secret() -> Option<String> {
match std::env::var("CLICK_TRACKING_SECRET") {
Ok(v) if !v.trim().is_empty() => Some(v),
_ => None,
}
}
fn click_allowed_hosts() -> Vec<String> {
std::env::var("CLICK_TRACKING_ALLOWED_HOSTS")
.ok()
.map(|v| {
v.split(',')
.map(|h| h.trim().to_ascii_lowercase())
.filter(|h| !h.is_empty())
.collect()
})
.unwrap_or_default()
}
fn is_safe_redirect_dest(dest: &str, allowed_hosts: &[String]) -> bool {
let Ok(url) = reqwest::Url::parse(dest) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
let host = match url.host_str() {
Some(h) if !h.is_empty() => h.to_ascii_lowercase(),
_ => return false,
};
if allowed_hosts.is_empty() {
return true;
}
allowed_hosts
.iter()
.any(|a| host == *a || host.ends_with(&format!(".{a}")))
}
pub async fn handle_click(
Path(encoded): Path<String>,
Query(query): Query<ClickQuery>,
) -> impl IntoResponse {
let secret = click_secret();
let decoded = decode_kref(&encoded, secret.as_deref());
let dest = match query.u.as_deref() {
Some(u) if !u.is_empty() => u.to_string(),
_ => {
warn!(
"click_tracking: missing 'u' query param for token {}",
truncate(&encoded, 40)
);
return (StatusCode::BAD_REQUEST, "click tracker missing destination").into_response();
}
};
if !is_safe_redirect_dest(&dest, &click_allowed_hosts()) {
warn!(
"click_tracking: refusing unsafe redirect destination for token {}",
truncate(&encoded, 40)
);
return (StatusCode::BAD_REQUEST, "click tracker invalid destination").into_response();
}
let redirect_allowed = match decoded {
Ok(DecodedClickRef { kref, verified }) => {
info!(
target: "click_tracking",
kref = %kref,
verified = verified,
dest = %dest,
"click received"
);
secret.is_none() || verified
}
Err(err) => {
warn!(
"click_tracking: failed to decode token {}: {}",
truncate(&encoded, 40),
err
);
secret.is_none()
}
};
if !redirect_allowed {
return (
StatusCode::BAD_REQUEST,
"click tracker invalid or unverified token",
)
.into_response();
}
Redirect::to(&dest).into_response()
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}…", &s[..max])
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
#[test]
fn safe_redirect_accepts_http_and_https() {
assert!(is_safe_redirect_dest("https://example.com/path?q=1", &[]));
assert!(is_safe_redirect_dest("http://example.com", &[]));
}
#[test]
fn safe_redirect_rejects_dangerous_schemes_and_relative() {
for bad in [
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"//evil.com", "/local/path", "evil.com", "", "ftp://example.com", ] {
assert!(
!is_safe_redirect_dest(bad, &[]),
"should reject unsafe dest: {bad:?}"
);
}
}
#[test]
fn safe_redirect_enforces_host_allow_list() {
let allow = vec!["example.com".to_string()];
assert!(is_safe_redirect_dest("https://example.com/x", &allow));
assert!(is_safe_redirect_dest("https://mail.example.com/x", &allow)); assert!(!is_safe_redirect_dest("https://evil.com/x", &allow));
assert!(!is_safe_redirect_dest("https://notexample.com/x", &allow));
assert!(!is_safe_redirect_dest("https://evilexample.com/x", &allow));
}
const KREF: &str = "kref://Revka/Outreach/contacts/acme.contact";
const SECRET: &str = "s3cret";
fn encode_token(kref: &str, secret: Option<&str>) -> String {
let body = kref.as_bytes();
let payload = if let Some(s) = secret {
let mut mac = HmacSha256::new_from_slice(s.as_bytes()).unwrap();
mac.update(body);
let sig = mac.finalize().into_bytes();
let mut combined = Vec::with_capacity(body.len() + 1 + 8);
combined.extend_from_slice(body);
combined.push(b':');
combined.extend_from_slice(&sig[..8]);
combined
} else {
body.to_vec()
};
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload)
}
#[test]
fn decode_unsigned_roundtrip() {
let token = encode_token(KREF, None);
let out = decode_kref(&token, None).unwrap();
assert_eq!(out.kref, KREF);
assert!(!out.verified);
}
#[test]
fn decode_signed_roundtrip_verifies() {
let token = encode_token(KREF, Some(SECRET));
let out = decode_kref(&token, Some(SECRET)).unwrap();
assert_eq!(out.kref, KREF);
assert!(out.verified, "matching secret should set verified=true");
}
#[test]
fn wrong_secret_decodes_but_does_not_verify() {
let token = encode_token(KREF, Some(SECRET));
let out = decode_kref(&token, Some("wrong")).unwrap();
assert_eq!(out.kref, KREF);
assert!(!out.verified);
}
#[test]
fn signed_token_decoded_without_secret_still_extracts_kref() {
let token = encode_token(KREF, Some(SECRET));
let out = decode_kref(&token, None).unwrap();
assert!(!out.verified);
assert!(out.kref.starts_with(KREF));
}
#[test]
fn invalid_base64_errors() {
let result = decode_kref("not!valid!base64@@@", None);
assert!(result.is_err());
}
#[test]
fn unsigned_token_with_secret_present_does_not_verify() {
let token = encode_token(KREF, None);
let out = decode_kref(&token, Some(SECRET)).unwrap();
assert_eq!(out.kref, KREF);
assert!(!out.verified);
}
#[test]
fn constant_time_eq_basics() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"abcd"));
assert!(constant_time_eq(b"", b""));
}
}