This is an automated email from the ASF dual-hosted git repository.

btellier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git


The following commit(s) were added to refs/heads/master by this push:
     new 5999e6a345 [FIX] Prevent stackoverflow in IMAP SEARCH (#3007)
5999e6a345 is described below

commit 5999e6a345ca6a67f215c032811fa735cd520152
Author: Benoit TELLIER <[email protected]>
AuthorDate: Mon Apr 13 07:16:11 2026 +0200

    [FIX] Prevent stackoverflow in IMAP SEARCH (#3007)
---
 .../imap/decode/parser/SearchCommandParser.java    |  57 +++++----
 .../SearchCommandParserNestingDepthTest.java       | 132 +++++++++++++++++++++
 2 files changed, 165 insertions(+), 24 deletions(-)

diff --git 
a/protocols/imap/src/main/java/org/apache/james/imap/decode/parser/SearchCommandParser.java
 
b/protocols/imap/src/main/java/org/apache/james/imap/decode/parser/SearchCommandParser.java
index efb244940b..4f76e6a83e 100644
--- 
a/protocols/imap/src/main/java/org/apache/james/imap/decode/parser/SearchCommandParser.java
+++ 
b/protocols/imap/src/main/java/org/apache/james/imap/decode/parser/SearchCommandParser.java
@@ -67,6 +67,7 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
     }
 
     private static final Logger LOGGER = 
LoggerFactory.getLogger(SearchCommandParser.class);
+    private static final int MAX_NESTING_DEPTH = 32;
 
     @Inject
     public SearchCommandParser(StatusResponseFactory statusResponseFactory) {
@@ -84,22 +85,26 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
      *            true when this is the first token read, false otherwise
      */
     protected SearchKey searchKey(ImapSession session, ImapRequestLineReader 
request, Context context, boolean isFirstToken) throws DecodingException, 
IllegalCharsetNameException, UnsupportedCharsetException {
+        return searchKey(session, request, context, isFirstToken, 0);
+    }
+
+    private SearchKey searchKey(ImapSession session, ImapRequestLineReader 
request, Context context, boolean isFirstToken, int depth) throws 
DecodingException, IllegalCharsetNameException, UnsupportedCharsetException {
         final char next = request.nextChar();
-        
+
         if (next >= '0' && next <= '9' || next == '*' || next == '$') {
             return sequenceSet(session, request);
         } else if (next == '(') {
-            return paren(session, request, context);
+            return paren(session, request, context, depth + 1);
         } else {
             final int cap = consumeAndCap(request);
             switch (cap) {
-            
+
             case 'A':
                 return a(request);
             case 'B':
                 return b(request, context.getCharset());
             case 'C':
-                return c(session, request, isFirstToken, context);
+                return c(session, request, isFirstToken, context, depth);
             case 'D':
                 return d(request);
             case 'E':
@@ -121,9 +126,9 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
             case 'M':
                 return modseq(request);
             case 'N':
-                return n(session, request, context);
+                return n(session, request, context, depth);
             case 'O':
-                return o(session, request, context);
+                return o(session, request, context, depth);
             case 'P':
                 throw new 
DecodingException(HumanReadableText.ILLEGAL_ARGUMENTS, "Unknown search key");
             case 'Q':
@@ -164,21 +169,25 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
         }
     }
 
-    private SearchKey paren(ImapSession session, ImapRequestLineReader 
request, Context context) throws DecodingException {
+    private SearchKey paren(ImapSession session, ImapRequestLineReader 
request, Context context, int depth) throws DecodingException {
+        if (depth > MAX_NESTING_DEPTH) {
+            throw new DecodingException(HumanReadableText.ILLEGAL_ARGUMENTS,
+                "Maximum SEARCH nesting depth of " + MAX_NESTING_DEPTH + " 
exceeded");
+        }
         request.consume();
         List<SearchKey> keys = new ArrayList<>();
-        addUntilParen(session, request, keys, context);
+        addUntilParen(session, request, keys, context, depth);
         return SearchKey.buildAnd(keys);
     }
 
-    private void addUntilParen(ImapSession session, ImapRequestLineReader 
request, List<SearchKey> keys, Context context) throws DecodingException {
+    private void addUntilParen(ImapSession session, ImapRequestLineReader 
request, List<SearchKey> keys, Context context, int depth) throws 
DecodingException {
         final char next = request.nextWordChar();
         if (next == ')') {
             request.consume();
         } else {
-            final SearchKey key = searchKey(session, request, context, false);
+            final SearchKey key = searchKey(session, request, context, false, 
depth);
             keys.add(key);
-            addUntilParen(session, request, keys, context);
+            addUntilParen(session, request, keys, context, depth);
         }
     }
 
@@ -195,19 +204,19 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
         return result;
     }
 
-    private SearchKey c(ImapSession session, ImapRequestLineReader request, 
boolean isFirstToken, Context context) throws DecodingException, 
IllegalCharsetNameException, UnsupportedCharsetException {
+    private SearchKey c(ImapSession session, ImapRequestLineReader request, 
boolean isFirstToken, Context context, int depth) throws DecodingException, 
IllegalCharsetNameException, UnsupportedCharsetException {
         final int next = consumeAndCap(request);
         switch (next) {
         case 'C':
             return cc(request, context.getCharset());
         case 'H':
-            return charset(session, request, isFirstToken, context);
+            return charset(session, request, isFirstToken, context, depth);
         default:
             throw new DecodingException(HumanReadableText.ILLEGAL_ARGUMENTS, 
"Unknown search key");
         }
     }
 
-    private SearchKey charset(ImapSession session, ImapRequestLineReader 
request, boolean isFirstToken, Context context) throws DecodingException, 
IllegalCharsetNameException, UnsupportedCharsetException {
+    private SearchKey charset(ImapSession session, ImapRequestLineReader 
request, boolean isFirstToken, Context context, int depth) throws 
DecodingException, IllegalCharsetNameException, UnsupportedCharsetException {
         final SearchKey result;
         nextIsA(request);
         nextIsR(request);
@@ -222,7 +231,7 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
         final Charset charset = Charset.forName(value);
         request.nextWordChar();
         context.setCharset(charset);
-        result = searchKey(session, request, context, false);
+        result = searchKey(session, request, context, false, depth);
         return result;
     }
 
@@ -356,7 +365,7 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
         }
     }
 
-    private SearchKey o(ImapSession session, ImapRequestLineReader request, 
Context context) throws DecodingException {
+    private SearchKey o(ImapSession session, ImapRequestLineReader request, 
Context context, int depth) throws DecodingException {
         final int next = consumeAndCap(request);
         switch (next) {
         case 'L':
@@ -364,7 +373,7 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
         case 'N':
             return on(request);
         case 'R':
-            return or(session, request, context);
+            return or(session, request, context, depth);
         default:
             throw new DecodingException(HumanReadableText.ILLEGAL_ARGUMENTS, 
"Unknown search key");
         }
@@ -381,13 +390,13 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
         }
     }
     
-    private SearchKey n(ImapSession session, ImapRequestLineReader request, 
Context context) throws DecodingException {
+    private SearchKey n(ImapSession session, ImapRequestLineReader request, 
Context context, int depth) throws DecodingException {
         final int next = consumeAndCap(request);
         switch (next) {
         case 'E':
             return newOperator(request);
         case 'O':
-            return not(session, request, context);
+            return not(session, request, context, depth);
         default:
             throw new DecodingException(HumanReadableText.ILLEGAL_ARGUMENTS, 
"Unknown search key");
         }
@@ -583,21 +592,21 @@ public class SearchCommandParser extends 
AbstractUidCommandParser {
         return result;
     }
 
-    private SearchKey or(ImapSession session, ImapRequestLineReader request, 
Context context) throws DecodingException {
+    private SearchKey or(ImapSession session, ImapRequestLineReader request, 
Context context, int depth) throws DecodingException {
         final SearchKey result;
         nextIsSpace(request);
-        final SearchKey firstKey = searchKey(session, request, context, false);
+        final SearchKey firstKey = searchKey(session, request, context, false, 
depth);
         nextIsSpace(request);
-        final SearchKey secondKey = searchKey(session, request, context, 
false);
+        final SearchKey secondKey = searchKey(session, request, context, 
false, depth);
         result = SearchKey.buildOr(firstKey, secondKey);
         return result;
     }
 
-    private SearchKey not(ImapSession session, ImapRequestLineReader request, 
Context context) throws DecodingException {
+    private SearchKey not(ImapSession session, ImapRequestLineReader request, 
Context context, int depth) throws DecodingException {
         final SearchKey result;
         nextIsT(request);
         nextIsSpace(request);
-        final SearchKey nextKey = searchKey(session, request, context, false);
+        final SearchKey nextKey = searchKey(session, request, context, false, 
depth);
         result = SearchKey.buildNot(nextKey);
         return result;
     }
diff --git 
a/protocols/imap/src/test/java/org/apache/james/imap/decode/parser/SearchCommandParserNestingDepthTest.java
 
b/protocols/imap/src/test/java/org/apache/james/imap/decode/parser/SearchCommandParserNestingDepthTest.java
new file mode 100644
index 0000000000..c7069403b9
--- /dev/null
+++ 
b/protocols/imap/src/test/java/org/apache/james/imap/decode/parser/SearchCommandParserNestingDepthTest.java
@@ -0,0 +1,132 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.imap.decode.parser;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.james.imap.api.message.response.StatusResponseFactory;
+import org.apache.james.imap.decode.DecodingException;
+import org.apache.james.imap.decode.ImapRequestStreamLineReader;
+import org.apache.james.imap.decode.parser.SearchCommandParser.Context;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class SearchCommandParserNestingDepthTest {
+
+    private static final int MAX_NESTING_DEPTH = 32;
+
+    private SearchCommandParser parser;
+
+    @BeforeEach
+    void setUp() {
+        parser = new SearchCommandParser(mock(StatusResponseFactory.class));
+    }
+
+    private ImapRequestStreamLineReader reader(String input) {
+        return new ImapRequestStreamLineReader(
+            new 
ByteArrayInputStream(input.getBytes(StandardCharsets.US_ASCII)),
+            new ByteArrayOutputStream());
+    }
+
+    /** Builds "(((...(ALL)...)))\r\n" with exactly {@code depth} levels of 
parentheses. */
+    private String nested(int depth) {
+        return nestedNoEol(depth) + "\r\n";
+    }
+
+    /** Same without trailing CRLF, for embedding inside compound commands 
(OR, NOT). */
+    private String nestedNoEol(int depth) {
+        return "(".repeat(depth) + "ALL" + ")".repeat(depth);
+    }
+
+    @Test
+    void nestingAtMaxDepthShouldBeAccepted() {
+        assertThatCode(() ->
+            parser.searchKey(null, reader(nested(MAX_NESTING_DEPTH)), new 
Context(), false))
+            .doesNotThrowAnyException();
+    }
+
+    @Test
+    void nestingBelowMaxDepthShouldBeAccepted() {
+        assertThatCode(() ->
+            parser.searchKey(null, reader(nested(MAX_NESTING_DEPTH - 1)), new 
Context(), false))
+            .doesNotThrowAnyException();
+    }
+
+    @Test
+    void nestingOneAboveMaxDepthShouldBeRejected() {
+        assertThatThrownBy(() ->
+            parser.searchKey(null, reader(nested(MAX_NESTING_DEPTH + 1)), new 
Context(), false))
+            .isInstanceOf(DecodingException.class)
+            .hasMessageContaining("nesting depth");
+    }
+
+    @Test
+    void deeplyNestedPayloadShouldBeRejectedWithoutStackOverflow() {
+        // 3998 levels fits in 8000 chars (the Netty line limit).
+        // Before the fix this caused StackOverflowError; after the fix it must
+        // throw a clean DecodingException well before the stack is exhausted.
+        String payload = nested(3998);
+        assertThatThrownBy(() ->
+            parser.searchKey(null, reader(payload), new Context(), false))
+            .isInstanceOf(DecodingException.class)
+            .isNotInstanceOf(StackOverflowError.class)
+            .hasMessageContaining("nesting depth");
+    }
+
+    @Test
+    void notWrappingNestingAtMaxDepthShouldBeAccepted() {
+        String payload = "NOT " + nestedNoEol(MAX_NESTING_DEPTH) + "\r\n";
+        assertThatCode(() ->
+            parser.searchKey(null, reader(payload), new Context(), false))
+            .doesNotThrowAnyException();
+    }
+
+    @Test
+    void notWrappingNestingAboveMaxDepthShouldBeRejected() {
+        String payload = "NOT " + nestedNoEol(MAX_NESTING_DEPTH + 1) + "\r\n";
+        assertThatThrownBy(() ->
+            parser.searchKey(null, reader(payload), new Context(), false))
+            .isInstanceOf(DecodingException.class)
+            .hasMessageContaining("nesting depth");
+    }
+
+    @Test
+    void orWithBothArgsAtMaxDepthShouldBeAccepted() {
+        String payload = "OR " + nestedNoEol(MAX_NESTING_DEPTH) + " " + 
nestedNoEol(MAX_NESTING_DEPTH) + "\r\n";
+        assertThatCode(() ->
+            parser.searchKey(null, reader(payload), new Context(), false))
+            .doesNotThrowAnyException();
+    }
+
+    @Test
+    void orWithOneArgAboveMaxDepthShouldBeRejected() {
+        String payload = "OR " + nestedNoEol(MAX_NESTING_DEPTH + 1) + " 
ALL\r\n";
+        assertThatThrownBy(() ->
+            parser.searchKey(null, reader(payload), new Context(), false))
+            .isInstanceOf(DecodingException.class)
+            .hasMessageContaining("nesting depth");
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to