From 02a0c84cdcd44252fca12718df7a5172a97c6726 Mon Sep 17 00:00:00 2001 From: Vidya m Date: Fri, 6 Nov 2026 15:12:20 +0530 Subject: [PATCH] Check credentials and allowed methods in the XMLHttpRequest preflight isPreflightAuthorized() accepted 'Access-Control-Allow-Origin: *' and did not check Access-Control-Allow-Credentials, so a wildcard preflight was enough to send a request with credentials (cookies) to another origin. Access-Control-Allow-Methods was not checked at all, and 'Access-Control-Allow-Headers: *' was also honoured for requests with credentials. Real browsers stop at the preflight in all these cases and never send the request. Run the preflight response through the CORS check used for the other responses, require methods other than GET, HEAD and POST to be listed in Access-Control-Allow-Methods and support the wildcard for methods and headers only for requests without credentials. --- src/changes/changes.xml | 6 + src/main/java/org/htmlunit/HttpHeader.java | 2 + .../javascript/host/xml/XMLHttpRequest.java | 45 +++- .../host/xml/XMLHttpRequestCORSTest.java | 196 ++++++++++++++++++ 4 files changed, 244 insertions(+), 5 deletions(-) diff --git a/src/changes/changes.xml b/src/changes/changes.xml index ee7d700999f..eb93d716c4c 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -8,6 +8,12 @@ + + XMLHttpRequest: the CORS preflight response is checked like in real browsers; requests with + credentials need the exact origin and Access-Control-Allow-Credentials, the wildcard '*' is not + supported for them and methods other than GET, HEAD and POST have to be listed + in Access-Control-Allow-Methods. + Support for the autofocus attribute; after the page is loaded the first focusable element with the autofocus attribute gets the focus. diff --git a/src/main/java/org/htmlunit/HttpHeader.java b/src/main/java/org/htmlunit/HttpHeader.java index bb2e3883249..c297af7f377 100644 --- a/src/main/java/org/htmlunit/HttpHeader.java +++ b/src/main/java/org/htmlunit/HttpHeader.java @@ -142,6 +142,8 @@ public final class HttpHeader { public static final String ACCESS_CONTROL_ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials"; /** Access-Control-Allow-Headers. */ public static final String ACCESS_CONTROL_ALLOW_HEADERS = "Access-Control-Allow-Headers"; + /** Access-Control-Allow-Methods. */ + public static final String ACCESS_CONTROL_ALLOW_METHODS = "Access-Control-Allow-Methods"; /** Ping-From. */ public static final String PING_FROM = "Ping-From"; diff --git a/src/main/java/org/htmlunit/javascript/host/xml/XMLHttpRequest.java b/src/main/java/org/htmlunit/javascript/host/xml/XMLHttpRequest.java index 040a8f51154..d1735a37360 100644 --- a/src/main/java/org/htmlunit/javascript/host/xml/XMLHttpRequest.java +++ b/src/main/java/org/htmlunit/javascript/host/xml/XMLHttpRequest.java @@ -117,6 +117,7 @@ * @author Lai Quang Duong * @author Sven Strickroth * @author Ashutosh Panda + * @author Vidya m * * @see MDN Documentation */ @@ -1395,10 +1396,19 @@ private static boolean isPreflight(final WebRequest request) { return false; } - private static boolean isPreflightAuthorized(final WebRequest request, final WebResponse preflightResponse) { - final String originHeader = preflightResponse.getResponseHeaderValue(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN); - if (!ALLOW_ORIGIN_ALL.equals(originHeader) - && !request.getAdditionalHeaders().get(HttpHeader.ORIGIN).equals(originHeader)) { + private boolean isPreflightAuthorized(final WebRequest request, final WebResponse preflightResponse) { + // the preflight response has to pass the CORS check too; for a request with credentials + // this requires the exact origin and Access-Control-Allow-Credentials + if (!isCorsAllowed(preflightResponse, request.getAdditionalHeaders().get(HttpHeader.ORIGIN))) { + return false; + } + + // the wildcard is not supported for requests with credentials + final boolean wildcard = !isWithCredentials(); + + final HttpMethod method = request.getHttpMethod(); + if (method != HttpMethod.GET && method != HttpMethod.HEAD && method != HttpMethod.POST + && !isPreflightMethodAllowed(method.name(), preflightResponse, wildcard)) { return false; } @@ -1409,7 +1419,7 @@ private static boolean isPreflightAuthorized(final WebRequest request, final Web if (HttpHeader.ACCESS_CONTROL_ALLOW_HEADERS.equalsIgnoreCase(pair.getName())) { String value = pair.getValue(); if (value != null) { - if (ALLOW_ORIGIN_ALL.equals(value)) { + if (wildcard && ALLOW_ORIGIN_ALL.equals(value)) { // all headers are allowed return true; } @@ -1435,6 +1445,31 @@ private static boolean isPreflightAuthorized(final WebRequest request, final Web return true; } + /** + * Checks whether the given method is listed in the Access-Control-Allow-Methods header + * of the preflight response (the method names are case-sensitive). + * + * @param method the method name + * @param preflightResponse the preflight response + * @param wildcard whether the wildcard '*' allows all methods + * @return {@code true} if the method is allowed + */ + private static boolean isPreflightMethodAllowed(final String method, final WebResponse preflightResponse, + final boolean wildcard) { + for (final NameValuePair pair : preflightResponse.getResponseHeaders()) { + if (HttpHeader.ACCESS_CONTROL_ALLOW_METHODS.equalsIgnoreCase(pair.getName()) + && pair.getValue() != null) { + for (final String part : StringUtils.splitAtComma(pair.getValue())) { + final String allowed = part.trim(); + if (method.equals(allowed) || (wildcard && ALLOW_ORIGIN_ALL.equals(allowed))) { + return true; + } + } + } + } + return false; + } + /** * Checks whether the given header name requires a preflight request. * diff --git a/src/test/java/org/htmlunit/javascript/host/xml/XMLHttpRequestCORSTest.java b/src/test/java/org/htmlunit/javascript/host/xml/XMLHttpRequestCORSTest.java index 051fa236197..49c6968a317 100644 --- a/src/test/java/org/htmlunit/javascript/host/xml/XMLHttpRequestCORSTest.java +++ b/src/test/java/org/htmlunit/javascript/host/xml/XMLHttpRequestCORSTest.java @@ -49,6 +49,7 @@ * @author Ronald Brill * @author Frank Danek * @author Lai Quang Duong + * @author Vidya m */ public class XMLHttpRequestCORSTest extends WebDriverTestCase { @@ -721,6 +722,201 @@ public void preflight_wildcard_allow_headers() throws Exception { verifyTitle2(getWebDriver(), getExpectedAlerts()); } + /** + * @throws Exception if the test fails. + */ + @Test + @Alerts({"4", "200", "PUT null"}) + public void preflight_put() throws Exception { + doPreflightTestReceived(false, "PUT", "http://localhost:" + PORT, null, "PUT", "X-PING"); + } + + /** + * A method that is not CORS-safelisted has to be listed in Access-Control-Allow-Methods. + * @throws Exception if the test fails. + */ + @Test + @Alerts({"NetworkError/DOMException", "4", "0", "nothing"}) + public void preflight_put_methodNotAllowed() throws Exception { + doPreflightTestReceived(false, "PUT", "http://localhost:" + PORT, null, "POST, GET, OPTIONS", "X-PING"); + } + + /** + * @throws Exception if the test fails. + */ + @Test + @Alerts({"4", "200", "PUT null"}) + public void preflight_put_wildcardMethods() throws Exception { + doPreflightTestReceived(false, "PUT", "http://localhost:" + PORT, null, "*", "X-PING"); + } + + /** + * @throws Exception if the test fails. + */ + @Test + @Alerts({"4", "200", "PUT cookie=sweet"}) + public void preflight_put_withCredentials() throws Exception { + doPreflightTestReceived(true, "PUT", "http://localhost:" + PORT, "true", "PUT", "X-PING"); + } + + /** + * The wildcard origin is not allowed for requests with credentials, the request must not be sent. + * @throws Exception if the test fails. + */ + @Test + @Alerts({"NetworkError/DOMException", "4", "0", "nothing"}) + public void preflight_put_withCredentials_wildcardOrigin() throws Exception { + doPreflightTestReceived(true, "PUT", "*", "true", "PUT", "X-PING"); + } + + /** + * A request with credentials needs Access-Control-Allow-Credentials in the preflight response. + * @throws Exception if the test fails. + */ + @Test + @Alerts({"NetworkError/DOMException", "4", "0", "nothing"}) + public void preflight_put_withCredentials_noAllowCredentials() throws Exception { + doPreflightTestReceived(true, "PUT", "http://localhost:" + PORT, null, "PUT", "X-PING"); + } + + /** + * The wildcard methods are not allowed for requests with credentials. + * @throws Exception if the test fails. + */ + @Test + @Alerts({"NetworkError/DOMException", "4", "0", "nothing"}) + public void preflight_put_withCredentials_wildcardMethods() throws Exception { + doPreflightTestReceived(true, "PUT", "http://localhost:" + PORT, "true", "*", "X-PING"); + } + + /** + * The wildcard headers are not allowed for requests with credentials. + * @throws Exception if the test fails. + */ + @Test + @Alerts({"NetworkError/DOMException", "4", "0", "nothing"}) + public void preflight_withCredentials_wildcardHeaders() throws Exception { + doPreflightTestReceived(true, "GET", "http://localhost:" + PORT, "true", null, "*"); + } + + /** + * Sends a request that needs a preflight and logs afterwards the request that + * reached the server ('nothing' if the request was not sent). + */ + private void doPreflightTestReceived(final boolean withCredentials, final String method, + final String allowOrigin, final String allowCredentials, + final String allowMethods, final String allowHeaders) throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + ""; + + PreflightReceivedServlet.ACCESS_CONTROL_ALLOW_ORIGIN_ = allowOrigin; + PreflightReceivedServlet.ACCESS_CONTROL_ALLOW_CREDENTIALS_ = allowCredentials; + PreflightReceivedServlet.ACCESS_CONTROL_ALLOW_METHODS_ = allowMethods; + PreflightReceivedServlet.ACCESS_CONTROL_ALLOW_HEADERS_ = allowHeaders; + PreflightReceivedServlet.RECEIVED_ = null; + + final Map> servlets2 = new HashMap<>(); + servlets2.put("/preflight2", PreflightReceivedServlet.class); + startWebServer2(".", servlets2); + + final List responseHeader = new ArrayList<>(); + responseHeader.add(new NameValuePair("Set-Cookie", "cookie=sweet")); + + final URL url = new URL(URL_FIRST, "/preflight1"); + getMockWebConnection().setResponse(url, html, + 200, "OK", "text/html;charset=ISO-8859-1", ISO_8859_1, responseHeader); + + loadPage2(url, null); + verifyTitle2(getWebDriver(), getExpectedAlerts()); + } + + /** + * Preflight CORS scenario Servlet; remembers the method and the cookie of the request + * that was sent after the preflight and answers the 'received' GET request with them. + */ + public static class PreflightReceivedServlet extends HttpServlet { + private static String ACCESS_CONTROL_ALLOW_ORIGIN_; + private static String ACCESS_CONTROL_ALLOW_CREDENTIALS_; + private static String ACCESS_CONTROL_ALLOW_METHODS_; + private static String ACCESS_CONTROL_ALLOW_HEADERS_; + private static String RECEIVED_; + + /** + * {@inheritDoc} + */ + @Override + protected void doOptions(final HttpServletRequest request, final HttpServletResponse response) { + setCorsHeaders(response); + if (ACCESS_CONTROL_ALLOW_METHODS_ != null) { + response.setHeader("Access-Control-Allow-Methods", ACCESS_CONTROL_ALLOW_METHODS_); + } + if (ACCESS_CONTROL_ALLOW_HEADERS_ != null) { + response.setHeader("Access-Control-Allow-Headers", ACCESS_CONTROL_ALLOW_HEADERS_); + } + } + + /** + * {@inheritDoc} + */ + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) throws IOException { + if ("received".equals(request.getQueryString())) { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setContentType(MimeType.TEXT_PLAIN); + response.getWriter().write(RECEIVED_ == null ? "nothing" : RECEIVED_); + return; + } + received(request, response); + } + + /** + * {@inheritDoc} + */ + @Override + protected void doPut(final HttpServletRequest request, final HttpServletResponse response) throws IOException { + received(request, response); + } + + private static void received(final HttpServletRequest request, final HttpServletResponse response) + throws IOException { + RECEIVED_ = request.getMethod() + " " + request.getHeader(HttpHeader.COOKIE); + setCorsHeaders(response); + response.setContentType(MimeType.TEXT_PLAIN); + response.getWriter().write("ok"); + } + + private static void setCorsHeaders(final HttpServletResponse response) { + if (ACCESS_CONTROL_ALLOW_ORIGIN_ != null) { + response.setHeader("Access-Control-Allow-Origin", ACCESS_CONTROL_ALLOW_ORIGIN_); + } + if (ACCESS_CONTROL_ALLOW_CREDENTIALS_ != null) { + response.setHeader("Access-Control-Allow-Credentials", ACCESS_CONTROL_ALLOW_CREDENTIALS_); + } + } + } + /** * @throws Exception if the test fails. */