From b63c7cc4fc5f23416d62df795b8554ac7ae6aa6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B3=20=F0=9D=99=B2=F0=9D=99=B7=F0=9D=99=B4?= =?UTF-8?q?=F0=9D=99=B5?= Date: Wed, 29 Jul 2026 00:37:49 +0000 Subject: [PATCH 1/4] fix(api): use char-safe truncation for header slicing to prevent panics --- services/api/src/audit_middleware.rs | 22 +++++++++++++++++++++- services/api/src/idempotency.rs | 17 ++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/services/api/src/audit_middleware.rs b/services/api/src/audit_middleware.rs index a9434a97..1fea9705 100644 --- a/services/api/src/audit_middleware.rs +++ b/services/api/src/audit_middleware.rs @@ -62,6 +62,14 @@ fn key_prefix(key: &str) -> String { format!("{}****", chars) } +/// Char-safe prefix of `s`, taking the first `n` characters. +/// +/// Unlike a raw byte slice (`&s[..n]`), this never panics when a multi-byte +/// UTF-8 character straddles the cut point. +fn safe_char_prefix(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} + // ── middleware ──────────────────────────────────────────────────────────────── /// Middleware that automatically logs admin operations **and** authentication @@ -85,7 +93,7 @@ pub async fn audit_logging_middleware( let actor = headers .get("x-api-key") .and_then(|v| v.to_str().ok()) - .map(|k| format!("api_key:{}", &k[..8.min(k.len())])) + .map(|k| format!("api_key:{}", safe_char_prefix(k, 8))) .or_else(|| { headers .get("authorization") @@ -335,6 +343,18 @@ mod tests { assert_eq!(key_prefix(""), "****"); } + // ── safe_char_prefix ───────────────────────────────────────────────────── + + #[test] + fn safe_char_prefix_does_not_panic_on_multibyte_utf8_boundary() { + // 7 ASCII bytes followed by a 2-byte UTF-8 character straddle byte + // offset 8, which is not a char boundary — a raw `&s[..8]` slice + // panics with "byte index 8 is not a char boundary". + let crafted = format!("{}{}", "a".repeat(7), "é"); + let result = safe_char_prefix(&crafted, 8); + assert_eq!(result, crafted); + } + // ── AuthFailureReason::as_str ──────────────────────────────────────────── #[test] diff --git a/services/api/src/idempotency.rs b/services/api/src/idempotency.rs index 67da96a0..65f580a3 100644 --- a/services/api/src/idempotency.rs +++ b/services/api/src/idempotency.rs @@ -53,7 +53,7 @@ fn extract_user_identity(req: &Request) -> String { req.headers() .get("authorization") .and_then(|v| v.to_str().ok()) - .map(|s| format!("auth:{}", &s[..32.min(s.len())])) + .map(|s| format!("auth:{}", s.chars().take(32).collect::())) }) .unwrap_or_else(|| "anonymous".to_string()) } @@ -264,4 +264,19 @@ mod tests { let store = IdempotencyStore::new(Duration::from_secs(60)); assert!(store.get("user_a", "nonexistent").unwrap().is_none()); } + + #[test] + fn extract_user_identity_does_not_panic_on_multibyte_utf8_boundary() { + // 31 ASCII bytes followed by a 2-byte UTF-8 character straddle byte + // offset 32, which is not a char boundary — `&s[..32]` used to panic. + let crafted = format!("{}{}", "a".repeat(31), "é"); + let req = Request::builder() + .uri("/") + .header("authorization", crafted.as_str()) + .body(Body::empty()) + .unwrap(); + + let identity = extract_user_identity(&req); + assert!(identity.starts_with("auth:")); + } } From 87516f197d9382415f20cf1a7879e60b76a82e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B3=20=F0=9D=99=B2=F0=9D=99=B7=F0=9D=99=B4?= =?UTF-8?q?=F0=9D=99=B5?= Date: Wed, 29 Jul 2026 00:38:28 +0000 Subject: [PATCH 2/4] fix(api): stop idempotency middleware bucketing all unauthenticated clients under one shared identity --- services/api/src/idempotency.rs | 62 +++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/services/api/src/idempotency.rs b/services/api/src/idempotency.rs index 65f580a3..92fba29c 100644 --- a/services/api/src/idempotency.rs +++ b/services/api/src/idempotency.rs @@ -12,7 +12,7 @@ use std::{ use axum::{ body::Body, - extract::{Request, State}, + extract::{ConnectInfo, Request, State}, http::{HeaderValue, StatusCode}, middleware::Next, response::{IntoResponse, Response}, @@ -20,7 +20,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::AppState; +use crate::{security::extract_client_ip_cidrs, AppState}; const IDEMPOTENCY_HEADER: &str = "Idempotency-Key"; const MAX_KEY_LEN: usize = 128; @@ -40,8 +40,13 @@ fn idempotency_cache_key(user_id: &str, raw_key: &str) -> String { } /// Extract a stable identity string from request headers. +/// /// Uses the API key prefix, or falls back to the Authorization header value. -fn extract_user_identity(req: &Request) -> String { +/// If neither credential is present, the caller is unauthenticated: identity +/// is derived from the client IP + User-Agent rather than a single fixed +/// `"anonymous"` bucket, so unrelated unauthenticated clients never collide +/// on the same scoped cache key (issue #1104). +fn extract_user_identity(req: &Request, client_ip: &str) -> String { req.headers() .get("x-api-key") .and_then(|v| v.to_str().ok()) @@ -55,7 +60,17 @@ fn extract_user_identity(req: &Request) -> String { .and_then(|v| v.to_str().ok()) .map(|s| format!("auth:{}", s.chars().take(32).collect::())) }) - .unwrap_or_else(|| "anonymous".to_string()) + .unwrap_or_else(|| { + let user_agent = req + .headers() + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let hash = hex::encode(Sha256::digest( + format!("{client_ip}|{user_agent}").as_bytes(), + )); + format!("anon:{}", hash) + }) } /// Middleware that deduplicates POST requests using an `Idempotency-Key` header. @@ -66,6 +81,7 @@ fn extract_user_identity(req: &Request) -> String { /// - Otherwise the request is executed, the response is cached, and returned. pub async fn idempotency_middleware( State(state): State>, + connect_info: Option>, req: Request, next: Next, ) -> Response { @@ -80,7 +96,13 @@ pub async fn idempotency_middleware( None => return next.run(req).await, }; - let user_id = extract_user_identity(&req); + let client_ip = extract_client_ip_cidrs( + req.headers(), + connect_info.as_ref(), + state.config.trust_proxy, + &state.config.trusted_proxy_cidrs, + ); + let user_id = extract_user_identity(&req, &client_ip); let cache_key = idempotency_cache_key(&user_id, &raw_key); let ttl = Duration::from_secs(state.config.idempotency_window_secs); @@ -276,7 +298,35 @@ mod tests { .body(Body::empty()) .unwrap(); - let identity = extract_user_identity(&req); + let identity = extract_user_identity(&req, "203.0.113.1"); assert!(identity.starts_with("auth:")); } + + #[test] + fn extract_user_identity_differs_for_unauthenticated_clients_on_different_ips() { + let req_a = Request::builder().uri("/").body(Body::empty()).unwrap(); + let req_b = Request::builder().uri("/").body(Body::empty()).unwrap(); + + let identity_a = extract_user_identity(&req_a, "203.0.113.1"); + let identity_b = extract_user_identity(&req_b, "203.0.113.2"); + + assert_ne!( + identity_a, identity_b, + "unauthenticated clients on different IPs must not share a cache identity" + ); + } + + #[test] + fn extract_user_identity_stable_for_same_unauthenticated_client() { + let req_a = Request::builder().uri("/").body(Body::empty()).unwrap(); + let req_b = Request::builder().uri("/").body(Body::empty()).unwrap(); + + let identity_a = extract_user_identity(&req_a, "203.0.113.1"); + let identity_b = extract_user_identity(&req_b, "203.0.113.1"); + + assert_eq!( + identity_a, identity_b, + "the same unauthenticated client retrying must get the same identity" + ); + } } From 61062280a33b549becc50a1f69698b0f2dec71bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B3=20=F0=9D=99=B2=F0=9D=99=B7=F0=9D=99=B4?= =?UTF-8?q?=F0=9D=99=B5?= Date: Wed, 29 Jul 2026 00:38:51 +0000 Subject: [PATCH 3/4] fix(api): mask Authorization header before storing it as audit log actor --- services/api/src/audit_middleware.rs | 57 ++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/services/api/src/audit_middleware.rs b/services/api/src/audit_middleware.rs index 1fea9705..13da8432 100644 --- a/services/api/src/audit_middleware.rs +++ b/services/api/src/audit_middleware.rs @@ -70,6 +70,26 @@ fn safe_char_prefix(s: &str, n: usize) -> String { s.chars().take(n).collect() } +/// Compute the `actor` identity persisted on the standard (non-auth-failure) +/// audit entry. +/// +/// An `Authorization` header is a bearer credential, not a distinguishing +/// prefix like an API key — it must never be persisted verbatim. This +/// mirrors the masking already applied on the auth-failure path below. +fn actor_identity(headers: &HeaderMap) -> String { + headers + .get("x-api-key") + .and_then(|v| v.to_str().ok()) + .map(|k| format!("api_key:{}", safe_char_prefix(k, 8))) + .or_else(|| { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(|_| "token_attempt:****".to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + // ── middleware ──────────────────────────────────────────────────────────────── /// Middleware that automatically logs admin operations **and** authentication @@ -90,17 +110,7 @@ pub async fn audit_logging_middleware( next: Next, ) -> Response { // ── capture request metadata ───────────────────────────────────────────── - let actor = headers - .get("x-api-key") - .and_then(|v| v.to_str().ok()) - .map(|k| format!("api_key:{}", safe_char_prefix(k, 8))) - .or_else(|| { - headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - }) - .unwrap_or_else(|| "unknown".to_string()); + let actor = actor_identity(&headers); let actor_ip = Some(addr.ip()); let user_agent = headers @@ -355,6 +365,31 @@ mod tests { assert_eq!(result, crafted); } + // ── actor_identity ─────────────────────────────────────────────────────── + + #[test] + fn actor_identity_masks_authorization_header_on_success_path() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + "Bearer super-secret-admin-token".parse().unwrap(), + ); + assert_eq!(actor_identity(&headers), "token_attempt:****"); + } + + #[test] + fn actor_identity_uses_masked_api_key_prefix() { + let mut headers = HeaderMap::new(); + headers.insert("x-api-key", "sk-live-abc123".parse().unwrap()); + assert_eq!(actor_identity(&headers), "api_key:sk-live-"); + } + + #[test] + fn actor_identity_unknown_when_no_credentials() { + let headers = HeaderMap::new(); + assert_eq!(actor_identity(&headers), "unknown"); + } + // ── AuthFailureReason::as_str ──────────────────────────────────────────── #[test] From 5d9ecf45b59e42fac54347c3f27ac0e602eb1aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B3=20=F0=9D=99=B2=F0=9D=99=B7=F0=9D=99=B4?= =?UTF-8?q?=F0=9D=99=B5?= Date: Wed, 29 Jul 2026 00:49:13 +0000 Subject: [PATCH 4/4] feat: submisson of all 3 issues --- services/api/src/handlers.rs | 147 +++++++++++++++++++++++- services/api/src/main.rs | 1 + services/api/src/newsletter.rs | 187 ++++++++++++++++++++++++++++++- services/api/src/openapi_spec.rs | 8 +- 4 files changed, 337 insertions(+), 6 deletions(-) diff --git a/services/api/src/handlers.rs b/services/api/src/handlers.rs index 244bcc28..fc4621e1 100644 --- a/services/api/src/handlers.rs +++ b/services/api/src/handlers.rs @@ -349,6 +349,13 @@ pub struct NewsletterSubscribeRequest { #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct NewsletterEmailRequest { pub email: String, + /// Signed verification token obtained from `/gdpr/request-token`. + pub token: String, +} + +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +pub struct NewsletterGdprTokenRequest { + pub email: String, } #[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] @@ -369,6 +376,8 @@ pub struct NewsletterExportQuery { #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct NewsletterExportBody { pub email: String, + /// Signed verification token obtained from `/gdpr/request-token`. + pub token: String, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] @@ -393,6 +402,41 @@ fn normalized_email(raw: &str) -> Option { } } +/// Verify a GDPR verification token for `email`. +/// +/// Returns `Some(response)` when the caller should be short-circuited +/// (missing signing config, or an invalid/expired/mismatched token) and +/// `None` when the token checks out and the request may proceed. +fn check_gdpr_token(state: &AppState, email: &str, token: &str) -> Option { + let Some(secret) = state.config.unsubscribe_signing_secret.as_deref() else { + return Some( + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(NewsletterResponse { + success: false, + message: "GDPR verification not configured.".to_string(), + }), + ) + .into_response(), + ); + }; + + if !crate::newsletter::verify_gdpr_verification_token(token, email, secret) { + return Some( + ( + StatusCode::UNAUTHORIZED, + Json(NewsletterResponse { + success: false, + message: "Invalid or expired verification token.".to_string(), + }), + ) + .into_response(), + ); + } + + None +} + fn is_disposable_email(email: &str) -> bool { const DISPOSABLE_DOMAINS: &[&str] = &["mailinator.com", "tempmail.com", "guerrillamail.com"]; @@ -638,6 +682,91 @@ pub async fn newsletter_unsubscribe( )) } +#[utoipa::path( + post, + path = "/api/v1/newsletter/gdpr/request-token", + tag = "newsletter", + request_body = NewsletterGdprTokenRequest, + responses( + (status = 200, description = "Verification email sent if the address is subscribed", body = NewsletterResponse), + (status = 400, description = "Invalid email", body = NewsletterResponse), + (status = 429, description = "Rate limited", body = NewsletterResponse), + ) +)] +pub async fn newsletter_gdpr_request_token( + State(state): State>, + headers: HeaderMap, + connect_info: Option>, + Json(payload): Json, +) -> Result { + use crate::security::extract_client_ip_cidrs; + let ip = extract_client_ip_cidrs( + &headers, + connect_info.as_ref(), + state.config.trust_proxy, + &state.config.trusted_proxy_cidrs, + ); + let allowed = state + .newsletter_rate_limiter + .allow( + &format!("gdpr_token:ip:{ip}"), + state.config.gdpr_export_rate_limit as usize, + std::time::Duration::from_secs(state.config.gdpr_export_rate_window_secs), + ) + .await; + if !allowed { + return Ok(( + StatusCode::TOO_MANY_REQUESTS, + Json(NewsletterResponse { + success: false, + message: "Too many requests, please try again later.".to_string(), + }), + ) + .into_response()); + } + + let Some(email) = normalized_email(&payload.email) else { + return Ok(( + StatusCode::BAD_REQUEST, + Json(NewsletterResponse { + success: false, + message: "Invalid email address.".to_string(), + }), + ) + .into_response()); + }; + + // Never disclose whether the address exists: only send an email when a + // record is found, but return the same generic response either way. + match state.db.newsletter_get_by_email(&email).await { + Ok(Some(_)) => { + if let Some(secret) = state.config.unsubscribe_signing_secret.as_deref() { + let token = crate::newsletter::generate_gdpr_verification_token(&email, secret); + if let Err(e) = + crate::newsletter::send_gdpr_verification_email(&state.config, &email, &token) + .await + { + tracing::warn!(error = %e, "[newsletter] failed to send GDPR verification email"); + } + } else { + tracing::warn!("[newsletter] GDPR verification requested but no signing secret configured"); + } + } + Ok(None) => {} + Err(e) => tracing::warn!(error = %e, "[newsletter] gdpr token lookup failed"), + } + + Ok(( + StatusCode::OK, + Json(NewsletterResponse { + success: true, + message: "If this email is subscribed, a verification code has been sent." + .to_string(), + }), + ) + .into_response()) +} + #[utoipa::path( post, path = "/api/v1/newsletter/gdpr/export", @@ -646,6 +775,7 @@ pub async fn newsletter_unsubscribe( responses( (status = 200, description = "GDPR data export", body = NewsletterExportResponse), (status = 400, description = "Invalid email", body = NewsletterResponse), + (status = 401, description = "Invalid or expired verification token", body = NewsletterResponse), (status = 404, description = "No record found", body = NewsletterResponse), (status = 429, description = "Rate limited", body = NewsletterResponse), ) @@ -693,6 +823,10 @@ pub async fn newsletter_gdpr_export( .into_response()); }; + if let Some(resp) = check_gdpr_token(&state, &email, &body.token) { + return Ok(resp); + } + let data = state .db .newsletter_get_by_email(&email) @@ -748,12 +882,13 @@ pub async fn newsletter_gdpr_export( responses( (status = 200, description = "Data deleted", body = NewsletterResponse), (status = 400, description = "Invalid email", body = NewsletterResponse), + (status = 401, description = "Invalid or expired verification token", body = NewsletterResponse), ) )] pub async fn newsletter_gdpr_delete( State(state): State>, Json(payload): Json, -) -> Result { +) -> Result { let Some(email) = normalized_email(&payload.email) else { return Ok(( StatusCode::BAD_REQUEST, @@ -761,9 +896,14 @@ pub async fn newsletter_gdpr_delete( success: false, message: "Invalid email address.".to_string(), }), - )); + ) + .into_response()); }; + if let Some(resp) = check_gdpr_token(&state, &email, &payload.token) { + return Ok(resp); + } + let _ = state .db .newsletter_gdpr_delete(&email) @@ -778,7 +918,8 @@ pub async fn newsletter_gdpr_delete( success: true, message: "Data deleted.".to_string(), }), - )) + ) + .into_response()) } #[utoipa::path( diff --git a/services/api/src/main.rs b/services/api/src/main.rs index 354e8f5b..bff3f8b4 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -388,6 +388,7 @@ async fn main() -> anyhow::Result<()> { .route("/api/v1/newsletter/subscribe", post(handlers::newsletter_subscribe)) .route("/api/v1/newsletter/confirm", get(handlers::newsletter_confirm)) .route("/api/v1/newsletter/unsubscribe", get(handlers::newsletter_unsubscribe)) + .route("/api/v1/newsletter/gdpr/request-token", post(handlers::newsletter_gdpr_request_token)) .route("/api/v1/newsletter/gdpr/export", post(handlers::newsletter_gdpr_export)) .route("/api/v1/newsletter/gdpr/delete", axum::routing::delete(handlers::newsletter_gdpr_delete)) .layer(middleware::from_fn(correlation::correlation_id_middleware)) diff --git a/services/api/src/newsletter.rs b/services/api/src/newsletter.rs index d94095d4..ae709db3 100644 --- a/services/api/src/newsletter.rs +++ b/services/api/src/newsletter.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::cache::RedisCache; use crate::metrics::Metrics; @@ -141,6 +141,112 @@ pub fn hash_unsubscribe_token(raw_token: &str) -> String { hex::encode(Sha256::digest(raw_token.as_bytes())) } +// ── GDPR verification tokens ───────────────────────────────────────────────── +// +// GDPR export/delete (issue #1101) previously accepted a bare email address +// as proof of ownership, letting anyone who knows or guesses a subscriber's +// email pull or destroy their record. Both endpoints now require a signed, +// short-lived token bound to the target email, obtained via +// `/api/v1/newsletter/gdpr/request-token` and delivered only to that address. +// +// The token is a stateless HMAC-SHA256 signature (reusing the same primitive +// already used to verify SendGrid webhook signatures — see +// `security::signing`) rather than a stored opaque token, so verification +// needs no database round-trip. + +/// How long a GDPR verification token remains valid after issuance. +pub const GDPR_TOKEN_TTL_SECS: u64 = 900; // 15 minutes + +fn gdpr_token_payload(email: &str, expires_at: u64) -> String { + format!("gdpr:{email}:{expires_at}") +} + +/// Generate a signed, time-limited token proving control of `email`. +/// +/// Format: `{expires_at_unix}.{hmac_signature}`. The signature covers both +/// the email and the expiry, so a token can't be replayed against a +/// different address or accepted past its expiry. +pub fn generate_gdpr_verification_token(email: &str, secret: &str) -> String { + let expires_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + + GDPR_TOKEN_TTL_SECS; + let signature = crate::security::signing::generate_signature( + gdpr_token_payload(email, expires_at).as_bytes(), + secret, + ) + .unwrap_or_default(); + format!("{expires_at}.{signature}") +} + +/// Verify a token produced by [`generate_gdpr_verification_token`] against +/// `email`. Returns `false` for a missing/malformed/expired/mismatched token. +pub fn verify_gdpr_verification_token(token: &str, email: &str, secret: &str) -> bool { + let Some((expires_at_str, signature)) = token.split_once('.') else { + return false; + }; + let Ok(expires_at) = expires_at_str.parse::() else { + return false; + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if now > expires_at { + return false; + } + crate::security::signing::verify_signature( + gdpr_token_payload(email, expires_at).as_bytes(), + signature, + secret, + ) +} + +/// Email a GDPR verification token to `email`. +pub async fn send_gdpr_verification_email( + config: &Config, + email: &str, + token: &str, +) -> anyhow::Result<()> { + let api_key = config + .sendgrid_api_key + .as_deref() + .context("missing SENDGRID_API_KEY")?; + let from_email = config.from_email.as_deref().context("missing FROM_EMAIL")?; + + let payload = json!({ + "personalizations": [{ "to": [{ "email": email }] }], + "from": { "email": from_email }, + "subject": "Verify your data request", + "content": [{ + "type": "text/plain", + "value": format!( + "Use this verification code to confirm your data export or deletion request: {token}\n\nThis code expires in 15 minutes. If you did not request this, you can ignore this email." + ) + }] + }); + + let response = reqwest::Client::new() + .post("https://api.sendgrid.com/v3/mail/send") + .bearer_auth(api_key) + .json(&payload) + .send() + .await + .context("sendgrid request failed")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to read SendGrid error response body"); + String::new() + }); + anyhow::bail!("sendgrid returned {status}: {body}"); + } + + Ok(()) +} + /// Result of attempting to redeem an unsubscribe token. #[derive(Debug, PartialEq)] pub enum UnsubscribeTokenResult { @@ -408,6 +514,85 @@ mod tests { ); } + // ------------------------------------------------------------------------- + // #1101: GDPR verification token tests + // ------------------------------------------------------------------------- + + #[test] + fn gdpr_token_roundtrip_valid() { + let token = generate_gdpr_verification_token("user@example.com", "test-secret"); + assert!(verify_gdpr_verification_token( + &token, + "user@example.com", + "test-secret" + )); + } + + #[test] + fn gdpr_token_rejects_wrong_email() { + let token = generate_gdpr_verification_token("user@example.com", "test-secret"); + assert!(!verify_gdpr_verification_token( + &token, + "attacker@example.com", + "test-secret" + )); + } + + #[test] + fn gdpr_token_rejects_wrong_secret() { + let token = generate_gdpr_verification_token("user@example.com", "test-secret"); + assert!(!verify_gdpr_verification_token( + &token, + "user@example.com", + "wrong-secret" + )); + } + + #[test] + fn gdpr_token_rejects_tampered_expiry() { + let token = generate_gdpr_verification_token("user@example.com", "test-secret"); + let (_, signature) = token.split_once('.').unwrap(); + // Attacker rewrites the expiry to push it further into the future, + // reusing the original signature — must not verify. + let forged = format!("9999999999.{signature}"); + assert!(!verify_gdpr_verification_token( + &forged, + "user@example.com", + "test-secret" + )); + } + + #[test] + fn gdpr_token_rejects_expired_token() { + let expired = format!( + "1.{}", + crate::security::signing::generate_signature( + gdpr_token_payload("user@example.com", 1).as_bytes(), + "test-secret" + ) + .unwrap() + ); + assert!(!verify_gdpr_verification_token( + &expired, + "user@example.com", + "test-secret" + )); + } + + #[test] + fn gdpr_token_rejects_malformed_token() { + assert!(!verify_gdpr_verification_token( + "not-a-valid-token", + "user@example.com", + "test-secret" + )); + assert!(!verify_gdpr_verification_token( + "", + "user@example.com", + "test-secret" + )); + } + // ------------------------------------------------------------------------- // #896: Opaque unsubscribe token tests // ------------------------------------------------------------------------- diff --git a/services/api/src/openapi_spec.rs b/services/api/src/openapi_spec.rs index 02a1eb72..1dc3f8cc 100644 --- a/services/api/src/openapi_spec.rs +++ b/services/api/src/openapi_spec.rs @@ -2,8 +2,9 @@ use utoipa::OpenApi; use crate::handlers::{ ApiError, AuditLogsQuery, AuditStatisticsQuery, EmailAnalyticsQuery, EmailTestRequest, - FeaturedMarketView, InvalidationResult, NewsletterEmailRequest, NewsletterExportResponse, - NewsletterResponse, NewsletterSubscribeRequest, ResolveMarketRequest, + FeaturedMarketView, InvalidationResult, NewsletterEmailRequest, NewsletterExportBody, + NewsletterExportResponse, NewsletterGdprTokenRequest, NewsletterResponse, + NewsletterSubscribeRequest, ResolveMarketRequest, NewsletterConfirmQuery, NewsletterUnsubscribeQuery, NewsletterExportQuery, }; use crate::pagination::PaginationQuery; @@ -27,6 +28,7 @@ use crate::pagination::PaginationQuery; crate::handlers::newsletter_subscribe, crate::handlers::newsletter_confirm, crate::handlers::newsletter_unsubscribe, + crate::handlers::newsletter_gdpr_request_token, crate::handlers::newsletter_gdpr_export, crate::handlers::newsletter_gdpr_delete, crate::handlers::statistics, @@ -57,6 +59,8 @@ use crate::pagination::PaginationQuery; InvalidationResult, NewsletterSubscribeRequest, NewsletterEmailRequest, + NewsletterGdprTokenRequest, + NewsletterExportBody, NewsletterResponse, NewsletterExportResponse, ResolveMarketRequest,