fix: send one-click unsubscribes only when the sender signed them

This commit is contained in:
Tomás Romero committed 2026-11-10 23:47:42 +01:00
1 parent d71ef7422a
commit 31d854f20e
3 files changed
+274 -41

No files matched your search

+24 -7
View File
@@ -3632,19 +3632,36 @@ impl Core {
Ok(None)
}
/// Unsubscribe from the list a message came from, preferring the RFC 8058
/// one-click HTTPS POST, then the RFC 2369 mailto: (sent immediately over
/// SMTP), and finally handing the URL back for the browser. The returned
/// outcome states what actually happened so the UI can toast honestly.
/// Unsubscribe from the list a message came from, preferring a signed
/// RFC 8058 one-click HTTPS POST, then the RFC 2369 mailto: (sent
/// immediately over SMTP), and finally handing the URL back for the
/// browser. The returned outcome states what actually happened so the UI
/// can toast honestly.
pub async fn unsubscribe_message(&self, message_id: i64) -> Result<UnsubscribeOutcome> {
let detail = self
let (detail, raw_path) = self
.db
.read(move |conn| repo::messages::detail(conn, message_id))
.read(move |conn| {
let detail = repo::messages::detail(conn, message_id)?;
let raw_path =
repo::messages::get_row(conn, message_id)?.and_then(|row| row.raw_path);
Ok((detail, raw_path))
})
.await?;
let raw = detail
.list_unsubscribe
.ok_or_else(|| CoreError::NotFound("message has no List-Unsubscribe header".into()))?;
let plan = unsubscribe::plan(&raw, detail.list_unsubscribe_post.as_deref())
// One-click needs the DKIM-Signature fields of the original, which
// the reader has downloaded; without them it falls back to the other
// ways.
let one_click = match &raw_path {
Some(raw_path) => {
crate::file_io::read_headers(raw_path, MAX_CACHED_HEADER_BYTES, "cached headers")
.await
.is_ok_and(|raw| crate::mime::one_click_signed(&raw))
}
None => false,
};
let plan = unsubscribe::plan(&raw, one_click)
.ok_or_else(|| CoreError::Other("List-Unsubscribe header has no usable URI".into()))?;
match plan {
unsubscribe::UnsubscribePlan::OneClick { url } => {
+219 -3
View File
@@ -1747,9 +1747,15 @@ fn authentication_results(results: &str) -> Vec<AuthenticationResult<'_>> {
let properties = tokens
.filter_map(|token| token.split_once('='))
.filter(|(name, _)| {
["policy.authority", "header.from", "header.d"]
.iter()
.any(|known| name.trim().eq_ignore_ascii_case(known))
[
"policy.authority",
"header.from",
"header.d",
"header.i",
"header.b",
]
.iter()
.any(|known| name.trim().eq_ignore_ascii_case(known))
})
.map(|(name, value)| {
(
@@ -1865,6 +1871,124 @@ pub fn is_one_click_post(raw: &str) -> bool {
.eq_ignore_ascii_case("List-Unsubscribe=One-Click")
}
/// Whether a message's header block asks for an RFC 8058 one-click
/// unsubscribe that its sender signed. Section 4 requires a valid DKIM
/// signature whose h= tag covers both List-Unsubscribe and
/// List-Unsubscribe-Post; without one, receivers should not offer one-click.
///
/// Signatures are not verified here. As with the verified-sender badge, the
/// result the receiving server put in the topmost Authentication-Results
/// field (RFC 8601) decides, and its header.b (RFC 6008) tells which
/// signature passed. When a server reports only the signing domain, every
/// signature of that domain has to cover both fields. Each field has to
/// appear exactly once: RFC 2369 allows no more, and a signature that covers
/// one instance says nothing about another (RFC 6376 section 8.15).
pub fn one_click_signed(raw: &[u8]) -> bool {
let Some(msg) = mail_parser::MessageParser::default().parse_headers(raw) else {
return false;
};
let fields = |name: &'static str| msg.header_as(name, mail_parser::HeaderForm::Raw);
let post = fields("List-Unsubscribe-Post");
if fields("List-Unsubscribe").len() != 1
|| post.len() != 1
|| !post[0].as_text().is_some_and(is_one_click_post)
{
return false;
}
let signatures: Vec<DkimSignature> = fields("DKIM-Signature")
.iter()
.filter_map(|value| value.as_text().and_then(DkimSignature::parse))
.collect();
let authentication = first_raw_header(&msg, "Authentication-Results").unwrap_or_default();
let authentication = without_comments(&authentication);
authentication_results(&authentication)
.iter()
.filter(|result| {
result.method.eq_ignore_ascii_case("dkim") && result.result.eq_ignore_ascii_case("pass")
})
.any(|result| {
let mut reported = signatures
.iter()
.filter(|signature| signature.reported_by(result))
.peekable();
reported.peek().is_some() && reported.all(DkimSignature::covers_one_click)
})
}
/// The DKIM-Signature tags that say who signed which fields (RFC 6376
/// section 3.5).
struct DkimSignature {
domain: String,
/// Signed field names, lowercase.
signed: Vec<String>,
/// The signature, without folding whitespace.
signature: String,
}
impl DkimSignature {
fn parse(value: &str) -> Option<Self> {
let (mut domain, mut signed, mut signature) = (None, None, None);
for tag in value.split(';') {
let Some((name, value)) = tag.split_once('=') else {
continue;
};
let compact = || -> String { value.split_ascii_whitespace().collect() };
// Tag names are case-sensitive.
match name.trim() {
"d" => domain = Some(compact()),
"h" => {
signed = Some(
compact()
.split(':')
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>(),
)
}
"b" => signature = Some(compact()),
_ => {}
}
}
Some(Self {
domain: domain.filter(|domain| !domain.is_empty())?,
signed: signed?,
signature: signature.filter(|signature| !signature.is_empty())?,
})
}
/// Whether the receiver's result is about this signature: by the start of
/// its b= value (matched case-sensitively, RFC 6008), else by the domain
/// it reports. An i= identity names the signing domain or a subdomain.
fn reported_by(&self, result: &AuthenticationResult<'_>) -> bool {
if let Some(signature) = auth_property(result, "header.b") {
return !signature.is_empty() && self.signature.starts_with(signature);
}
if let Some(domain) = auth_property(result, "header.d") {
return self
.domain
.trim_end_matches('.')
.eq_ignore_ascii_case(domain.trim_end_matches('.'));
}
auth_property(result, "header.i").is_some_and(|identity| {
let identity = domain_of(identity).trim_end_matches('.');
let domain = self.domain.trim_end_matches('.');
identity.eq_ignore_ascii_case(domain)
|| identity
.len()
.checked_sub(domain.len() + 1)
.is_some_and(|dot| {
identity.as_bytes()[dot] == b'.'
&& identity[dot + 1..].eq_ignore_ascii_case(domain)
})
})
}
fn covers_one_click(&self) -> bool {
["list-unsubscribe", "list-unsubscribe-post"]
.iter()
.all(|name| self.signed.iter().any(|signed| signed == name))
}
}
fn parse_headers(msg: &mail_parser::Message) -> ParsedHeaders {
let mut refs: Vec<String> = Vec::new();
if let Some(irt) = msg.in_reply_to().as_text_list() {
@@ -2574,6 +2698,98 @@ mod tests {
);
}
fn one_click_message(authentication: &str, signatures: &[&str]) -> String {
let mut raw = format!("Authentication-Results: {authentication}\r\n");
for signature in signatures {
raw.push_str(&format!("DKIM-Signature: {signature}\r\n"));
}
raw.push_str(concat!(
"From: News <news@news.example>\r\n",
"List-Unsubscribe: <https://news.example/u/1>\r\n",
"List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n",
"Subject: Weekly\r\n\r\nbody"
));
raw
}
const COVERING: &str = "v=1; a=rsa-sha256; d=news.example; s=s1;\r\n\th=From:Subject:\r\n List-Unsubscribe:List-Unsubscribe-Post; bh=x;\r\n\tb=AbCdEfGh12\r\n 345";
const PARTIAL: &str = "v=1; a=rsa-sha256; d=news.example; s=s2; h=From:Subject:List-Unsubscribe; bh=x; b=ZyXwVuTs999";
#[test]
fn one_click_needs_the_passing_signature_to_cover_both_fields() {
// Gmail names the signature that passed by the start of its b= tag.
let gmail = "mx.google.com; dkim=pass header.i=@news.example header.s=s1 header.b=AbCdEfGh";
assert!(one_click_signed(
one_click_message(gmail, &[PARTIAL, COVERING]).as_bytes()
));
// The case of the b= prefix matters (RFC 6008).
let wrong_case = "mx.google.com; dkim=pass header.b=abcdefgh";
assert!(!one_click_signed(
one_click_message(wrong_case, &[COVERING]).as_bytes()
));
// Only the signature that passed counts, not one that covers both.
let partial_passed = "mx.example; dkim=pass header.b=ZyXwVuTs; dkim=fail header.b=AbCdEfGh";
assert!(!one_click_signed(
one_click_message(partial_passed, &[PARTIAL, COVERING]).as_bytes()
));
// A claimed pass inside a comment is not a result.
let comment = "mx.example; dkim=none (dkim=pass header.b=AbCdEfGh)";
assert!(!one_click_signed(
one_click_message(comment, &[COVERING]).as_bytes()
));
assert!(!one_click_signed(
one_click_message("mx.example; dkim=pass header.b=AbCdEfGh", &[]).as_bytes()
));
}
#[test]
fn one_click_by_domain_needs_every_signature_of_that_domain() {
// Microsoft reports only the signing domain.
let microsoft = "spf=pass (sender IP is 192.0.2.1) smtp.mailfrom=news.example; dkim=pass (signature was verified) header.d=news.example;dmarc=pass action=none header.from=news.example;compauth=pass reason=100";
assert!(one_click_signed(
one_click_message(microsoft, &[COVERING]).as_bytes()
));
assert!(!one_click_signed(
one_click_message(microsoft, &[COVERING, PARTIAL]).as_bytes()
));
let other_domain = "mx.example; dkim=pass header.d=esp.example";
assert!(!one_click_signed(
one_click_message(other_domain, &[COVERING]).as_bytes()
));
// An identity in a subdomain still names the signing domain.
let identity = "mx.example; dkim=pass header.i=@mail.news.example";
assert!(one_click_signed(
one_click_message(identity, &[COVERING]).as_bytes()
));
let lookalike = "mx.example; dkim=pass header.i=@evilnews.example";
assert!(!one_click_signed(
one_click_message(lookalike, &[COVERING]).as_bytes()
));
}
#[test]
fn one_click_needs_exactly_one_of_each_field() {
let gmail = "mx.google.com; dkim=pass header.b=AbCdEfGh";
let signed = one_click_message(gmail, &[COVERING]);
let added = signed.replacen(
"List-Unsubscribe:",
"List-Unsubscribe: <https://elsewhere.example/u>\r\nList-Unsubscribe:",
1,
);
assert!(!one_click_signed(added.as_bytes()));
let twice = signed.replacen(
"List-Unsubscribe-Post:",
"List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\nList-Unsubscribe-Post:",
1,
);
assert!(!one_click_signed(twice.as_bytes()));
let other_value = signed.replace(
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
"List-Unsubscribe-Post: List-Unsubscribe=Later",
);
assert!(!one_click_signed(other_value.as_bytes()));
}
#[test]
fn accepts_receiver_stamped_bimi_pass() {
let raw = concat!(
+31 -31
View File
@@ -3,14 +3,14 @@
//!
//! One-click (RFC 8058) requires the sender to publish BOTH
//! `List-Unsubscribe: <https://…>` and
//! `List-Unsubscribe-Post: List-Unsubscribe=One-Click`; the receiver then
//! POSTs the literal body `List-Unsubscribe=One-Click` to the HTTPS URI.
//! The endpoint must complete the unsubscribe without cookies, logins or
//! redirects, so the client here carries no cookie store and treats any
//! non-2xx (including 3xx) as failure.
//! `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, signed by DKIM
//! ([`crate::mime::one_click_signed`]); the receiver then POSTs the field
//! `List-Unsubscribe=One-Click` to the HTTPS URI. The endpoint must complete
//! the unsubscribe without cookies, logins or redirects, so the client here
//! carries no cookie store and treats any non-2xx (including 3xx) as failure.
use crate::error::{CoreError, Result};
use crate::mime::{is_one_click_post, unsubscribe_uris};
use crate::mime::unsubscribe_uris;
use std::{
net::{IpAddr, SocketAddr},
time::Duration,
@@ -33,17 +33,15 @@ pub enum UnsubscribePlan {
Browser { url: String },
}
/// Decide how to unsubscribe from the raw header values. None when the header
/// contains no usable URI.
pub fn plan(
list_unsubscribe: &str,
list_unsubscribe_post: Option<&str>,
) -> Option<UnsubscribePlan> {
/// Decide how to unsubscribe from the raw List-Unsubscribe value. `one_click`
/// says the sender signed a one-click request
/// ([`crate::mime::one_click_signed`]). None when the header contains no
/// usable URI.
pub fn plan(list_unsubscribe: &str, one_click: bool) -> Option<UnsubscribePlan> {
let mut uris = unsubscribe_uris(list_unsubscribe);
let web = uris
.clone()
.find(|u| has_scheme_prefix(u, "https://") || has_scheme_prefix(u, "http://"));
let one_click = list_unsubscribe_post.is_some_and(is_one_click_post);
// RFC 8058 is https-only; a one-click marker on an http:// URI is ignored.
if one_click && let Some(url) = web.filter(|u| has_scheme_prefix(u, "https://")) {
@@ -84,6 +82,11 @@ fn parse_mailto(uri: &str) -> Option<UnsubscribePlan> {
Some(UnsubscribePlan::Mailto { to, subject, body })
}
/// The form field the POST carries, as multipart/form-data: RFC 8058 section
/// 3.2 says it SHOULD be sent that way, and MAY be urlencoded.
const ONE_CLICK_BOUNDARY: &str = "flectar-one-click";
const ONE_CLICK_FORM: &str = "--flectar-one-click\r\nContent-Disposition: form-data; name=\"List-Unsubscribe\"\r\n\r\nOne-Click\r\n--flectar-one-click--\r\n";
/// Perform the RFC 8058 one-click POST. Success is a 2xx only: no redirects
/// are followed (the endpoint must not require them) and no cookies are sent.
pub async fn post_one_click(url: &str) -> Result<()> {
@@ -120,9 +123,9 @@ pub async fn post_one_click(url: &str) -> Result<()> {
.post(url)
.header(
reqwest::header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
format!("multipart/form-data; boundary={ONE_CLICK_BOUNDARY}"),
)
.body("List-Unsubscribe=One-Click")
.body(ONE_CLICK_FORM)
.send()
.await
.map_err(|e| CoreError::Network(e.to_string()))?;
@@ -203,11 +206,8 @@ mod tests {
use super::*;
#[test]
fn one_click_needs_post_header_and_https() {
let p = plan(
"<https://x.example/u?t=1>, <mailto:u@x.example>",
Some("List-Unsubscribe=One-Click"),
);
fn one_click_needs_a_signed_request_and_https() {
let p = plan("<https://x.example/u?t=1>, <mailto:u@x.example>", true);
assert_eq!(
p,
Some(UnsubscribePlan::OneClick {
@@ -215,12 +215,12 @@ mod tests {
})
);
// Same header without the POST marker: mailto wins over browser.
let p = plan("<https://x.example/u?t=1>, <mailto:u@x.example>", None);
// The same header without a signed request: mailto wins over browser.
let p = plan("<https://x.example/u?t=1>, <mailto:u@x.example>", false);
assert!(matches!(p, Some(UnsubscribePlan::Mailto { .. })));
// One-click marker on a plain http URL is ignored.
let p = plan("<http://x.example/u>", Some("List-Unsubscribe=One-Click"));
let p = plan("<http://x.example/u>", true);
assert_eq!(
p,
Some(UnsubscribePlan::Browser {
@@ -249,7 +249,7 @@ mod tests {
fn mailto_parses_subject_and_body() {
let p = plan(
"<mailto:leave@list.example?subject=unsub%20me&body=please%20go>",
None,
false,
);
assert_eq!(
p,
@@ -263,7 +263,7 @@ mod tests {
#[test]
fn mailto_defaults() {
let p = plan("<mailto:leave@list.example>", None);
let p = plan("<mailto:leave@list.example>", false);
assert_eq!(
p,
Some(UnsubscribePlan::Mailto {
@@ -278,7 +278,7 @@ mod tests {
fn mailto_keeps_plus_addresses() {
let p = plan(
"<mailto:googlegroups-manage+123+unsubscribe@googlegroups.com?subject=Unsubscribe+me>",
None,
false,
);
assert_eq!(
p,
@@ -292,9 +292,9 @@ mod tests {
#[test]
fn mailto_without_an_address_is_not_usable() {
assert_eq!(plan("<mailto:?subject=unsubscribe>", None), None);
assert_eq!(plan("<mailto:?subject=unsubscribe>", false), None);
assert_eq!(
plan("<mailto:?subject=x>, <https://x.example/u>", None),
plan("<mailto:?subject=x>, <https://x.example/u>", false),
Some(UnsubscribePlan::Browser {
url: "https://x.example/u".into()
})
@@ -304,15 +304,15 @@ mod tests {
#[test]
fn browser_fallback_and_empty() {
assert_eq!(
plan("<https://x.example/u>", None),
plan("<https://x.example/u>", false),
Some(UnsubscribePlan::Browser {
url: "https://x.example/u".into()
})
);
assert_eq!(plan("nothing useful", None), None);
assert_eq!(plan("nothing useful", false), None);
// Bare (unbracketed) value from a sloppy sender still parses.
assert!(matches!(
plan("https://x.example/u", Some("List-Unsubscribe=One-Click")),
plan("https://x.example/u", true),
Some(UnsubscribePlan::OneClick { .. })
));
}