(tomcat) branch main updated: Implement HTTP QUREY (RFC 10008)

[email protected]
Newsgroups gmane.comp.jakarta.tomcat.devel
Message-ID <178290581138.4118911.12506552781214214089@gitbox3-he-fi.apache.org>
This is an automated email from the ASF dual-hosted git repository.

markt-asf pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/main by this push:
     new 5e01091299 Implement HTTP QUREY (RFC 10008)
5e01091299 is described below

commit 5e01091299e41bc79509b1c8d17486f85df1d872
Author: Mark Thomas <[email protected]>
AuthorDate: Thu Jun 25 20:28:12 2026 +0100

    Implement HTTP QUREY (RFC 10008)
    
    Based on pr #1026 by Mario Daniel Ruiz Saavedra.
---
 java/jakarta/servlet/http/HttpServlet.java         |  39 +++++-
 java/jakarta/servlet/http/LocalStrings.properties  |   1 +
 .../catalina/authenticator/FormAuthenticator.java  |   2 +-
 java/org/apache/catalina/connector/Connector.java  |   2 +-
 .../apache/catalina/connector/CoyoteAdapter.java   |   6 +
 .../catalina/connector/LocalStrings.properties     |   1 +
 .../org/apache/catalina/filters/ExpiresFilter.java |   4 +-
 java/org/apache/tomcat/util/http/Method.java       |  10 ++
 test/jakarta/servlet/http/TestHttpServlet.java     | 144 ++++++++++++++++++++-
 test/org/apache/tomcat/util/http/TestMethod.java   |   2 +-
 webapps/docs/changelog.xml                         |   5 +
 webapps/docs/config/ajp.xml                        |   2 +-
 webapps/docs/config/http.xml                       |   2 +-
 13 files changed, 210 insertions(+), 10 deletions(-)

diff --git a/java/jakarta/servlet/http/HttpServlet.java b/java/jakarta/servlet/http/HttpServlet.java
index 51b636abe1..e11006e055 100644
--- a/java/jakarta/servlet/http/HttpServlet.java
+++ b/java/jakarta/servlet/http/HttpServlet.java
@@ -80,6 +80,7 @@ public abstract class HttpServlet extends GenericServlet {
     private static final String METHOD_POST = "POST";
     private static final String METHOD_PUT = "PUT";
     private static final String METHOD_TRACE = "TRACE";
+    private static final String METHOD_QUERY = "QUERY";
 
     private static final String HEADER_IFMODSINCE = "If-Modified-Since";
     private static final String HEADER_LASTMOD = "Last-Modified";
@@ -266,6 +267,32 @@ public abstract class HttpServlet extends GenericServlet {
     }
 
 
+    /**
+     * Called by the server (via the <code>service</code> method) to allow a servlet to handle a QUERY request.
+     * The HTTP QUERY method is safe and idempotent and is used to query the target resource.
+     * <p>
+     * Unlike <code>doGet</code>, the container does not perform automatic conditional header processing
+     * (e.g. checking <code>If-Modified-Since</code> using <code>getLastModified</code>). Because a QUERY request
+     * contains a request body, reading the body to determine the modification time would consume the input stream,
+     * preventing the servlet from reading the query. Therefore, if conditional requests (RFC 10008, Section 2.6)
+     * are supported, the servlet must handle them manually inside this method.
+     *
+     * @param req  an {@link HttpServletRequest} object that contains the request the client has made of the servlet
+     * @param resp an {@link HttpServletResponse} object that contains the response the servlet sends to the client
+     *
+     * @exception IOException      if an input or output error is detected when the servlet handles the request
+     * @exception ServletException if the request for the QUERY could not be handled
+     *
+     * @see jakarta.servlet.ServletResponse#setContentType
+     *
+     * @since Servlet 6.2
+     */
+    protected void doQuery(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+        String msg = lStrings.getString("http.method_query_not_supported");
+        sendMethodNotAllowed(req, resp, msg);
+    }
+
+
     /**
      * Called by the server (via the <code>service</code> method) to allow a servlet to handle a POST request. The HTTP
      * POST method allows the client to send data of unlimited length to the Web server a single time and is useful when
@@ -391,6 +418,7 @@ public abstract class HttpServlet extends GenericServlet {
                     boolean allowPost = false;
                     boolean allowPut = false;
                     boolean allowDelete = false;
+                    boolean allowQuery = false;
 
                     for (Method method : methods) {
                         switch (method.getName()) {
@@ -415,10 +443,13 @@ public abstract class HttpServlet extends GenericServlet {
                                 allowDelete = true;
                                 break;
                             }
+                            case "doQuery": {
+                                allowQuery = true;
+                                break;
+                            }
                             default:
                                 // NO-OP
                         }
-
                     }
 
                     StringBuilder allow = new StringBuilder();
@@ -453,6 +484,11 @@ public abstract class HttpServlet extends GenericServlet {
                         allow.append(", ");
                     }
 
+                    if (allowQuery) {
+                        allow.append(METHOD_QUERY);
+                        allow.append(", ");
+                    }
+
                     // Options is always allowed
                     allow.append(METHOD_OPTIONS);
 
@@ -652,6 +688,7 @@ public abstract class HttpServlet extends GenericServlet {
             case METHOD_OPTIONS -> doOptions(req, resp);
             case METHOD_TRACE -> doTrace(req, resp);
             case METHOD_PATCH -> doPatch(req, resp);
+            case METHOD_QUERY -> doQuery(req, resp);
             default -> {
                 //
                 // Note that this means NO servlet supports whatever
diff --git a/java/jakarta/servlet/http/LocalStrings.properties b/java/jakarta/servlet/http/LocalStrings.properties
index 492548841f..157811fb70 100644
--- a/java/jakarta/servlet/http/LocalStrings.properties
+++ b/java/jakarta/servlet/http/LocalStrings.properties
@@ -27,6 +27,7 @@ http.method_delete_not_supported=HTTP method DELETE is not supported by this URL
 http.method_get_not_supported=HTTP method GET is not supported by this URL
 http.method_not_implemented=Method [{0}] is not implemented by this Servlet for this URI
 http.method_patch_not_supported=HTTP method PATCH is not supported by this URL
+http.method_query_not_supported=HTTP method QUERY is not supported by this URL
 http.method_post_not_supported=HTTP method POST is not supported by this URL
 http.method_put_not_supported=HTTP method PUT is not supported by this URL
 http.non_http=Non HTTP request or response
diff --git a/java/org/apache/catalina/authenticator/FormAuthenticator.java b/java/org/apache/catalina/authenticator/FormAuthenticator.java
index dde079f227..3157a70d7b 100644
--- a/java/org/apache/catalina/authenticator/FormAuthenticator.java
+++ b/java/org/apache/catalina/authenticator/FormAuthenticator.java
@@ -596,7 +596,7 @@ public class FormAuthenticator extends AuthenticatorBase {
         String method = saved.getMethod();
         MimeHeaders rmh = request.getCoyoteRequest().getMimeHeaders();
         rmh.recycle();
-        boolean cacheable = Method.GET.equals(method) || Method.HEAD.equals(method);
+        boolean cacheable = Method.GET.equals(method) || Method.HEAD.equals(method) || Method.QUERY.equals(method);
         Iterator<String> names = saved.getHeaderNames();
         while (names.hasNext()) {
             String name = names.next();
diff --git a/java/org/apache/catalina/connector/Connector.java b/java/org/apache/catalina/connector/Connector.java
index e3f89a0eb4..c39833ff65 100644
--- a/java/org/apache/catalina/connector/Connector.java
+++ b/java/org/apache/catalina/connector/Connector.java
@@ -269,7 +269,7 @@ public class Connector extends LifecycleMBeanBase {
      * Comma-separated list of HTTP methods that will be parsed according to POST-style rules for
      * application/x-www-form-urlencoded request bodies.
      */
-    protected String parseBodyMethods = Method.POST;
+    protected String parseBodyMethods = StringUtils.join(Method.POST, Method.QUERY);
 
     /**
      * A Set of methods determined by {@link #parseBodyMethods}.
diff --git a/java/org/apache/catalina/connector/CoyoteAdapter.java b/java/org/apache/catalina/connector/CoyoteAdapter.java
index 92fbecffea..05dcad99cc 100644
--- a/java/org/apache/catalina/connector/CoyoteAdapter.java
+++ b/java/org/apache/catalina/connector/CoyoteAdapter.java
@@ -794,6 +794,12 @@ public class CoyoteAdapter implements Adapter {
             }
         }
 
+        // Filter QUERY method without Content-Type
+        if (Method.QUERY.equals(req.getMethod()) && req.getContentType() == null) {
+            response.sendError(HttpServletResponse.SC_BAD_REQUEST, sm.getString("coyoteAdapter.query.contentTypeMissing"));
+            return true;
+        }
+
         // Possible redirect
         MessageBytes redirectPathMB = request.getMappingData().redirectPath;
         if (!redirectPathMB.isNull()) {
diff --git a/java/org/apache/catalina/connector/LocalStrings.properties b/java/org/apache/catalina/connector/LocalStrings.properties
index 536a9a1b65..aff818f742 100644
--- a/java/org/apache/catalina/connector/LocalStrings.properties
+++ b/java/org/apache/catalina/connector/LocalStrings.properties
@@ -24,6 +24,7 @@ coyoteAdapter.debug=The variable [{0}] has value [{1}]
 coyoteAdapter.invalidURI=Invalid URI
 coyoteAdapter.invalidURIWithMessage=Invalid URI: [{0}]
 coyoteAdapter.nullRequest=An asynchronous dispatch may only happen on an existing request
+coyoteAdapter.query.contentTypeMissing=HTTP requests using the QUERY method must have a Content-Type header
 coyoteAdapter.trace=TRACE method is not allowed
 
 coyoteConnector.invalidEncoding=The encoding [{0}] is not recognised by the JRE. The Connector will continue to use [{1}]
diff --git a/java/org/apache/catalina/filters/ExpiresFilter.java b/java/org/apache/catalina/filters/ExpiresFilter.java
index 0bf7de33b3..fc30b91b37 100644
--- a/java/org/apache/catalina/filters/ExpiresFilter.java
+++ b/java/org/apache/catalina/filters/ExpiresFilter.java
@@ -1473,9 +1473,9 @@ public class ExpiresFilter extends FilterBase {
     protected boolean isEligibleToExpirationHeaderGeneration(HttpServletRequest request,
             XHttpServletResponse response) {
 
-        // Don't add cache headers unless the request is a GET or a HEAD request
+        // Don't add cache headers unless the request is a GET, HEAD, or QUERY request
         String method = request.getMethod();
-        if (!Method.GET.equals(method) && !Method.HEAD.equals(method)) {
+        if (!Method.GET.equals(method) && !Method.HEAD.equals(method) && !Method.QUERY.equals(method)) {
             if (log.isDebugEnabled()) {
                 log.debug(sm.getString("expiresFilter.invalidMethod", request.getRequestURI(), method));
             }
diff --git a/java/org/apache/tomcat/util/http/Method.java b/java/org/apache/tomcat/util/http/Method.java
index 648d634ee4..7efb9fac31 100644
--- a/java/org/apache/tomcat/util/http/Method.java
+++ b/java/org/apache/tomcat/util/http/Method.java
@@ -66,6 +66,9 @@ public class Method {
     // Other methods recognised by Tomcat
     /** CONNECT method. */
     public static final String CONNECT = "CONNECT";
+    // RFC 10008 HTTP QUERY method
+    /** QUERY method. */
+    public static final String QUERY = "QUERY";
 
 
     /**
@@ -171,6 +174,13 @@ public class Method {
                 }
                 break;
             }
+            case 'Q': {
+                if (len == 5 && buf[start + 1] == 'U' && buf[start + 2] == 'E' && buf[start + 3] == 'R' &&
+                        buf[start + 4] == 'Y') {
+                    return QUERY;
+                }
+                break;
+            }
         }
 
         return null;
diff --git a/test/jakarta/servlet/http/TestHttpServlet.java b/test/jakarta/servlet/http/TestHttpServlet.java
index eb0caa2919..b92febb99d 100644
--- a/test/jakarta/servlet/http/TestHttpServlet.java
+++ b/test/jakarta/servlet/http/TestHttpServlet.java
@@ -225,6 +225,123 @@ public class TestHttpServlet extends TomcatBaseTest {
     }
 
 
+    @Test
+    public void testDoOptionsQuery() throws Exception {
+        doTestDoOptions(new OptionsServletQuery(), "GET, HEAD, QUERY, OPTIONS");
+    }
+
+
+    @Test
+    public void testQueryMethod() throws Exception {
+        Tomcat tomcat = getTomcatInstance();
+
+        // No file system docBase required
+        StandardContext ctx = (StandardContext) getProgrammaticRootContext();
+
+        // Map the test Servlet
+        OptionsServletQuery servlet = new OptionsServletQuery();
+        Tomcat.addServlet(ctx, "servlet", servlet);
+        ctx.addServletMappingDecoded("/", "servlet");
+
+        tomcat.start();
+
+        SimpleHttpClient client = new SimpleHttpClient() {
+            @Override
+            public boolean isResponseBodyOK() {
+                return true;
+            }
+        };
+        client.setPort(getPort());
+        client.setRequest(new String[] {
+                "QUERY / HTTP/1.1" + CRLF +
+                    "Host: localhost:" + getPort() + CRLF +
+                    "Content-Type: text/plain" + CRLF +
+                    "Connection: close" + CRLF +
+                    CRLF
+        });
+        client.connect();
+        client.sendRequest();
+        client.readResponse(true);
+
+        Assert.assertTrue(client.isResponse200());
+        Assert.assertEquals("OK", client.getResponseBody());
+    }
+
+
+    @Test
+    public void testQueryMethodWithoutContentType() throws Exception {
+        Tomcat tomcat = getTomcatInstance();
+
+        // No file system docBase required
+        StandardContext ctx = (StandardContext) getProgrammaticRootContext();
+
+        // Map the test Servlet
+        OptionsServletQuery servlet = new OptionsServletQuery();
+        Tomcat.addServlet(ctx, "servlet", servlet);
+        ctx.addServletMappingDecoded("/", "servlet");
+
+        tomcat.start();
+
+        SimpleHttpClient client = new SimpleHttpClient() {
+            @Override
+            public boolean isResponseBodyOK() {
+                return true;
+            }
+        };
+        client.setPort(getPort());
+        client.setRequest(new String[] {
+                "QUERY / HTTP/1.1" + CRLF +
+                    "Host: localhost:" + getPort() + CRLF +
+                    "Connection: close" + CRLF +
+                    CRLF
+        });
+        client.connect();
+        client.sendRequest();
+        client.readResponse(true);
+
+        Assert.assertTrue(client.isResponse400());
+    }
+
+
+    @Test
+    public void testQueryParameters() throws Exception {
+        Tomcat tomcat = getTomcatInstance();
+
+        // No file system docBase required
+        StandardContext ctx = (StandardContext) getProgrammaticRootContext();
+
+        // Map the test Servlet
+        ParamsServlet servlet = new ParamsServlet();
+        Tomcat.addServlet(ctx, "servlet", servlet);
+        ctx.addServletMappingDecoded("/", "servlet");
+
+        tomcat.start();
+
+        SimpleHttpClient client = new SimpleHttpClient() {
+            @Override
+            public boolean isResponseBodyOK() {
+                return true;
+            }
+        };
+        client.setPort(getPort());
+        client.setRequest(new String[] {
+                "QUERY /?baz=123 HTTP/1.1" + CRLF +
+                    "Host: localhost:" + getPort() + CRLF +
+                    "Content-Type: application/x-www-form-urlencoded" + CRLF +
+                    "Content-Length: 15" + CRLF +
+                    "Connection: close" + CRLF +
+                    CRLF +
+                    "foo=abc&bar=xyz"
+        });
+        client.connect();
+        client.sendRequest();
+        client.readResponse(true);
+
+        Assert.assertTrue(client.isResponse200());
+        Assert.assertEquals("abc,xyz,123", client.getResponseBody());
+    }
+
+
     private void doTestDoOptions(Servlet servlet, String expectedAllow) throws Exception {
         Tomcat tomcat = getTomcatInstance();
 
@@ -262,8 +379,6 @@ public class TestHttpServlet extends TomcatBaseTest {
      * See org.apache.coyote.http2.TestHttpServlet for the HTTP/2 version of this test. It was placed in that package
      * because it needed access to package private classes.
      */
-
-
     private void doTestUnimplementedMethod(String httpVersion) {
         StringBuilder request = new StringBuilder("PUT /test");
         boolean isHttp09 = "0.9".equals(httpVersion);
@@ -583,4 +698,29 @@ public class TestHttpServlet extends TomcatBaseTest {
             doGet(req, resp);
         }
     }
+
+
+    private static class OptionsServletQuery extends OptionsServlet {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        protected void doQuery(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+            doGet(req, resp);
+        }
+    }
+
+
+    private static class ParamsServlet extends HttpServlet {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        protected void doQuery(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+            resp.setContentType("text/plain");
+            resp.setCharacterEncoding("UTF-8");
+            PrintWriter pw = resp.getWriter();
+            pw.print(req.getParameter("foo") + "," + req.getParameter("bar") + "," + req.getParameter("baz"));
+        }
+    }
 }
diff --git a/test/org/apache/tomcat/util/http/TestMethod.java b/test/org/apache/tomcat/util/http/TestMethod.java
index a5fc7b7c28..e72ecdf0ad 100644
--- a/test/org/apache/tomcat/util/http/TestMethod.java
+++ b/test/org/apache/tomcat/util/http/TestMethod.java
@@ -32,7 +32,7 @@ public class TestMethod {
     public void testHttpMethodParsing() {
         List<String> methods = Arrays.asList(Method.GET, Method.POST, Method.PUT, Method.PATCH, Method.HEAD,
                 Method.OPTIONS, Method.DELETE, Method.TRACE, Method.PROPPATCH, Method.PROPFIND, Method.MKCOL,
-                Method.COPY, Method.MOVE, Method.LOCK, Method.UNLOCK, Method.CONNECT);
+                Method.COPY, Method.MOVE, Method.LOCK, Method.UNLOCK, Method.CONNECT, Method.QUERY);
 
         for (String method : methods) {
             byte[] bytes = method.getBytes(StandardCharsets.ISO_8859_1);
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 01a487fcd3..a726b3ed70 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -225,6 +225,11 @@
         Reject BASIC authorization with empty user names as required by RFC
         7613. (markt)
       </fix>
+      <add>
+        Add support for the HTTP <code>QUERY</code> method as defined in RFC
+        10008. Based on pull request <pr>1026</pr> by Mario Daniel Ruiz
+        Saavedra. (markt)
+      </add>
       <!-- Entries for backport and removal before 12.0.0-M1 below this line -->
       <fix>
         Avoid a race condition with concurrent lookups for a singleton JNDI
diff --git a/webapps/docs/config/ajp.xml b/webapps/docs/config/ajp.xml
index f1c33155a4..e4df3fd4ce 100644
--- a/webapps/docs/config/ajp.xml
+++ b/webapps/docs/config/ajp.xml
@@ -247,7 +247,7 @@
       specification.
       The HTTP method TRACE is specifically forbidden here in accordance
       with the HTTP specification.
-      The default is <code>POST</code></p>
+      The default is <code>POST,QUERY</code></p>
     </attribute>
 
     <attribute name="port" required="true">
diff --git a/webapps/docs/config/http.xml b/webapps/docs/config/http.xml
index 49dd080e08..33f357f71c 100644
--- a/webapps/docs/config/http.xml
+++ b/webapps/docs/config/http.xml
@@ -245,7 +245,7 @@
       specification.
       The HTTP method TRACE is specifically forbidden here in accordance
       with the HTTP specification.
-      The default is <code>POST</code></p>
+      The default is <code>POST,QUERY</code></p>
     </attribute>
 
     <attribute name="port" required="true">
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.