diff --git a/java/jakarta/servlet/http/HttpServlet.java b/java/jakarta/servlet/http/HttpServlet.java index 51b636abe1fd..e4d9a6e64121 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,33 @@ protected void doPatch(HttpServletRequest req, HttpServletResponse resp) throws } + /** + * Called by the server (via the service 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. + *

+ * Unlike doGet, the container does not perform automatic conditional header processing + * (e.g. checking If-Modified-Since using getLastModified). 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 service 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 +419,8 @@ private String getCachedAllowHeaderValue() { boolean allowPost = false; boolean allowPut = false; boolean allowDelete = false; + boolean allowQuery = false; + for (Method method : methods) { switch (method.getName()) { @@ -415,10 +445,15 @@ private String getCachedAllowHeaderValue() { allowDelete = true; break; } + case "doQuery": { + allowQuery = true; + break; + } default: // NO-OP } + } StringBuilder allow = new StringBuilder(); @@ -453,6 +488,12 @@ private String getCachedAllowHeaderValue() { allow.append(", "); } + if (allowQuery) { + allow.append(METHOD_QUERY); + allow.append(", "); + } + + // Options is always allowed allow.append(METHOD_OPTIONS); @@ -652,6 +693,8 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) throws 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 492548841fbf..157811fb7099 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 dde079f227da..8e49aa1a3e4c 100644 --- a/java/org/apache/catalina/authenticator/FormAuthenticator.java +++ b/java/org/apache/catalina/authenticator/FormAuthenticator.java @@ -596,7 +596,7 @@ protected boolean restoreRequest(Request request, Session session) throws IOExce 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 names = saved.getHeaderNames(); while (names.hasNext()) { String name = names.next(); @@ -630,7 +630,7 @@ protected boolean restoreRequest(Request request, Session session) throws IOExce // If no content type specified, use default for POST String savedContentType = saved.getContentType(); - if (savedContentType == null && Method.POST.equals(method)) { + if (savedContentType == null && (Method.POST.equals(method) || Method.QUERY.equals(method))) { savedContentType = Globals.CONTENT_TYPE_FORM_URL_ENCODING; } diff --git a/java/org/apache/catalina/connector/CoyoteAdapter.java b/java/org/apache/catalina/connector/CoyoteAdapter.java index 92fbecffea44..cd702bbe24f7 100644 --- a/java/org/apache/catalina/connector/CoyoteAdapter.java +++ b/java/org/apache/catalina/connector/CoyoteAdapter.java @@ -815,6 +815,12 @@ protected boolean postParseRequest(org.apache.coyote.Request req, Request reques return false; } + // 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; + } + // Filter TRACE method if (!connector.getAllowTrace() && Method.TRACE.equals(req.getMethod())) { Wrapper wrapper = request.getWrapper(); diff --git a/java/org/apache/catalina/connector/LocalStrings.properties b/java/org/apache/catalina/connector/LocalStrings.properties index 536a9a1b656f..10689d385a0b 100644 --- a/java/org/apache/catalina/connector/LocalStrings.properties +++ b/java/org/apache/catalina/connector/LocalStrings.properties @@ -20,6 +20,7 @@ coyoteAdapter.authorize=Authorizing user [{0}] using Tomcat''s Realm coyoteAdapter.checkRecycled.request=Encountered a non-recycled request and recycled it forcedly. coyoteAdapter.checkRecycled.response=Encountered a non-recycled response and recycled it forcedly. coyoteAdapter.connect=HTTP requests using the CONNECT method are not supported +coyoteAdapter.query.contentTypeMissing=HTTP requests using the QUERY method must have a Content-Type header coyoteAdapter.debug=The variable [{0}] has value [{1}] coyoteAdapter.invalidURI=Invalid URI coyoteAdapter.invalidURIWithMessage=Invalid URI: [{0}] diff --git a/java/org/apache/catalina/filters/ExpiresFilter.java b/java/org/apache/catalina/filters/ExpiresFilter.java index 0bf7de33b31d..fc30b91b3700 100644 --- a/java/org/apache/catalina/filters/ExpiresFilter.java +++ b/java/org/apache/catalina/filters/ExpiresFilter.java @@ -1473,9 +1473,9 @@ public void init(FilterConfig filterConfig) throws ServletException { 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 648d634ee434..045cddba5a2e 100644 --- a/java/org/apache/tomcat/util/http/Method.java +++ b/java/org/apache/tomcat/util/http/Method.java @@ -66,6 +66,10 @@ 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 +175,13 @@ public static String bytesToString(byte[] buf, int start, int len) { } 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 eb0caa291977..4d1e5a3dd109 100644 --- a/test/jakarta/servlet/http/TestHttpServlet.java +++ b/test/jakarta/servlet/http/TestHttpServlet.java @@ -225,6 +225,85 @@ public void testDoOptionsSub() throws Exception { } + @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()); + } + + + private void doTestDoOptions(Servlet servlet, String expectedAllow) throws Exception { Tomcat tomcat = getTomcatInstance(); @@ -583,4 +662,16 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws S 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); + } + } } + diff --git a/test/org/apache/tomcat/util/http/TestMethod.java b/test/org/apache/tomcat/util/http/TestMethod.java index a5fc7b7c28b5..e72ecdf0ad58 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 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 8c0734370b33..82081525befc 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -136,6 +136,10 @@ Add the Jakarta EE 12 XML schemas. (markt) + + Add support for the HTTP QUERY method as defined in RFC + 10008. (desiderantes) + Add support for the new Servlet API method HttpServletResponse.sendEarlyHints(). (markt)