Skip to content

Commit 1a86fc7

Browse files
committed
CSP: about:blank, data: and blob: documents inherit the policy of their creator (parent or opener)
1 parent 6c57a31 commit 1a86fc7

8 files changed

Lines changed: 581 additions & 28 deletions

File tree

‎pom.xml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
<htmlunit-cssparser.version>5.8.0-SNAPSHOT</htmlunit-cssparser.version>
4848
<htmlunit-corejs.version>5.8.0-SNAPSHOT</htmlunit-corejs.version>
4949
<htmlunit-neko.version>5.8.0-SNAPSHOT</htmlunit-neko.version>
50-
<htmlunit-websocketclient.version>5.7.0</htmlunit-websocketclient.version>
50+
<htmlunit-websocketclient.version>5.8.0-SNAPSHOT</htmlunit-websocketclient.version>
5151
<htmlunit-xpath.version>5.7.0</htmlunit-xpath.version>
5252

5353
<httpcomponents.version>4.5.14</httpcomponents.version>
@@ -64,7 +64,7 @@
6464
<!-- test dependencies -->
6565
<junit.version>6.1.3</junit.version>
6666
<junit-pioneer.version>2.3.0</junit-pioneer.version>
67-
<jetty.version>12.1.14</jetty.version>
67+
<jetty.version>12.1.15</jetty.version>
6868
<quercus.version>4.0.66</quercus.version>
6969
<logback-classic.version>1.6.6</logback-classic.version>
7070
<commons-text.version>1.15.0</commons-text.version>

‎src/changes/changes.xml‎

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

99
<body>
1010
<release version="5.8.0" date="xxxx xx, 2026" description="Chrome/Edge 157, Bugfixes">
11+
<action type="update" dev="rbri">
12+
websocket-client: jetty updated to 12.1.15.
13+
</action>
14+
<action type="add" dev="rbri">
15+
CSP: documents with a local scheme url (about:blank, data:, blob:) inherit a copy of the policy
16+
of the parent document or the opener; documents written by document.open()/write()/close()
17+
and the results of javascript: urls keep the policy of the replaced document.
18+
</action>
19+
<action type="fix" dev="rbri">
20+
CSP: eval() and new Function() are checked against the policy of the window they belong to
21+
(e.g. frame.contentWindow.eval()).
22+
</action>
23+
<action type="fix" dev="rbri">
24+
XMLHttpRequest: requests from about:blank documents use the origin of the creator (parent or opener).
25+
</action>
26+
<action type="update" dev="rbri">
27+
core-js: new hook Context.ensureCanCompileStrings() called by eval and the Function constructors.
28+
</action>
1129
<action type="add" dev="rbri">
1230
CSP: support for the frame-src directive (with fallback to child-src and default-src);
1331
frames and iframes (including redirects) are checked against the policy, blocked frames show about:blank.

‎src/main/java/org/htmlunit/html/ContentSecurityPolicy.java‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.htmlunit.ContentSecurityPolicyViolation;
3131
import org.htmlunit.ContentSecurityPolicyViolationListener;
3232
import org.htmlunit.HttpHeader;
33+
import org.htmlunit.TopLevelWindow;
3334
import org.htmlunit.WebResponse;
3435
import org.htmlunit.WebWindow;
3536
import org.htmlunit.csp.FetchDirectiveKind;
@@ -81,6 +82,8 @@ public final class ContentSecurityPolicy implements Serializable {
8182
private final List<String> reportOnly_ = new ArrayList<>();
8283
private final List<String> meta_ = new ArrayList<>();
8384
private boolean metaSinglePolicy_;
85+
// for inherited policies the url of the creator document ('self')
86+
private URL selfUrl_;
8487

8588
private transient List<Policy> enforcedPolicies_;
8689
private transient List<Policy> reportOnlyPolicies_;
@@ -126,6 +129,53 @@ else if (HEADER_REPORT_ONLY.equalsIgnoreCase(header.getName())) {
126129
return result;
127130
}
128131

132+
/**
133+
* Returns a copy of the policy of the creator for documents with a local scheme url
134+
* (about:, data:, blob:). Like the browsers, these documents inherit the policy of the
135+
* document creating them; for frames this is the parent document, for windows opened
136+
* by window.open() the opener. The copy is independent from the policy of the creator
137+
* (policies added later by meta elements are not shared) and 'self' refers to the
138+
* origin of the creator.
139+
*
140+
* @param url the url of the new document
141+
* @param window the window the new document is loaded into
142+
* @return the inherited policy or {@code null}
143+
*/
144+
public static ContentSecurityPolicy inherit(final URL url, final WebWindow window) {
145+
if (url == null || window == null) {
146+
return null;
147+
}
148+
final String protocol = url.getProtocol();
149+
if (!"about".equals(protocol) && !"data".equals(protocol) && !"blob".equals(protocol)) {
150+
return null;
151+
}
152+
153+
WebWindow creator = null;
154+
if (window instanceof FrameWindow) {
155+
creator = window.getParentWindow();
156+
}
157+
else if (window instanceof TopLevelWindow topLevelWindow) {
158+
creator = topLevelWindow.getOpener();
159+
}
160+
if (creator == null || creator == window
161+
|| !(creator.getEnclosedPage() instanceof HtmlPage creatorPage)) {
162+
return null;
163+
}
164+
165+
final ContentSecurityPolicy creatorCsp = creatorPage.getContentSecurityPolicy();
166+
if (creatorCsp == null) {
167+
return null;
168+
}
169+
170+
final ContentSecurityPolicy result = new ContentSecurityPolicy();
171+
result.enforced_.addAll(creatorCsp.enforced_);
172+
result.reportOnly_.addAll(creatorCsp.reportOnly_);
173+
result.meta_.addAll(creatorCsp.meta_);
174+
result.metaSinglePolicy_ = creatorCsp.metaSinglePolicy_;
175+
result.selfUrl_ = selfUrl(creatorPage);
176+
return result;
177+
}
178+
129179
/**
130180
* Adds a policy defined by a meta element to the given page.
131181
* The policy is parsed with the meta restrictions (frame-ancestors, sandbox and
@@ -585,7 +635,7 @@ private static String navigationBlockedUri(final HtmlPage page, final URL url) {
585635
if (host == null || host.isEmpty()) {
586636
return "";
587637
}
588-
if (UrlUtils.isSameOrigin(page.getUrl(), url)) {
638+
if (UrlUtils.isSameOrigin(selfUrl(page), url)) {
589639
return withoutRef(url);
590640
}
591641

@@ -598,7 +648,18 @@ private static String navigationBlockedUri(final HtmlPage page, final URL url) {
598648
}
599649

600650
private static Optional<URI> origin(final HtmlPage page) {
601-
return URI.parseURI(page.getUrl().toExternalForm());
651+
return URI.parseURI(selfUrl(page).toExternalForm());
652+
}
653+
654+
/**
655+
* Returns the url used for 'self'; for inherited policies the url of the creator.
656+
*/
657+
private static URL selfUrl(final HtmlPage page) {
658+
final ContentSecurityPolicy csp = page.getContentSecurityPolicy();
659+
if (csp != null && csp.selfUrl_ != null) {
660+
return csp.selfUrl_;
661+
}
662+
return page.getUrl();
602663
}
603664

604665
/**

‎src/main/java/org/htmlunit/html/HtmlPage.java‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
import org.htmlunit.Page;
5858
import org.htmlunit.ScriptResult;
5959
import org.htmlunit.SgmlPage;
60+
import org.htmlunit.StringWebResponse;
6061
import org.htmlunit.TopLevelWindow;
6162
import org.htmlunit.WebAssert;
6263
import org.htmlunit.WebClient;
@@ -232,6 +233,20 @@ public int compare(final DomElement elt1, final DomElement elt2) {
232233
public HtmlPage(final WebResponse webResponse, final WebWindow webWindow) {
233234
super(webResponse, webWindow);
234235
contentSecurityPolicy_ = ContentSecurityPolicy.fromResponse(webResponse);
236+
if (contentSecurityPolicy_ == null && webResponse != null) {
237+
// the content written by document.open()/write()/close() and the result of a
238+
// javascript: url replace the current document; the policy is kept
239+
if (webResponse instanceof StringWebResponse stringWebResponse
240+
&& stringWebResponse.isFromJavascript()
241+
&& webWindow != null
242+
&& webWindow.getEnclosedPage() instanceof HtmlPage currentPage) {
243+
contentSecurityPolicy_ = currentPage.getContentSecurityPolicy();
244+
}
245+
else {
246+
contentSecurityPolicy_ = ContentSecurityPolicy.inherit(webResponse.getWebRequest().getUrl(),
247+
webWindow);
248+
}
249+
}
235250
}
236251

237252
/**
@@ -253,6 +268,35 @@ public ContentSecurityPolicy getContentSecurityPolicy() {
253268
return contentSecurityPolicy_;
254269
}
255270

271+
/**
272+
* <span style="color:red">INTERNAL API - SUBJECT TO CHANGE AT ANY TIME - USE AT YOUR OWN RISK.</span><br>
273+
*
274+
* Returns the url used to determine the origin of this page. Like in the browsers, an about:blank
275+
* document has the origin of its creator; for frames this is the parent document, for windows
276+
* opened by window.open() the opener. For all other pages this is the url of the page.
277+
*
278+
* @return the url of the origin
279+
*/
280+
public URL getOriginUrl() {
281+
final URL url = getUrl();
282+
if (!UrlUtils.ABOUT_BLANK.equals(url.toExternalForm())) {
283+
return url;
284+
}
285+
286+
final WebWindow window = getEnclosingWindow();
287+
WebWindow creator = null;
288+
if (window instanceof FrameWindow) {
289+
creator = window.getParentWindow();
290+
}
291+
else if (window instanceof TopLevelWindow topLevelWindow) {
292+
creator = topLevelWindow.getOpener();
293+
}
294+
if (creator != null && creator != window && creator.getEnclosedPage() instanceof HtmlPage creatorPage) {
295+
return creatorPage.getOriginUrl();
296+
}
297+
return url;
298+
}
299+
256300
/**
257301
* <span style="color:red">INTERNAL API - SUBJECT TO CHANGE AT ANY TIME - USE AT YOUR OWN RISK.</span><br>
258302
*

‎src/main/java/org/htmlunit/javascript/HtmlUnitContextFactory.java‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@
3636
import org.htmlunit.corejs.javascript.Script;
3737
import org.htmlunit.corejs.javascript.ScriptRuntime;
3838
import org.htmlunit.corejs.javascript.ScriptStackElement;
39+
import org.htmlunit.corejs.javascript.ScriptableObject;
3940
import org.htmlunit.corejs.javascript.VarScope;
4041
import org.htmlunit.corejs.javascript.debug.Debugger;
4142
import org.htmlunit.html.ContentSecurityPolicy;
4243
import org.htmlunit.html.HtmlElement;
4344
import org.htmlunit.html.HtmlPage;
45+
import org.htmlunit.javascript.host.Window;
4446

4547
/**
4648
* ContextFactory that supports termination of scripts if they exceed a timeout. Based on example from
@@ -166,10 +168,6 @@ protected Script compileString(String source, final Evaluator compiler,
166168
// which is used for window.eval. We have to take care in which case we are.
167169
final boolean isWindowEval = compiler != null;
168170

169-
if (isWindowEval) {
170-
ensureEvalAllowed(source);
171-
}
172-
173171
// Remove HTML comments around the source if needed
174172
if (!isWindowEval) {
175173

@@ -203,16 +201,20 @@ protected Script compileString(String source, final Evaluator compiler,
203201
sourceName, lineno, securityDomain, compilerEnvironsProcessor);
204202
}
205203

204+
/**
205+
* Like the browsers, checks the Content-Security-Policy of the realm (page) the
206+
* string is compiled for (eval, new Function()).
207+
*/
208+
@Override
209+
protected void ensureCanCompileStrings(final VarScope scope, final String source) {
210+
ensureEvalAllowed(source, scope);
211+
}
212+
206213
@Override
207214
protected Function compileFunction(final VarScope scope, String source,
208215
final Evaluator compiler, final ErrorReporter compilationErrorReporter,
209216
final String sourceName, final int lineno, final Object securityDomain) {
210217

211-
// the compiler is only given for new Function(...)
212-
if (compiler != null) {
213-
ensureEvalAllowed(source);
214-
}
215-
216218
if (deminifyFunctionCode_) {
217219
final Function f = super.compileFunction(scope, source, compiler,
218220
compilationErrorReporter, sourceName, lineno, securityDomain);
@@ -224,18 +226,29 @@ protected Function compileFunction(final VarScope scope, String source,
224226
}
225227

226228
/**
227-
* Throws an EvalError if the Content-Security-Policy of the page of the
228-
* running script does not allow the evaluation of strings as code.
229+
* Throws an EvalError if the Content-Security-Policy of the page
230+
* does not allow the evaluation of strings as code.
231+
* Like the browsers, the policy of the realm of the called function is used (e.g. for
232+
* {@code frame.contentWindow.eval(...)} or {@code new frame.contentWindow.Function(...)} the
233+
* policy of the frame); if the realm has no page the page of the running script is used.
229234
*
230235
* @param source the source to evaluate
236+
* @param scope the scope of the realm
231237
*/
232-
private static void ensureEvalAllowed(final String source) {
238+
private static void ensureEvalAllowed(final String source, final VarScope scope) {
233239
final Context cx = Context.getCurrentContext();
234240
if (cx == null) {
235241
return;
236242
}
237243

238-
Object page = cx.getThreadLocal(JavaScriptEngine.KEY_RUNNING_PAGE);
244+
Object page = null;
245+
if (scope != null
246+
&& ScriptableObject.getTopLevelScope(scope).getGlobalThis() instanceof Window window) {
247+
page = window.getWebWindow().getEnclosedPage();
248+
}
249+
if (!(page instanceof HtmlPage)) {
250+
page = cx.getThreadLocal(JavaScriptEngine.KEY_RUNNING_PAGE);
251+
}
239252
if (!(page instanceof HtmlPage)) {
240253
page = cx.getThreadLocal(JavaScriptEngine.KEY_STARTING_PAGE);
241254
}

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,8 @@ public void open(final String method, final Object urlParam, final Object asyncP
730730

731731
try {
732732
final URL pageUrl = containingPage.getUrl();
733+
// about:blank documents have the origin of their creator
734+
final URL originUrl = containingPage.getOriginUrl();
733735
final URL fullUrl = containingPage.getFullyQualifiedUrl(url);
734736
final WebRequest request = new WebRequest(fullUrl, getBrowserVersion().getXmlHttpRequestAcceptHeader(),
735737
getBrowserVersion().getAcceptEncodingHeader());
@@ -740,7 +742,7 @@ public void open(final String method, final Object urlParam, final Object asyncP
740742

741743
request.setFetchDestination(WebRequest.FetchDestination.EMPTY);
742744
request.setFetchModeOverride(FetchMode.CORS);
743-
request.setRequestingUrl(pageUrl);
745+
request.setRequestingUrl(originUrl);
744746

745747
try {
746748
HttpMethod.validateHttpMethodName(method);
@@ -777,22 +779,22 @@ else if ("blob".equals(fullUrl.getProtocol())) {
777779
boolean sameOrigin = false;
778780
try {
779781
final URL blobOrigin = UrlUtils.toUrlUnsafe(fullUrl.toExternalForm().substring("blob:".length()));
780-
sameOrigin = UrlUtils.isSameOrigin(pageUrl, blobOrigin);
782+
sameOrigin = UrlUtils.isSameOrigin(originUrl, blobOrigin);
781783
}
782784
catch (final MalformedURLException ignored) {
783785
// keep sameOrigin = false
784786
}
785787
isSameOrigin_ = sameOrigin;
786788
}
787789
else {
788-
isSameOrigin_ = UrlUtils.isSameOrigin(pageUrl, fullUrl);
790+
isSameOrigin_ = UrlUtils.isSameOrigin(originUrl, fullUrl);
789791
final boolean alwaysAddOrigin = HttpMethod.GET != request.getHttpMethod()
790792
&& HttpMethod.HEAD != request.getHttpMethod();
791793
if (alwaysAddOrigin || !isSameOrigin_) {
792-
final StringBuilder origin = new StringBuilder().append(pageUrl.getProtocol()).append("://")
793-
.append(pageUrl.getHost());
794-
if (pageUrl.getPort() != -1) {
795-
origin.append(':').append(pageUrl.getPort());
794+
final StringBuilder origin = new StringBuilder().append(originUrl.getProtocol()).append("://")
795+
.append(originUrl.getHost());
796+
if (originUrl.getPort() != -1) {
797+
origin.append(':').append(originUrl.getPort());
796798
}
797799
request.setAdditionalHeader(HttpHeader.ORIGIN, origin.toString());
798800
}

‎src/site/xdoc/csp.xml‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,34 @@
175175
</subsection>
176176
</section>
177177

178+
<section name="Policy Inheritance" id="sec_inheritance">
179+
<p>
180+
Like the browsers, documents with a local scheme url (<code>about:blank</code>, <code>data:</code>,
181+
<code>blob:</code>) inherit a copy of the policy of the document creating them:
182+
</p>
183+
<ul>
184+
<li>frames and iframes inherit the policy of the parent document</li>
185+
<li>windows opened by <code>window.open()</code> inherit the policy of the opener</li>
186+
<li>
187+
<code>'self'</code> refers to the origin of the creator; policies added later
188+
(e.g. by meta elements) are not shared between the documents
189+
</li>
190+
<li>
191+
like the browsers, <code>eval()</code> and <code>new Function()</code> use the policy of the
192+
window they belong to (e.g. <code>frame.contentWindow.eval()</code> is checked against the
193+
policy of the frame)
194+
</li>
195+
<li>
196+
the content written by <code>document.open()</code>/<code>write()</code>/<code>close()</code>
197+
and the result of a <code>javascript:</code> url keep the policy of the replaced document
198+
</li>
199+
</ul>
200+
<p>
201+
Documents loaded from http(s) urls never inherit the policy; they only use the policies
202+
delivered with their own response.
203+
</p>
204+
</section>
205+
178206
<section name="Blocked Actions" id="sec_blocked">
179207
<p>
180208
A blocked action behaves like in the browsers:
@@ -290,10 +318,7 @@ webClient.setContentSecurityPolicyViolationListener((page, violation) -> {
290318
Known limitations:
291319
</p>
292320
<ul>
293-
<li>
294-
<code>about:blank</code> and <code>srcdoc</code> documents do not inherit the policy
295-
of the document creating them.
296-
</li>
321+
<li>HtmlUnit does not support <code>srcdoc</code> iframes.</li>
297322
<li>
298323
<code>javascript:</code> urls used as <code>src</code> of a frame or with
299324
<code>window.open()</code> are not checked.

0 commit comments

Comments
 (0)