Skip to content

Commit c6d7fbb

Browse files
yoyo930021claude
andcommitted
fix: harden click tracking against open redirect and URL tampering
- Include URL in HMAC-SHA256 openhash so click tracking links are tamper-proof; replacing the `url` parameter invalidates the hash - Add URL scheme validation in track_click to reject non-http(s) URLs - Set SameSite=Strict and conditional Secure flag on admin session cookie based on whether BASE_URL uses HTTPS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ab3e42f commit c6d7fbb

File tree

4 files changed

+118
-30
lines changed

4 files changed

+118
-30
lines changed

src/newsletter.rs

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -119,23 +119,25 @@ pub fn personalize_email(
119119

120120
/// Rewrite all http/https links in HTML to go through `/r/c` click tracking.
121121
/// Each link becomes `/r/c?ucode=...&topic=...&hash=...&url=<original>`.
122-
/// This is per-subscriber (each subscriber gets their own openhash).
122+
/// The hash is HMAC-SHA256 over (ucode, topic, url), so the URL is tamper-proof.
123+
/// This is per-subscriber (each subscriber gets their own hash per link).
123124
pub fn rewrite_links_for_tracking(
124125
html: &str,
125126
base_url: &str,
126127
ucode: &str,
127128
topic: &str,
128-
openhash: &str,
129+
secret_code: &str,
129130
) -> String {
130131
let re = Regex::new(r#"href="(https?://[^"]+)""#).expect("valid regex");
131132
re.replace_all(html, |caps: &regex::Captures| {
132133
let original_url = &caps[1];
134+
let hash = security::compute_openhash(secret_code, ucode, topic, original_url);
133135
let tracking_url = format!(
134136
"{}/r/c?ucode={}&topic={}&hash={}&url={}",
135137
base_url,
136138
urlencoding::encode(ucode),
137139
urlencoding::encode(topic),
138-
urlencoding::encode(openhash),
140+
urlencoding::encode(&hash),
139141
urlencoding::encode(original_url),
140142
);
141143
format!("href=\"{tracking_url}\"")
@@ -292,17 +294,17 @@ pub async fn send_newsletter(
292294
continue;
293295
}
294296

295-
// Compute per-subscriber tracking
296-
let openhash = security::compute_openhash(secret_code, ucode, &slug);
297+
// Compute per-subscriber open-tracking pixel hash (no URL)
298+
let openhash = security::compute_openhash(secret_code, ucode, &slug, "");
297299
let tracking_pixel = build_tracking_pixel(&state.config.base_url, ucode, &slug, &openhash);
298300

299-
// Rewrite links for per-subscriber click tracking
301+
// Rewrite links for per-subscriber click tracking (each link gets its own HMAC)
300302
let tracked_html = rewrite_links_for_tracking(
301303
&shortened_html,
302304
&state.config.base_url,
303305
ucode,
304306
&slug,
305-
&openhash,
307+
secret_code,
306308
);
307309
let tracked_html = replace_recipient_name(&tracked_html, name);
308310

@@ -717,26 +719,39 @@ mod tests {
717719

718720
#[test]
719721
fn test_rewrite_links_for_tracking() {
720-
let html = r#"<a href="https://coscup.org">COSCUP</a> and <a href="https://example.com/page">Example</a>"#;
722+
let secret = "mysecret";
723+
let ucode = "abc123";
724+
let topic = "nl-01";
725+
let url1 = "https://coscup.org";
726+
let url2 = "https://example.com/page";
727+
728+
let html = format!(r#"<a href="{url1}">COSCUP</a> and <a href="{url2}">Example</a>"#);
721729
let result = rewrite_links_for_tracking(
722-
html,
730+
&html,
723731
"https://newsletter.coscup.org",
724-
"abc123",
725-
"nl-01",
726-
"myhash",
732+
ucode,
733+
topic,
734+
secret,
727735
);
736+
728737
assert!(result.contains("/r/c?"));
729738
assert!(result.contains("ucode=abc123"));
730739
assert!(result.contains("topic=nl-01"));
731-
assert!(result.contains("hash=myhash"));
732740
assert!(result.contains("url=https%3A%2F%2Fcoscup.org"));
733741
assert!(result.contains("url=https%3A%2F%2Fexample.com%2Fpage"));
742+
743+
// Each link has its own per-URL hash
744+
let hash1 = security::compute_openhash(secret, ucode, topic, url1);
745+
let hash2 = security::compute_openhash(secret, ucode, topic, url2);
746+
assert_ne!(hash1, hash2);
747+
assert!(result.contains(&urlencoding::encode(&hash1).to_string()));
748+
assert!(result.contains(&urlencoding::encode(&hash2).to_string()));
734749
}
735750

736751
#[test]
737752
fn test_rewrite_links_skips_non_http() {
738753
let html = r##"<a href="mailto:hi@coscup.org">Mail</a> <a href="#top">Top</a>"##;
739-
let result = rewrite_links_for_tracking(html, "https://x.com", "u", "t", "h");
754+
let result = rewrite_links_for_tracking(html, "https://x.com", "u", "t", "secret");
740755
// Non-http links should be unchanged
741756
assert!(result.contains("mailto:hi@coscup.org"));
742757
assert!(result.contains("#top"));

src/routes/admin.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::net::SocketAddr;
33
use axum::extract::{ConnectInfo, Multipart, Path, Query, State};
44
use axum::http::{header, HeaderMap};
55
use axum::response::{Html, IntoResponse, Redirect, Response};
6+
use axum_extra::extract::cookie::SameSite;
67
use axum_extra::extract::CookieJar;
78
use chrono::Utc;
89
use serde::Deserialize;
@@ -127,9 +128,12 @@ pub async fn auth_magic_link(
127128
)
128129
.await;
129130

131+
let is_https = state.config.base_url.starts_with("https://");
130132
let cookie = axum_extra::extract::cookie::Cookie::build((SESSION_COOKIE, session_token))
131133
.path("/admin")
132134
.http_only(true)
135+
.secure(is_https)
136+
.same_site(SameSite::Strict)
133137
.max_age(time::Duration::hours(24))
134138
.build();
135139

@@ -418,7 +422,7 @@ pub async fn export_csv(State(state): State<AppState>) -> Result<Response, AppEr
418422
.into_iter()
419423
.map(|(email, name, ucode, status, secret_code)| {
420424
let admin_link = security::compute_admin_link(&secret_code, &email);
421-
let openhash = security::compute_openhash(&secret_code, &ucode, "");
425+
let openhash = security::compute_openhash(&secret_code, &ucode, "", "");
422426
ExportCsvRecord {
423427
email,
424428
name,

src/routes/tracking.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pub async fn track_open(
3838
.await?;
3939

4040
if let Some((secret_code,)) = subscriber {
41-
if security::verify_openhash(&secret_code, &query.ucode, &query.topic, &query.hash) {
41+
if security::verify_openhash(&secret_code, &query.ucode, &query.topic, "", &query.hash) {
4242
let user_agent = headers
4343
.get(header::USER_AGENT)
4444
.and_then(|v| v.to_str().ok())
@@ -75,6 +75,11 @@ pub async fn track_click(
7575
.as_deref()
7676
.ok_or_else(|| AppError::BadRequest("Missing url parameter".to_string()))?;
7777

78+
// Validate redirect URL to prevent open redirect attacks
79+
if !redirect_url.starts_with("https://") && !redirect_url.starts_with("http://") {
80+
return Err(AppError::BadRequest("Invalid redirect URL".to_string()));
81+
}
82+
7883
// Verify openhash
7984
let subscriber =
8085
sqlx::query_as::<_, (String,)>("SELECT secret_code FROM subscribers WHERE ucode = $1")
@@ -83,7 +88,13 @@ pub async fn track_click(
8388
.await?;
8489

8590
if let Some((secret_code,)) = subscriber {
86-
if security::verify_openhash(&secret_code, &query.ucode, &query.topic, &query.hash) {
91+
if security::verify_openhash(
92+
&secret_code,
93+
&query.ucode,
94+
&query.topic,
95+
redirect_url,
96+
&query.hash,
97+
) {
8798
let user_agent = headers
8899
.get(header::USER_AGENT)
89100
.and_then(|v| v.to_str().ok())

src/security.rs

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,12 @@ pub fn compute_admin_link(secret_code: &str, email: &str) -> String {
3434
hex::encode(hasher.finalize())
3535
}
3636

37-
/// Compute openhash = HMAC-SHA256(secret_code, "ucode:topic").
38-
pub fn compute_openhash(secret_code: &str, ucode: &str, topic: &str) -> String {
37+
/// Compute openhash = HMAC-SHA256(secret_code, "ucode:topic:url").
38+
/// For open-tracking (no URL), pass `url = ""`.
39+
pub fn compute_openhash(secret_code: &str, ucode: &str, topic: &str, url: &str) -> String {
3940
let mut mac =
4041
HmacSha256::new_from_slice(secret_code.as_bytes()).expect("HMAC accepts any key length");
41-
let message = format!("{ucode}:{topic}");
42+
let message = format!("{ucode}:{topic}:{url}");
4243
mac.update(message.as_bytes());
4344
hex::encode(mac.finalize().into_bytes())
4445
}
@@ -54,8 +55,15 @@ pub fn verify_admin_link(provided: &str, expected: &str) -> bool {
5455
}
5556

5657
/// Verify openhash using constant-time comparison.
57-
pub fn verify_openhash(secret_code: &str, ucode: &str, topic: &str, provided: &str) -> bool {
58-
let expected = compute_openhash(secret_code, ucode, topic);
58+
/// For open-tracking (no URL), pass `url = ""`.
59+
pub fn verify_openhash(
60+
secret_code: &str,
61+
ucode: &str,
62+
topic: &str,
63+
url: &str,
64+
provided: &str,
65+
) -> bool {
66+
let expected = compute_openhash(secret_code, ucode, topic, url);
5967
verify_admin_link(provided, &expected)
6068
}
6169

@@ -116,27 +124,77 @@ mod tests {
116124

117125
#[test]
118126
fn test_compute_openhash_deterministic() {
119-
let h1 = compute_openhash("secret", "abc123", "newsletter-01");
120-
let h2 = compute_openhash("secret", "abc123", "newsletter-01");
127+
let h1 = compute_openhash("secret", "abc123", "newsletter-01", "");
128+
let h2 = compute_openhash("secret", "abc123", "newsletter-01", "");
121129
assert_eq!(h1, h2);
122130
}
123131

132+
#[test]
133+
fn test_compute_openhash_url_changes_hash() {
134+
let h1 = compute_openhash("secret", "abc123", "newsletter-01", "");
135+
let h2 = compute_openhash("secret", "abc123", "newsletter-01", "https://coscup.org");
136+
assert_ne!(h1, h2);
137+
}
138+
124139
#[test]
125140
fn test_verify_openhash_correct() {
126-
let hash = compute_openhash("secret", "abc123", "newsletter-01");
127-
assert!(verify_openhash("secret", "abc123", "newsletter-01", &hash));
141+
let hash = compute_openhash("secret", "abc123", "newsletter-01", "");
142+
assert!(verify_openhash(
143+
"secret",
144+
"abc123",
145+
"newsletter-01",
146+
"",
147+
&hash
148+
));
149+
}
150+
151+
#[test]
152+
fn test_verify_openhash_correct_with_url() {
153+
let url = "https://coscup.org/2025";
154+
let hash = compute_openhash("secret", "abc123", "newsletter-01", url);
155+
assert!(verify_openhash(
156+
"secret",
157+
"abc123",
158+
"newsletter-01",
159+
url,
160+
&hash
161+
));
162+
}
163+
164+
#[test]
165+
fn test_verify_openhash_wrong_url() {
166+
let hash = compute_openhash("secret", "abc123", "newsletter-01", "https://coscup.org");
167+
assert!(!verify_openhash(
168+
"secret",
169+
"abc123",
170+
"newsletter-01",
171+
"https://evil.com",
172+
&hash
173+
));
128174
}
129175

130176
#[test]
131177
fn test_verify_openhash_wrong_topic() {
132-
let hash = compute_openhash("secret", "abc123", "newsletter-01");
133-
assert!(!verify_openhash("secret", "abc123", "newsletter-02", &hash));
178+
let hash = compute_openhash("secret", "abc123", "newsletter-01", "");
179+
assert!(!verify_openhash(
180+
"secret",
181+
"abc123",
182+
"newsletter-02",
183+
"",
184+
&hash
185+
));
134186
}
135187

136188
#[test]
137189
fn test_verify_openhash_wrong_secret() {
138-
let hash = compute_openhash("secret", "abc123", "newsletter-01");
139-
assert!(!verify_openhash("wrong", "abc123", "newsletter-01", &hash));
190+
let hash = compute_openhash("secret", "abc123", "newsletter-01", "");
191+
assert!(!verify_openhash(
192+
"wrong",
193+
"abc123",
194+
"newsletter-01",
195+
"",
196+
&hash
197+
));
140198
}
141199

142200
#[test]

0 commit comments

Comments
 (0)