diff --git a/src/changes/changes.xml b/src/changes/changes.xml index df01d12bb6..fa0055ad7e 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. + Content-Security-Policy frame-ancestors: no NullPointerException if the url of the parent frame is not a network url (e.g. data:); about:blank frames inherit the origin of their parent. diff --git a/src/main/java/org/htmlunit/HttpHeader.java b/src/main/java/org/htmlunit/HttpHeader.java index bb2e388324..c297af7f37 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 040a8f5115..d1735a3736 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 051fa23619..49c6968a31 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. */