This is an automated email from the ASF dual-hosted git repository. chibenwa pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-jspf.git
commit 62bc0d9e43c7af09090d543f015bb163347f2f81 Author: Quan Tran <[email protected]> AuthorDate: Wed Nov 4 22:35:43 2026 +0700 [FIX] SPFResult: quote a Received-SPF value that is no dot-atom The client address, MAIL FROM and HELO were written as they are in the key-value pairs of the Received-SPF header, where a value is a dot-atom or a quoted-string (RFC 7208 9.1). Since the previous commits keep their control characters, a HELO "mail.example.org\u0000" wrote a NUL into the header, which RFC 5322 does not allow; a MAIL FROM holding a space, a ";" or a quote, as "\"a b;\"@example.com", ended its value early; and a mailbox, an IPv6 client address, a HELO address literal or a HELO ending with a dot is no dot-atom ("@", ":" and "[" are no atext, RFC 5322 3.2.3): the examples of RFC 7208 9.1 write envelope-from="[email protected]". Write a dot-atom as it is, and any other value as a quoted-string: "\" and the quote become quoted-pairs, and characters neither visible nor white space are dropped. Non-ASCII characters, as of an SMTPUTF8 MAIL FROM, are atext: a header holds UTF-8 (RFC 6532 3.2). Behaviour change: envelope-from is now quoted for a mailbox, client-ip for an IPv6 address, and helo for an address literal or a name ending with a dot. --- docs/modules/ROOT/pages/usage.adoc | 6 +- .../org/apache/james/jspf/executor/SPFResult.java | 70 +++++++++++++++- .../org/apache/james/jspf/SPFExecutorBaseTest.java | 97 +++++++++++++++++++--- 3 files changed, 157 insertions(+), 16 deletions(-) diff --git a/docs/modules/ROOT/pages/usage.adoc b/docs/modules/ROOT/pages/usage.adoc index 332f9f7..5cc3c07 100644 --- a/docs/modules/ROOT/pages/usage.adoc +++ b/docs/modules/ROOT/pages/usage.adoc @@ -92,8 +92,12 @@ Beyond the raw result, `SPFResult` builds a ready to use `Received-SPF` header: + ---- Received-SPF: pass (spfCheck: domain of example.com designates 192.0.2.25 as permitted -sender) client-ip=192.0.2.25; [email protected]; helo=example.com; +sender) client-ip=192.0.2.25; envelope-from="[email protected]"; helo=example.com; ---- ++ +Each value is written as a dot-atom when it is one, else as a quoted-string (RFC 7208 9.1): a +mailbox, an IPv6 address, a HELO address literal (`[192.0.2.1]`) or a domain ending with a dot is +quoted. `getExplanation()` returns the explanation published by the domain through the `exp=` modifier, or the empty string when there is none. diff --git a/resolver/src/main/java/org/apache/james/jspf/executor/SPFResult.java b/resolver/src/main/java/org/apache/james/jspf/executor/SPFResult.java index c274b11..a7e8bdc 100644 --- a/resolver/src/main/java/org/apache/james/jspf/executor/SPFResult.java +++ b/resolver/src/main/java/org/apache/james/jspf/executor/SPFResult.java @@ -31,6 +31,70 @@ import org.apache.james.jspf.core.exceptions.SPFErrorConstants; */ public class SPFResult { + /** + * The atext characters of RFC 5322 3.2.3 other than letters and digits + */ + private static final String ATEXT_SYMBOLS = "!#$%&'*+-/=?^_`{|}~"; + + /** + * @return true for a visible character: printable ASCII, or a non-ASCII one, as UTF-8 headers + * hold (RFC 6532 3.2), but no control character (C0, DEL, C1) + */ + private static boolean isVisible(int c) { + return (c > ' ' && c < 0x7F) || c >= 0xA0; + } + + /** + * @return true for an atext character of RFC 5322 3.2.3, or a non-ASCII one (RFC 6532 3.2) other + * than a control character + */ + private static boolean isAtext(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || ATEXT_SYMBOLS.indexOf(c) >= 0 || c >= 0xA0; + } + + /** + * @return true for an RFC 5322 dot-atom-text: runs of atext separated by single dots, so neither + * empty nor starting or ending with a dot + */ + private static boolean isDotAtom(String value) { + boolean afterAtext = false; + for (char c : value.toCharArray()) { + if (c == '.' && afterAtext) { + afterAtext = false; + } else if (isAtext(c)) { + afterAtext = true; + } else { + return false; + } + } + return afterAtext; + } + + /** + * Write the value of a key-value pair of the header, a dot-atom or a quoted-string (RFC 7208 9.1): + * as it is when it is a dot-atom, else as an RFC 5322 quoted-string, of which "\\" and "\"" become + * quoted-pairs and characters that are neither visible nor white space are dropped. A mailbox ("@", + * quoted in the examples of RFC 7208 9.1), an IPv6 address (":"), an address literal ("[") or a + * domain ending with a dot is quoted. + * + * @param value The value, given by the SMTP client + */ + private static String keyValue(String value) { + if (isDotAtom(value)) { + return value; + } + StringBuilder quoted = new StringBuilder(value.length() + 2).append('"'); + for (char c : value.toCharArray()) { + if (c == '"' || c == '\\') { + quoted.append('\\').append(c); + } else if (isVisible(c) || c == ' ' || c == '\t') { + quoted.append(c); + } + } + return quoted.append('"').toString(); + } + protected String headerTextAsString = ""; protected final static String HEADER_NAME = "Received-SPF"; @@ -129,9 +193,9 @@ public class SPFResult { String headerTextAsString; if (headerText.length() > 0) { - headerText.append("client-ip=" + spfData.getIpAddress() - + "; envelope-from=" + spfData.getMailFrom() + "; helo=" - + spfData.getHostName() + ";"); + headerText.append("client-ip=" + keyValue(spfData.getIpAddress()) + + "; envelope-from=" + keyValue(spfData.getMailFrom()) + "; helo=" + + keyValue(spfData.getHostName()) + ";"); headerTextAsString = headerText.toString(); } else { headerTextAsString = ""; diff --git a/resolver/src/test/java/org/apache/james/jspf/SPFExecutorBaseTest.java b/resolver/src/test/java/org/apache/james/jspf/SPFExecutorBaseTest.java index bee20fc..b7736cd 100644 --- a/resolver/src/test/java/org/apache/james/jspf/SPFExecutorBaseTest.java +++ b/resolver/src/test/java/org/apache/james/jspf/SPFExecutorBaseTest.java @@ -188,7 +188,7 @@ public abstract class SPFExecutorBaseTest { SPF spf = createSPF(); SPFResult result = spf.checkSPF("109.197.176.25", "[email protected]", "linagora.com"); assertEquals("pass", result.getResult()); - assertEquals("Received-SPF: pass (spfCheck: domain of linagora.com designates 109.197.176.25 as permitted sender) client-ip=109.197.176.25; [email protected]; helo=linagora.com;", + assertEquals("Received-SPF: pass (spfCheck: domain of linagora.com designates 109.197.176.25 as permitted sender) client-ip=109.197.176.25; envelope-from=\"[email protected]\"; helo=linagora.com;", result.getHeader()); } @@ -199,7 +199,7 @@ public abstract class SPFExecutorBaseTest { "[email protected]", "reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de"); assertEquals("none", result.getResult()); - assertEquals("Received-SPF: none (spfCheck: 207.54.72.202 is neither permitted nor denied by domain of reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de) client-ip=207.54.72.202; envelope-from=do_not_re...@reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de; helo=reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de;", + assertEquals("Received-SPF: none (spfCheck: 207.54.72.202 is neither permitted nor denied by domain of reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de) client-ip=207.54.72.202; envelope-from=\"[email protected]\"; helo=reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de;", result.getHeader()); } @@ -208,7 +208,7 @@ public abstract class SPFExecutorBaseTest { SPF spf = createSPF(); SPFResult result = spf.checkSPF("207.54.72.202", "[email protected]", "com.br"); assertEquals("none", result.getResult()); - assertEquals("Received-SPF: none (spfCheck: 207.54.72.202 is neither permitted nor denied by domain of com.br) client-ip=207.54.72.202; [email protected]; helo=com.br;", + assertEquals("Received-SPF: none (spfCheck: 207.54.72.202 is neither permitted nor denied by domain of com.br) client-ip=207.54.72.202; envelope-from=\"[email protected]\"; helo=com.br;", result.getHeader()); } @@ -221,7 +221,7 @@ public abstract class SPFExecutorBaseTest { "[email protected]", "reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de"); assertEquals("temperror", result.getResult()); - assertEquals("Received-SPF: temperror (spfCheck: Error in retrieving data from DNS) client-ip=207.54.72.202; envelope-from=do_not_re...@reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de; helo=reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de;", + assertEquals("Received-SPF: temperror (spfCheck: Error in retrieving data from DNS) client-ip=207.54.72.202; envelope-from=\"[email protected]\"; helo=reyifglerifwukfvbdjhrkbvebvekvfulervkerkeruerbeb.de;", result.getHeader()); } @@ -239,7 +239,7 @@ public abstract class SPFExecutorBaseTest { SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); SPFResult result = spf.checkSPF(ip, "a_user@" + hostname, hostname); assertEquals(String.format( - "Received-SPF: %3$s (spfCheck: domain of %2$s designates %1$s as permitted sender) client-ip=%1$s; envelope-from=a_user@%2$s; helo=%2$s;", + "Received-SPF: %3$s (spfCheck: domain of %2$s designates %1$s as permitted sender) client-ip=%1$s; envelope-from=\"a_user@%2$s\"; helo=%2$s;", ip, hostname, SPFErrorConstants.PASS_CONV), result.getHeader()); assertEquals(SPFErrorConstants.PASS_CONV, result.getResult()); @@ -258,7 +258,7 @@ public abstract class SPFExecutorBaseTest { SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); SPFResult result = spf.checkSPF(ip, "a_user@" + hostname, hostname); assertEquals(String.format( - "Received-SPF: %3$s (spfCheck: Error in processing SPF Record) client-ip=%1$s; envelope-from=a_user@%2$s; helo=%2$s;", + "Received-SPF: %3$s (spfCheck: Error in processing SPF Record) client-ip=%1$s; envelope-from=\"a_user@%2$s\"; helo=%2$s;", ip, hostname, SPFErrorConstants.PERM_ERROR_CONV), result.getHeader()); assertEquals(SPFErrorConstants.PERM_ERROR_CONV, result.getResult()); @@ -281,7 +281,7 @@ public abstract class SPFExecutorBaseTest { SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); SPFResult result = spf.checkSPF(ip, "a_user@" + hostname, hostname); assertEquals(String.format( - "Received-SPF: %3$s (spfCheck: Error in processing SPF Record) client-ip=%1$s; envelope-from=a_user@%2$s; helo=%2$s;", + "Received-SPF: %3$s (spfCheck: Error in processing SPF Record) client-ip=%1$s; envelope-from=\"a_user@%2$s\"; helo=%2$s;", ip, hostname, SPFErrorConstants.PERM_ERROR_CONV), result.getHeader()); assertEquals(SPFErrorConstants.PERM_ERROR_CONV, result.getResult()); @@ -317,7 +317,7 @@ public abstract class SPFExecutorBaseTest { SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); SPFResult result = spf.checkSPF(ip, "a_user@" + hostname, hostname); assertEquals(String.format( - "Received-SPF: %3$s (spfCheck: Error in processing SPF Record) client-ip=%1$s; envelope-from=a_user@%2$s; helo=%2$s;", + "Received-SPF: %3$s (spfCheck: Error in processing SPF Record) client-ip=%1$s; envelope-from=\"a_user@%2$s\"; helo=%2$s;", ip, hostname, SPFErrorConstants.PERM_ERROR_CONV), result.getHeader()); assertEquals(SPFErrorConstants.PERM_ERROR_CONV, result.getResult()); @@ -359,7 +359,7 @@ public abstract class SPFExecutorBaseTest { SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); SPFResult result = spf.checkSPF(ip, "a_user@" + hostname, hostname); assertEquals(String.format( - "Received-SPF: %3$s (spfCheck: domain of %2$s designates %1$s as permitted sender) client-ip=%1$s; envelope-from=a_user@%2$s; helo=%2$s;", + "Received-SPF: %3$s (spfCheck: domain of %2$s designates %1$s as permitted sender) client-ip=%1$s; envelope-from=\"a_user@%2$s\"; helo=%2$s;", ip, hostname, SPFErrorConstants.PASS_CONV), result.getHeader()); assertEquals(SPFErrorConstants.PASS_CONV, result.getResult()); @@ -378,7 +378,7 @@ public abstract class SPFExecutorBaseTest { SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); SPFResult result = spf.checkSPF(ip, "a_user@" + hostname, hostname); assertEquals(String.format( - "Received-SPF: %3$s (spfCheck: Error in processing SPF Record) client-ip=%1$s; envelope-from=a_user@%2$s; helo=%2$s;", + "Received-SPF: %3$s (spfCheck: Error in processing SPF Record) client-ip=%1$s; envelope-from=\"a_user@%2$s\"; helo=%2$s;", ip, hostname, SPFErrorConstants.PERM_ERROR_CONV), result.getHeader()); assertEquals(SPFErrorConstants.PERM_ERROR_CONV, result.getResult()); @@ -397,7 +397,7 @@ public abstract class SPFExecutorBaseTest { SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); SPFResult result = spf.checkSPF(ip, "a_user@" + hostname, hostname); assertEquals(String.format( - "Received-SPF: %3$s (spfCheck: domain of %2$s designates %1$s as permitted sender) client-ip=%1$s; envelope-from=a_user@%2$s; helo=%2$s;", + "Received-SPF: %3$s (spfCheck: domain of %2$s designates %1$s as permitted sender) client-ip=%1$s; envelope-from=\"a_user@%2$s\"; helo=%2$s;", ip, hostname, SPFErrorConstants.PASS_CONV), result.getHeader()); assertEquals(SPFErrorConstants.PASS_CONV, result.getResult()); @@ -417,7 +417,7 @@ public abstract class SPFExecutorBaseTest { SPFResult result = spf.checkSPF(ip, "a_user@" + hostname, hostname); assertEquals(SPFErrorConstants.FAIL_CONV, result.getResult()); assertEquals(String.format( - "Received-SPF: %3$s (spfCheck: domain of x\\)client-ip=203.0.113.9;\\(.%2$s does not designate %1$s as permitted sender) client-ip=%1$s; envelope-from=a_user@%2$s; helo=%2$s;", + "Received-SPF: %3$s (spfCheck: domain of x\\)client-ip=203.0.113.9;\\(.%2$s does not designate %1$s as permitted sender) client-ip=%1$s; envelope-from=\"a_user@%2$s\"; helo=%2$s;", ip, hostname, SPFErrorConstants.FAIL_CONV), result.getHeader()); } @@ -887,6 +887,79 @@ public abstract class SPFExecutorBaseTest { assertEquals(SPFErrorConstants.FAIL_CONV, result.getResult()); } + @Test(timeout = 10000) + public void headerShouldNotHoldTheControlCharactersOfTheHelo() throws IOException { + String hostname = "heloControl.spfexecutor.fake"; + FakeResolver fakeResolver = new FakeResolver(); + fakeResolver.addRecord(new TXTRecord(Name.fromString(hostname + "."), DClass.IN, 30L, "v=spf1 +all")); + SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); + + SPFResult result = spf.checkSPF("192.0.2.10", "a_user@" + hostname, hostname + "\u0000"); + + // RFC 7208 9.1: a value is a dot-atom or a quoted-string, of no control character (RFC 5322 3.2.4) + assertEquals("Received-SPF: pass (spfCheck: domain of " + hostname + " designates 192.0.2.10 as permitted sender)" + + " client-ip=192.0.2.10; envelope-from=\"a_user@" + hostname + "\"; helo=\"" + hostname + "\";", result.getHeader()); + } + + @Test(timeout = 10000) + public void headerShouldKeepTheNonAsciiCharactersOfAMailFrom() throws IOException { + String hostname = "mailFromUtf8.spfexecutor.fake"; + FakeResolver fakeResolver = new FakeResolver(); + fakeResolver.addRecord(new TXTRecord(Name.fromString(hostname + "."), DClass.IN, 30L, "v=spf1 +all")); + SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); + + // a UTF-8 local part, of SMTPUTF8 + SPFResult result = spf.checkSPF("192.0.2.10", "pel\u00e9@" + hostname, hostname); + + // RFC 6532 3.2: UTF-8 in a header value + assertEquals("Received-SPF: pass (spfCheck: domain of " + hostname + " designates 192.0.2.10 as permitted sender)" + + " client-ip=192.0.2.10; envelope-from=\"pel\u00e9@" + hostname + "\"; helo=" + hostname + ";", result.getHeader()); + } + + @Test(timeout = 10000) + public void headerShouldQuoteAMailFromThatWouldEndItsValue() throws IOException { + String hostname = "mailFromQuote.spfexecutor.fake"; + FakeResolver fakeResolver = new FakeResolver(); + fakeResolver.addRecord(new TXTRecord(Name.fromString(hostname + "."), DClass.IN, 30L, "v=spf1 +all")); + SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); + + SPFResult result = spf.checkSPF("192.0.2.10", "\"a b;\"@" + hostname, hostname); + + assertEquals("Received-SPF: pass (spfCheck: domain of " + hostname + " designates 192.0.2.10 as permitted sender)" + + " client-ip=192.0.2.10; envelope-from=\"\\\"a b;\\\"@" + hostname + "\"; helo=" + hostname + ";", + result.getHeader()); + } + + @Test(timeout = 10000) + public void headerShouldQuoteTheAddressOfAnIpv6Client() throws IOException { + String hostname = "clientIpv6.spfexecutor.fake"; + FakeResolver fakeResolver = new FakeResolver(); + fakeResolver.addRecord(new TXTRecord(Name.fromString(hostname + "."), DClass.IN, 30L, "v=spf1 +all")); + SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); + + SPFResult result = spf.checkSPF("2001:db8::10", "a_user@" + hostname, hostname); + + // RFC 7208 9.1: ":" is no atext (RFC 5322 3.2.3), so the address is no dot-atom + assertEquals("Received-SPF: pass (spfCheck: domain of " + hostname + " designates 2001:db8::10 as permitted sender)" + + " client-ip=\"2001:db8::10\"; envelope-from=\"a_user@" + hostname + "\"; helo=" + hostname + ";", + result.getHeader()); + } + + @Test(timeout = 10000) + public void headerShouldQuoteAHeloEndingWithADot() throws IOException { + String hostname = "heloDot.spfexecutor.fake"; + FakeResolver fakeResolver = new FakeResolver(); + fakeResolver.addRecord(new TXTRecord(Name.fromString(hostname + "."), DClass.IN, 30L, "v=spf1 +all")); + SPF spf = createCustomSPF(new DNSServiceXBillImpl(fakeResolver)); + + SPFResult result = spf.checkSPF("192.0.2.10", "a_user@" + hostname, hostname + "."); + + // RFC 7208 9.1: a dot-atom ends with atext (RFC 5322 3.2.3) + assertEquals("Received-SPF: pass (spfCheck: domain of " + hostname + " designates 192.0.2.10 as permitted sender)" + + " client-ip=192.0.2.10; envelope-from=\"a_user@" + hostname + "\"; helo=\"" + hostname + ".\";", + result.getHeader()); + } + @Test(timeout = 10000) public void shouldReturnPermErrorOnAIp4CidrOverflowingAnInt() throws IOException { SPFResult result = checkRecord("aIp4CidrOverflow.spfexecutor.fake", "v=spf1 a/4294967296 -all", "1.2.3.4"); --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
