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]

Reply via email to