Skip to content

Commit 9948cd5

Browse files
committed
improved headers for xhr preflight requests
1 parent 4acf3e4 commit 9948cd5

5 files changed

Lines changed: 202 additions & 5 deletions

File tree

src/changes/changes.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
<body>
1010
<release version="5.4.0" date="August xx, 2026" description="Firefox 153, Bugfixes">
11+
<action type="update" dev="rbri">
12+
Improved headers for xhr preflight requests.
13+
</action>
1114
<action type="update" dev="rbri">
1215
Method innerText() for selects generates now the same output as the real browsers.
1316
</action>

src/main/java/org/htmlunit/BrowserVersionFeatures.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,10 @@ public enum BrowserVersionFeatures {
404404
@BrowserFeature({FF, FF_ESR})
405405
XHR_LOAD_ALWAYS_AFTER_DONE,
406406

407+
/** Indicates if the XMLHttpRequest preflight request includes the 'Sec-Fetch-Mode=cors' header. */
408+
@BrowserFeature({CHROME, EDGE})
409+
XHR_PREFLIGHT_CORS,
410+
407411
/** If state unsent the response text is empty even if the response type is wrong. */
408412
@BrowserFeature({FF, FF_ESR})
409413
XHR_RESPONSE_TEXT_EMPTY_UNSENT,

src/main/java/org/htmlunit/HttpWebConnection.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1116,7 +1116,8 @@ else if (HttpHeader.UPGRADE_INSECURE_REQUESTS.equals(header)) {
11161116

11171117
// Real browsers only send this for navigations (top-level, iframe, frame),
11181118
// never for subresource fetches (image/script/style/xhr/...).
1119-
else if (WebRequest.FetchMode.NAVIGATE.getValue().equals(secFetchMode)) {
1119+
else if (WebRequest.FetchMode.NAVIGATE.getValue().equals(secFetchMode)
1120+
&& HttpMethod.OPTIONS != webRequest.getHttpMethod()) {
11201121
list.add(new UpgradeInsecureRequestHeaderHttpRequestInterceptor("1"));
11211122
}
11221123
}

src/main/java/org/htmlunit/javascript/host/xml/XMLHttpRequest.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import static java.nio.charset.StandardCharsets.UTF_8;
1818
import static org.htmlunit.BrowserVersionFeatures.XHR_HANDLE_SYNC_NETWORK_ERRORS;
1919
import static org.htmlunit.BrowserVersionFeatures.XHR_LOAD_ALWAYS_AFTER_DONE;
20+
import static org.htmlunit.BrowserVersionFeatures.XHR_PREFLIGHT_CORS;
2021
import static org.htmlunit.BrowserVersionFeatures.XHR_RESPONSE_TEXT_EMPTY_UNSENT;
2122
import static org.htmlunit.BrowserVersionFeatures.XHR_SEND_NETWORK_ERROR_IF_ABORTED;
2223

@@ -942,7 +943,9 @@ else if (content instanceof Blob blob) {
942943
* Performs the actual send operation.
943944
*/
944945
void doSend() {
945-
final WebClient wc = getWindow().getWebWindow().getWebClient();
946+
final Window window = getWindow();
947+
final WebWindow webWindow = window.getWebWindow();
948+
final WebClient wc = webWindow.getWebClient();
946949

947950
// accessing to local resource is forbidden for security reason
948951
if (!wc.getOptions().isFileProtocolForXMLHttpRequestsAllowed()
@@ -960,7 +963,7 @@ void doSend() {
960963
}
961964
else {
962965
throw JavaScriptEngine.asJavaScriptException(
963-
getWindow(),
966+
window,
964967
"Not allowed to load local resource: " + webRequest_.getUrl(),
965968
DOMException.NETWORK_ERR);
966969
}
@@ -970,6 +973,7 @@ void doSend() {
970973
try {
971974
if (!isSameOrigin_ && isPreflight()) {
972975
final WebRequest preflightRequest = new WebRequest(webRequest_.getUrl(), HttpMethod.OPTIONS);
976+
preflightRequest.setEncodingType(null);
973977

974978
// preflight request shouldn't have cookies
975979
preflightRequest.addHint(HttpHint.BlockCookies);
@@ -1001,6 +1005,14 @@ void doSend() {
10011005
preflightRequest.setTimeout(timeout_);
10021006
}
10031007

1008+
if (getBrowserVersion().hasFeature(XHR_PREFLIGHT_CORS)) {
1009+
// preflightRequest.setFetchModeOverride(FetchMode.CORS);
1010+
preflightRequest.setAdditionalHeader(HttpHeader.SEC_FETCH_MODE, "cors");
1011+
}
1012+
1013+
final HtmlPage containingPage = (HtmlPage) webWindow.getEnclosedPage();
1014+
preflightRequest.setRefererHeader(containingPage.getUrl());
1015+
10041016
// do the preflight request
10051017
final WebResponse preflightResponse = wc.loadWebResponse(preflightRequest);
10061018
if (!preflightResponse.isSuccessOrUseProxyOrNotModified()
@@ -1016,7 +1028,7 @@ void doSend() {
10161028
LOG.debug("No permitted request for URL " + webRequest_.getUrl());
10171029
}
10181030
throw JavaScriptEngine.asJavaScriptException(
1019-
getWindow(),
1031+
window,
10201032
"No permitted \"Access-Control-Allow-Origin\" header.",
10211033
DOMException.NETWORK_ERR);
10221034
}
@@ -1139,7 +1151,7 @@ public Charset getContentCharset() {
11391151
fireJavascriptEvent(Event.TYPE_LOAD_END);
11401152
}
11411153

1142-
throw JavaScriptEngine.asJavaScriptException(getWindow(),
1154+
throw JavaScriptEngine.asJavaScriptException(window,
11431155
e.getMessage(), DOMException.NETWORK_ERR);
11441156
}
11451157
}

src/test/java/org/htmlunit/HttpWebConnection3Test.java

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3196,6 +3196,183 @@ public void xmlHttpRequestGet() throws Exception {
31963196
}
31973197
}
31983198

3199+
/**
3200+
* Tests the CORS preflight (OPTIONS) request triggered by a cross-origin XHR using a
3201+
* non-simple method (PUT) and a custom header. This is a structurally distinct request
3202+
* category from anything else in this file - no payload, Access-Control-Request-Method/
3203+
* Access-Control-Request-Headers instead of a body - and it's not yet known whether
3204+
* Sec-Fetch-* headers even apply to the preflight itself as opposed to only the actual
3205+
* follow-up request.
3206+
* <p>
3207+
* NOTE: needs two concurrent {@link PrimitiveWebServer} instances (one per origin), same
3208+
* as {@link #redirectToCrossSiteHost()} - unverified whether the constructor used here
3209+
* actually supports that; double check before relying on it.
3210+
*
3211+
* @throws Exception if the test fails
3212+
*/
3213+
@Test
3214+
@Alerts(CHROME = {"OPTIONS /target HTTP/1.1",
3215+
"Host: host1.htmlunit-dev.org:§§PORT§§",
3216+
"Connection: keep-alive",
3217+
"Accept: */*",
3218+
"Access-Control-Request-Method: PUT",
3219+
"Access-Control-Request-Headers: x-custom",
3220+
"Origin: http://localhost:22225",
3221+
"User-Agent: §§USER_AGENT§§",
3222+
"Sec-Fetch-Mode: cors",
3223+
"Referer: http://localhost:22225/",
3224+
"Accept-Encoding: gzip, deflate",
3225+
"Accept-Language: en-US,en;q=0.9"},
3226+
EDGE = {"OPTIONS /target HTTP/1.1",
3227+
"Host: host1.htmlunit-dev.org:§§PORT§§",
3228+
"Connection: keep-alive",
3229+
"Accept: */*",
3230+
"Access-Control-Request-Method: PUT",
3231+
"Access-Control-Request-Headers: x-custom",
3232+
"Origin: http://localhost:22225",
3233+
"User-Agent: §§USER_AGENT§§",
3234+
"Sec-Fetch-Mode: cors",
3235+
"Referer: http://localhost:22225/",
3236+
"Accept-Encoding: gzip, deflate",
3237+
"Accept-Language: en-US,en;q=0.9"},
3238+
FF = {"OPTIONS /target HTTP/1.1",
3239+
"Host: host1.htmlunit-dev.org:§§PORT§§",
3240+
"User-Agent: §§USER_AGENT§§",
3241+
"Accept: */*",
3242+
"Accept-Language: en-US,en;q=0.9",
3243+
"Accept-Encoding: gzip, deflate",
3244+
"Access-Control-Request-Method: PUT",
3245+
"Access-Control-Request-Headers: x-custom",
3246+
"Referer: http://localhost:22225/",
3247+
"Origin: http://localhost:22225",
3248+
"Connection: keep-alive",
3249+
"Priority: u=4"},
3250+
FF_ESR = {"OPTIONS /target HTTP/1.1",
3251+
"Host: host1.htmlunit-dev.org:§§PORT§§",
3252+
"User-Agent: §§USER_AGENT§§",
3253+
"Accept: */*",
3254+
"Accept-Language: en-US,en;q=0.5",
3255+
"Accept-Encoding: gzip, deflate",
3256+
"Access-Control-Request-Method: PUT",
3257+
"Access-Control-Request-Headers: x-custom",
3258+
"Referer: http://localhost:22225/",
3259+
"Origin: http://localhost:22225",
3260+
"Connection: keep-alive",
3261+
"Priority: u=4"})
3262+
@HtmlUnitNYI(
3263+
CHROME = {"OPTIONS /target HTTP/1.1",
3264+
"Host: host1.htmlunit-dev.org:§§PORT§§",
3265+
"Connection: keep-alive",
3266+
"User-Agent: §§USER_AGENT§§",
3267+
"Accept: */*",
3268+
"Sec-Fetch-Mode: cors",
3269+
"Referer: http://localhost:22225/",
3270+
"Accept-Encoding: gzip, deflate",
3271+
"Accept-Language: en-US,en;q=0.9",
3272+
"Origin: http://localhost:22225",
3273+
"Access-Control-Request-Method: PUT",
3274+
"Access-Control-Request-Headers: x-custom",
3275+
"Content-Length: 0"}, // wrong
3276+
EDGE = {"OPTIONS /target HTTP/1.1",
3277+
"Host: host1.htmlunit-dev.org:§§PORT§§",
3278+
"Connection: keep-alive",
3279+
"User-Agent: §§USER_AGENT§§",
3280+
"Accept: */*",
3281+
"Sec-Fetch-Mode: cors",
3282+
"Referer: http://localhost:22225/",
3283+
"Accept-Encoding: gzip, deflate",
3284+
"Accept-Language: en-US,en;q=0.9",
3285+
"Origin: http://localhost:22225",
3286+
"Access-Control-Request-Method: PUT",
3287+
"Access-Control-Request-Headers: x-custom",
3288+
"Content-Length: 0"}, // wrong
3289+
FF = {"OPTIONS /target HTTP/1.1",
3290+
"Host: host1.htmlunit-dev.org:§§PORT§§",
3291+
"User-Agent: §§USER_AGENT§§",
3292+
"Accept: */*",
3293+
"Accept-Language: en-US,en;q=0.9",
3294+
"Accept-Encoding: gzip, deflate",
3295+
"Connection: keep-alive",
3296+
"Referer: http://localhost:22225/",
3297+
"Priority: u=0, i",
3298+
"Origin: http://localhost:22225",
3299+
"Access-Control-Request-Method: PUT",
3300+
"Access-Control-Request-Headers: x-custom",
3301+
"Content-Length: 0"}, // wrong
3302+
FF_ESR = {"OPTIONS /target HTTP/1.1",
3303+
"Host: host1.htmlunit-dev.org:§§PORT§§",
3304+
"User-Agent: §§USER_AGENT§§",
3305+
"Accept: */*",
3306+
"Accept-Language: en-US,en;q=0.5",
3307+
"Accept-Encoding: gzip, deflate",
3308+
"Connection: keep-alive",
3309+
"Referer: http://localhost:22225/",
3310+
"Priority: u=0, i",
3311+
"Origin: http://localhost:22225",
3312+
"Access-Control-Request-Method: PUT",
3313+
"Access-Control-Request-Headers: x-custom",
3314+
"Content-Length: 0"}) // wrong
3315+
public void xmlHttpRequestPreflight() throws Exception {
3316+
final String preflightResponse = "HTTP/1.1 200 OK\r\n"
3317+
+ "Content-Length: 0\r\n"
3318+
+ "Access-Control-Allow-Origin: *\r\n"
3319+
+ "Access-Control-Allow-Methods: PUT\r\n"
3320+
+ "Access-Control-Allow-Headers: X-Custom\r\n"
3321+
+ "Connection: close\r\n"
3322+
+ "\r\n";
3323+
final String actualResponse = "HTTP/1.1 200 OK\r\n"
3324+
+ "Content-Length: 2\r\n"
3325+
+ "Content-Type: text/plain\r\n"
3326+
+ "Access-Control-Allow-Origin: *\r\n"
3327+
+ "Connection: close\r\n"
3328+
+ "\r\n"
3329+
+ "Hi";
3330+
3331+
shutDownAll();
3332+
try (PrimitiveWebServer crossServer = new PrimitiveWebServer(PORT_PROXY_SERVER,
3333+
null, preflightResponse, actualResponse, actualResponse)) {
3334+
final String html = DOCTYPE_HTML
3335+
+ "<html><head><script>\n"
3336+
+ " function doXhr() {\n"
3337+
+ " var x = new XMLHttpRequest();\n"
3338+
+ " x.open('PUT', 'http://host1.htmlunit-dev.org:" + crossServer.getPort() + "/target', true);\n"
3339+
+ " x.setRequestHeader('X-Custom', 'value');\n"
3340+
+ " x.send('body');\n"
3341+
+ " }\n"
3342+
+ "</script></head>\n"
3343+
+ "<body onload='doXhr()'></body></html>";
3344+
final String htmlResponse = "HTTP/1.1 200 OK\r\n"
3345+
+ "Content-Length: " + html.length() + "\r\n"
3346+
+ "Content-Type: text/html\r\n"
3347+
+ "Connection: close\r\n"
3348+
+ "\r\n"
3349+
+ html;
3350+
3351+
try (PrimitiveWebServer originServer = new PrimitiveWebServer(null, htmlResponse, htmlResponse)) {
3352+
final WebDriver driver = getWebDriver();
3353+
3354+
driver.get("http://localhost:" + originServer.getPort());
3355+
3356+
final long endTime = System.currentTimeMillis() + Duration.ofSeconds(4).toMillis();
3357+
while (crossServer.getRequests().size() < 2
3358+
&& System.currentTimeMillis() < endTime) {
3359+
Thread.sleep(100);
3360+
}
3361+
3362+
if (crossServer.getRequests().size() < 2) {
3363+
Assertions.fail("Still no request / request count:" + crossServer.getRequests().size());
3364+
}
3365+
3366+
final String[] expectedHeaders = getExpectedAlertsWithHtmlReplacement(crossServer);
3367+
3368+
// request 0 = the OPTIONS preflight
3369+
final String request = crossServer.getRequests().get(0);
3370+
final String[] headers = request.split("\\r\\n");
3371+
assertEquals(Arrays.asList(expectedHeaders).toString(), Arrays.asList(headers).toString());
3372+
}
3373+
}
3374+
}
3375+
31993376
/**
32003377
* Tests a form submitted purely by script ({@code form.submit()}), as opposed to via
32013378
* a click on a submit control. Real browsers do not consider this a user-activated

0 commit comments

Comments
 (0)