Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 66 additions & 11 deletions services/api/src/audit_middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,34 @@ 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()
}

/// 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
Expand All @@ -82,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:{}", &k[..8.min(k.len())]))
.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
Expand Down Expand Up @@ -335,6 +353,43 @@ 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);
}

// ── 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]
Expand Down
147 changes: 144 additions & 3 deletions services/api/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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)]
Expand All @@ -393,6 +402,41 @@ fn normalized_email(raw: &str) -> Option<String> {
}
}

/// 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<Response> {
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"];

Expand Down Expand Up @@ -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<Arc<AppState>>,
headers: HeaderMap,
connect_info: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
Json(payload): Json<NewsletterGdprTokenRequest>,
) -> Result<Response, ApiError> {
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",
Expand All @@ -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),
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -748,22 +882,28 @@ 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<Arc<AppState>>,
Json(payload): Json<NewsletterEmailRequest>,
) -> Result<impl IntoResponse, ApiError> {
) -> Result<Response, ApiError> {
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());
};

if let Some(resp) = check_gdpr_token(&state, &email, &payload.token) {
return Ok(resp);
}

let _ = state
.db
.newsletter_gdpr_delete(&email)
Expand All @@ -778,7 +918,8 @@ pub async fn newsletter_gdpr_delete(
success: true,
message: "Data deleted.".to_string(),
}),
))
)
.into_response())
}

#[utoipa::path(
Expand Down
Loading
Loading