fix(dkim): Accept TXT records with escaped quotes (#61)

This commit is contained in:
Jagoda Estera Ślązak
2026-08-18 12:21:27 +02:00
committed by missytake
parent 83aa54bb82
commit 8fefc1cc94
2 changed files with 120 additions and 17 deletions
+40 -17
View File
@@ -14,6 +14,18 @@ use viadkim::verifier::LookupTxt;
// "top 1000 relays" is much more than enough, the limit is mostly to prevent DoS attacks.
const LRU_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).expect("1000 != 0");
/// Normalizes a TXT record RDATA by removing irrelevant characters.
///
/// Some DKIM key records use e.g. LF + WSP line breaks.
/// This is technically not correct, and `viadkim` fails to parse such records,
/// but in practice this is accepted by many implementations.
///
/// Additionally, removes escaped quotes, as such records as:
/// `"...UL9" "\" \"7vGm..."` proved to still be accepted by e.g. dkimpy or OpenDKIM.
fn normalize_rdata(txt_data: &str) -> String {
txt_data.replace([' ', '\t', '\n', '\r', '"'], "")
}
/// DNS resolver for DKIM TXT records, that caches RDATA in memory.
#[derive(Clone)]
struct CachedResolver {
@@ -38,15 +50,6 @@ impl CachedResolver {
})
}
/// Normalizes a TXT record RDATA by removing whitespace characters.
///
/// Some DKIM key records use e.g. LF + WSP line breaks.
/// This is technically not correct, and `viadkim` fails to parse such records,
/// but in practice this is accepted by many implementations.
fn normalize_rdata(txt_data: &str) -> String {
txt_data.replace([' ', '\t', '\n', '\r'], "")
}
/// Invalidates the cached RDATA for a given selector and domain.
///
/// Fails silently.
@@ -98,7 +101,10 @@ impl LookupTxt for CachedResolver {
.map(|txt| {
let rdata_raw = txt.txt_data().concat();
let rdata = String::from_utf8_lossy(&rdata_raw);
Self::normalize_rdata(&rdata).into_bytes()
log::trace!("TXT (concat): {:?}", rdata);
let normalized = normalize_rdata(&rdata);
log::trace!("TXT (normalized): {:?}", normalized);
normalized.into_bytes()
})
.collect()
};
@@ -124,7 +130,8 @@ impl LookupTxt for MockResolver {
fn lookup_txt(&self, _domain: &str) -> Self::Query<'_> {
Box::pin(async move {
let txts: Self::Answer = Box::new(std::iter::once(Ok(self.0.clone().into_bytes())));
let txts: Self::Answer =
Box::new(std::iter::once(Ok(normalize_rdata(&self.0).into_bytes())));
Ok(txts)
})
}
@@ -256,13 +263,29 @@ impl DkimVerifier {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[tokio::test]
async fn test_dkim_verifier() {
let verifier = DkimVerifier::mock(
r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5krC4Xi5Wkr6eMlla38LCFmV645E3FLAgsRl2YJ0SrZ4N2Vw1/yH0mefvtk7HYE7ytV7RQl/er2CkSsaHLJSYLmPCBw5CO6PSsBSXuh6DBqdylh/1t9vVQ9p38fTwn9gU1QvplcpRQL9eepRra1k24VMIaVy2ZZcu3LI9zkPsR7o7TyNaeMhsL8ouWInWc1NSid+p0SgliQuwHIejZhlTPE60JLbJE0OR9I4wmq3377H6z/QrO8XeabCgtmTuzE/hTRyIyNS40jql/99pjlhIcjM2U+P2B0FjwYt7BwLHsgANr74ctlnKY+SdH25rNwVpPmkotaULG5SJCByKBkfCwIDAQAB;s=email;t=s"#.to_string()
);
let raw_mail = include_bytes!("../test_data/dkim-abjadiyah.eml");
verifier.verify(raw_mail, "abjadiyah.xyz").await.unwrap();
#[case::simple_simple_canonicalization(
r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5krC4Xi5Wkr6eMlla38LCFmV645E3FLAgsRl2YJ0SrZ4N2Vw1/yH0mefvtk7HYE7ytV7RQl/er2CkSsaHLJSYLmPCBw5CO6PSsBSXuh6DBqdylh/1t9vVQ9p38fTwn9gU1QvplcpRQL9eepRra1k24VMIaVy2ZZcu3LI9zkPsR7o7TyNaeMhsL8ouWInWc1NSid+p0SgliQuwHIejZhlTPE60JLbJE0OR9I4wmq3377H6z/QrO8XeabCgtmTuzE/hTRyIyNS40jql/99pjlhIcjM2U+P2B0FjwYt7BwLHsgANr74ctlnKY+SdH25rNwVpPmkotaULG5SJCByKBkfCwIDAQAB;s=email;t=s"#,
include_bytes!("../test_data/dkim-abjadiyah.eml")
)]
#[case::txt_escaped_quotes(
r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL9" "7vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"#,
include_bytes!("../test_data/dkim-privitty.eml")
)]
async fn test_dkim_verifier(#[case] txt: &str, #[case] message: &[u8]) {
let verifier = DkimVerifier::mock(txt.to_string());
verifier.verify(message, "abjadiyah.xyz").await.unwrap();
}
#[rstest]
#[case::escaped_quotes(
r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL9" "7vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"#,
r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL97vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"#
)]
fn test_normalize_rdata(#[case] input: &str, #[case] expected: &str) {
assert_eq!(normalize_rdata(input), expected);
}
}