From 784b87f7f1a595a8944fda35d3dc4d233c49c29a Mon Sep 17 00:00:00 2001 From: duonglaiquang Date: Thu, 9 Jul 2026 16:23:22 +0900 Subject: [PATCH 1/2] XMLHttpRequest: check if blob url is same origin or not --- .../javascript/host/xml/XMLHttpRequest.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 3c48ca57b6..ee2115b08d 100644 --- a/src/main/java/org/htmlunit/javascript/host/xml/XMLHttpRequest.java +++ b/src/main/java/org/htmlunit/javascript/host/xml/XMLHttpRequest.java @@ -720,10 +720,20 @@ public void open(final String method, final Object urlParam, final Object asyncP return; } - final boolean isDataUrl = "data".equals(fullUrl.getProtocol()); - if (isDataUrl) { + if ("data".equals(fullUrl.getProtocol())) { isSameOrigin_ = true; } + else if ("blob".equals(fullUrl.getProtocol())) { + boolean sameOrigin = false; + try { + final URL blobOrigin = UrlUtils.toUrlUnsafe(fullUrl.toExternalForm().substring("blob:".length())); + sameOrigin = UrlUtils.isSameOrigin(pageUrl, blobOrigin); + } + catch (final MalformedURLException ignored) { + // keep sameOrigin = false + } + isSameOrigin_ = sameOrigin; + } else { isSameOrigin_ = UrlUtils.isSameOrigin(pageUrl, fullUrl); final boolean alwaysAddOrigin = HttpMethod.GET != request.getHttpMethod() From 5339c6e8d09aff910614eb7cddb5d5c08bacd083 Mon Sep 17 00:00:00 2001 From: duonglaiquang Date: Thu, 9 Jul 2026 16:29:41 +0900 Subject: [PATCH 2/2] URL: reimplement URL.createObjectURL() --- src/main/java/org/htmlunit/BlobUrlStore.java | 91 ++++++++++++++ src/main/java/org/htmlunit/WebClient.java | 23 +++- .../org/htmlunit/javascript/host/URL.java | 42 +++++-- .../javascript/host/dom/Document.java | 40 ------ .../htmlunit/javascript/host/URL2Test.java | 115 ++++++++++++++++++ .../org/htmlunit/javascript/host/URLTest.java | 21 +++- 6 files changed, 276 insertions(+), 56 deletions(-) create mode 100644 src/main/java/org/htmlunit/BlobUrlStore.java create mode 100644 src/test/java/org/htmlunit/javascript/host/URL2Test.java diff --git a/src/main/java/org/htmlunit/BlobUrlStore.java b/src/main/java/org/htmlunit/BlobUrlStore.java new file mode 100644 index 0000000000..2c73bad464 --- /dev/null +++ b/src/main/java/org/htmlunit/BlobUrlStore.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.htmlunit.javascript.host.file.Blob; + +/** + * The user-agent-wide blob URL store defined by + * Blob URL Store. + * + * @author Lai Quang Duong + */ +public class BlobUrlStore { + + private static final class Entry { + private final Blob object_; + private final Page owningPage_; + + Entry(final Blob object, final Page owningPage) { + object_ = object; + owningPage_ = owningPage; + } + } + + private final Map store_ = new ConcurrentHashMap<>(); + + /** + * Adds an entry. + * @param blobUrl the generated {@code blob:} URL + * @param object the {@link Blob} (or {@code File}) the URL refers to + * @param owningPage the page that created the URL + */ + public void put(final String blobUrl, final Blob object, final Page owningPage) { + store_.put(blobUrl, new Entry(object, owningPage)); + } + + /** + * Resolve a blob URL. + * @param blobUrl the {@code blob:} URL to resolve + * @return the stored {@link Blob}, or {@code null} if there is no entry + */ + public Blob resolve(final String blobUrl) { + final Entry entry = store_.get(excludeFragment(blobUrl)); + return entry == null ? null : entry.object_; + } + + /** + * Removes an entry. + * @param blobUrl the {@code blob:} URL to remove + */ + public void remove(final String blobUrl) { + store_.remove(excludeFragment(blobUrl)); + } + + /** + * See Lifetime of blob URLs. + * @param owningPage the unloading page + */ + void removeForPage(final Page owningPage) { + store_.values().removeIf(entry -> entry.owningPage_ == owningPage); + } + + /** + * INTERNAL API - SUBJECT TO CHANGE AT ANY TIME - USE AT YOUR OWN RISK.
+ * + *

Removes all entries. + */ + public void clear() { + store_.clear(); + } + + private static String excludeFragment(final String blobUrl) { + final int fragmentIndex = blobUrl.indexOf('#'); + return fragmentIndex >= 0 ? blobUrl.substring(0, fragmentIndex) : blobUrl; + } +} diff --git a/src/main/java/org/htmlunit/WebClient.java b/src/main/java/org/htmlunit/WebClient.java index 06b01feec6..b3029871d1 100644 --- a/src/main/java/org/htmlunit/WebClient.java +++ b/src/main/java/org/htmlunit/WebClient.java @@ -21,6 +21,7 @@ import java.io.BufferedInputStream; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; @@ -233,6 +234,8 @@ public class WebClient implements Serializable, AutoCloseable { Collections.synchronizedList(new ArrayList<>()); private WebWindow currentWindow_; + private transient BlobUrlStore blobUrlStore_ = new BlobUrlStore(); + private HTMLParserListener htmlParserListener_; private CSSErrorHandler cssErrorHandler_ = new DefaultCssErrorHandler(); private OnbeforeunloadHandler onbeforeunloadHandler_; @@ -1019,6 +1022,14 @@ public void setCurrentWindow(final WebWindow window) { } } + /** + * Returns the blob URL store for this client. + * @return the {@link BlobUrlStore} for this client + */ + public BlobUrlStore getBlobUrlStore() { + return blobUrlStore_; + } + /** * Adds a listener for {@link WebWindowEvent}s. All events from all windows associated with this * client will be sent to the specified listener. @@ -1045,6 +1056,8 @@ private void fireWindowContentChanged(final WebWindowEvent event) { for (final WebWindowListener listener : new ArrayList<>(webWindowListeners_)) { listener.webWindowContentChanged(event); } + + blobUrlStore_.removeForPage(event.getOldPage()); } private void fireWindowOpened(final WebWindowEvent event) { @@ -1065,6 +1078,8 @@ private void fireWindowClosed(final WebWindowEvent event) { listener.webWindowClosed(event); } + blobUrlStore_.removeForPage(event.getOldPage()); + // to open a new top level window if all others are gone if (currentWindowTracker_ != null) { currentWindowTracker_.afterWebWindowClosedListenersProcessed(event); @@ -1450,11 +1465,10 @@ private WebResponse makeWebResponseForFileUrl(final WebRequest webRequest) throw return webResponse; } - private WebResponse makeWebResponseForBlobUrl(final WebRequest webRequest) { - final Window window = getCurrentWindow().getScriptableObject(); - final Blob fileOrBlob = window.getDocument().resolveBlobUrl(webRequest.getUrl().toString()); + private WebResponse makeWebResponseForBlobUrl(final WebRequest webRequest) throws IOException { + final Blob fileOrBlob = blobUrlStore_.resolve(webRequest.getUrl().toString()); if (fileOrBlob == null) { - throw JavaScriptEngine.typeError("Cannot load data from " + webRequest.getUrl()); + throw new FileNotFoundException("No entry for '" + webRequest.getUrl() + "' in the BlobUrlStore."); } final List headers = new ArrayList<>(); @@ -2671,6 +2685,7 @@ private void readObject(final ObjectInputStream in) throws IOException, ClassNot loadQueue_ = new ArrayList<>(); css3ParserPool_ = new CSS3ParserPool(); broadcastChannel_ = new HashSet<>(); + blobUrlStore_ = new BlobUrlStore(); } private static class LoadJob { diff --git a/src/main/java/org/htmlunit/javascript/host/URL.java b/src/main/java/org/htmlunit/javascript/host/URL.java index 5aac4f744d..a75154096f 100644 --- a/src/main/java/org/htmlunit/javascript/host/URL.java +++ b/src/main/java/org/htmlunit/javascript/host/URL.java @@ -18,8 +18,11 @@ import java.net.MalformedURLException; import java.util.List; +import java.util.UUID; import org.apache.commons.lang3.StringUtils; +import org.htmlunit.Page; +import org.htmlunit.WebWindow; import org.htmlunit.corejs.javascript.Context; import org.htmlunit.corejs.javascript.Scriptable; import org.htmlunit.javascript.HtmlUnitScriptable; @@ -83,32 +86,51 @@ public void jsConstructor(final String url, final Object base) { /** * The URL.createObjectURL() static method creates a DOMString containing a URL * representing the object given in parameter. - * The URL lifetime is tied to the document in the window on which it was created. - * The new object URL represents the specified File object or Blob object. + * The new object URL represents the specified {@link File} object or {@link Blob} object + * and is registered in the {@link org.htmlunit.BlobUrlStore user-agent-wide blob URL store}, + * so it can be resolved from any document of the same client. * * @param fileOrBlob Is a File object or a Blob object to create a object URL for. * @return the url */ @JsxStaticFunction public static String createObjectURL(final Object fileOrBlob) { - if (fileOrBlob instanceof File file) { - return getWindow(file).getDocument().generateBlobUrl(file); + if (!(fileOrBlob instanceof Blob blob)) { + throw JavaScriptEngine.typeError("URL.createObjectURL: argument 1 is not a Blob."); } - if (fileOrBlob instanceof Blob blob) { - return getWindow(blob).getDocument().generateBlobUrl(blob); + final WebWindow webWindow = getWindow(blob).getWebWindow(); + final Page page = webWindow.getEnclosedPage(); + final java.net.URL pageUrl = page.getUrl(); + + String origin = "null"; + if (pageUrl != UrlUtils.URL_ABOUT_BLANK) { + final int port = pageUrl.getPort(); + if (port < 0 || port == pageUrl.getDefaultPort()) { + origin = pageUrl.getProtocol() + "://" + pageUrl.getHost(); + } + else { + origin = pageUrl.getProtocol() + "://" + pageUrl.getHost() + ':' + port; + } } - return null; + final String blobUrl = "blob:" + origin + "/" + UUID.randomUUID(); + webWindow.getWebClient().getBlobUrlStore().put(blobUrl, blob, page); + return blobUrl; } /** - * @param objectURL String representing the object URL that was - * created by calling URL.createObjectURL(). + * Revokes a {@code blob:} URL, removing its entry from the blob URL store. + * + * @param objectURL the object URL previously returned by {@link #createObjectURL(Object)} */ @JsxStaticFunction public static void revokeObjectURL(final Scriptable objectURL) { - getWindow(objectURL).getDocument().revokeBlobUrl(Context.toString(objectURL)); + final String url = Context.toString(objectURL); + if (!url.startsWith("blob:")) { + return; + } + getWindow(objectURL).getWebWindow().getWebClient().getBlobUrlStore().remove(url); } /** diff --git a/src/main/java/org/htmlunit/javascript/host/dom/Document.java b/src/main/java/org/htmlunit/javascript/host/dom/Document.java index 13d070db93..e290de2b34 100644 --- a/src/main/java/org/htmlunit/javascript/host/dom/Document.java +++ b/src/main/java/org/htmlunit/javascript/host/dom/Document.java @@ -30,13 +30,11 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Date; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.UUID; import java.util.function.Predicate; import org.apache.commons.logging.Log; @@ -118,7 +116,6 @@ import org.htmlunit.javascript.host.event.TextEvent; import org.htmlunit.javascript.host.event.UIEvent; import org.htmlunit.javascript.host.event.WheelEvent; -import org.htmlunit.javascript.host.file.Blob; import org.htmlunit.javascript.host.html.HTMLAllCollection; import org.htmlunit.javascript.host.html.HTMLBodyElement; import org.htmlunit.javascript.host.html.HTMLCollection; @@ -231,8 +228,6 @@ public class Document extends Node { private transient FontFaceSet fonts_; private transient StyleSheetList styleSheetList_; - private final Map blobUrl2Blobs_ = new HashMap<>(); - static { // commands String[] cmds = { @@ -3583,39 +3578,4 @@ public void clear() { public boolean contains(final Object element) { return getDocumentElement().contains(element); } - - /** - * Generate and return the URL for the given blob. - * @param blob the Blob containing the data - * @return the URL {@link org.htmlunit.javascript.host.URL#createObjectURL(Object)} - */ - public String generateBlobUrl(final Blob blob) { - final URL url = getPage().getUrl(); - - String origin = "null"; - if (!UrlUtils.URL_ABOUT_BLANK.equals(url)) { - origin = url.getProtocol() + "://" + url.getAuthority(); - } - - final String blobUrl = "blob:" + origin + "/" + UUID.randomUUID(); - blobUrl2Blobs_.put(blobUrl, blob); - return blobUrl; - } - - /** - * Resolves a blob URL to its associated {@link Blob}. - * @param url the url to resolve - * @return the Blob for the given URL or {@code null} if not found - */ - public Blob resolveBlobUrl(final String url) { - return blobUrl2Blobs_.get(url); - } - - /** - * Revokes the URL for the given blob. - * @param url the url to revoke {@link org.htmlunit.javascript.host.URL#revokeObjectURL(Scriptable)} - */ - public void revokeBlobUrl(final String url) { - blobUrl2Blobs_.remove(url); - } } diff --git a/src/test/java/org/htmlunit/javascript/host/URL2Test.java b/src/test/java/org/htmlunit/javascript/host/URL2Test.java new file mode 100644 index 0000000000..7ad198abc8 --- /dev/null +++ b/src/test/java/org/htmlunit/javascript/host/URL2Test.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.javascript.host; + +import org.htmlunit.HttpHeader; +import org.htmlunit.SimpleWebTestCase; +import org.htmlunit.TopLevelWindow; +import org.htmlunit.WebClient; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.html.HtmlPage; +import org.htmlunit.util.UrlUtils; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link URL}. + * + * @author Lai Quang Duong + */ +public class URL2Test extends SimpleWebTestCase { + + /** + * @throws Exception if the test fails + */ + @Test + public void createObjectURL() throws Exception { + final String html = DOCTYPE_HTML + ""; + + getMockWebConnection().setResponse(URL_FIRST, html); + getMockWebConnection().setResponse(URL_SECOND, html); + + final WebClient wc = getWebClientWithMockWebConnection(); + final HtmlPage page = wc.getPage(URL_FIRST); + final HtmlPage otherPage = (HtmlPage) wc.openWindow(URL_SECOND, "other").getEnclosedPage(); + + final String blobUrl1 = (String) page.executeJavaScript( + "URL.createObjectURL(new Blob(['blob1'], {type: 'text/plain'}))").getJavaScriptResult(); + final String blobUrl2 = (String) otherPage.executeJavaScript( + "URL.createObjectURL(new Blob(['blob2'], {type: 'text/plain'}))").getJavaScriptResult(); + + // each blob URL carries its creator's origin + assertEquals(true, blobUrl1.startsWith( + "blob:" + page.getUrl().getProtocol() + "://" + page.getUrl().getAuthority() + "/")); + assertEquals(true, blobUrl2.startsWith( + "blob:" + otherPage.getUrl().getProtocol() + "://" + otherPage.getUrl().getAuthority() + "/")); + + // every blob resolves through the one store, regardless of which window created it + assertEquals("blob1", wc.loadWebResponse(new WebRequest(UrlUtils.toUrlUnsafe(blobUrl1))).getContentAsString()); + assertEquals("blob2", wc.loadWebResponse(new WebRequest(UrlUtils.toUrlUnsafe(blobUrl2))).getContentAsString()); + + // the fragment is ignored when resolving a blob URL + assertEquals("blob1", + wc.loadWebResponse(new WebRequest(UrlUtils.toUrlUnsafe(blobUrl1 + "#foo=bar"))).getContentAsString()); + + // closing a window drops only the blobs it created + ((TopLevelWindow) otherPage.getEnclosingWindow()).close(); + assertEquals(null, wc.getBlobUrlStore().resolve(blobUrl2)); + assertEquals(true, wc.getBlobUrlStore().resolve(blobUrl1) != null); + + // navigating a window away drops the blobs it created + page.executeJavaScript("window.location.href = '" + URL_SECOND + "';"); + assertEquals(null, wc.getBlobUrlStore().resolve(blobUrl1)); + } + + /** + * @throws Exception if the test fails + */ + @Test + public void createObjectURLFromFile() throws Exception { + final String html = DOCTYPE_HTML + ""; + getMockWebConnection().setResponse(URL_FIRST, html); + + final WebClient wc = getWebClientWithMockWebConnection(); + final HtmlPage page = wc.getPage(URL_FIRST); + + final String blobUrl = (String) page.executeJavaScript( + "URL.createObjectURL(new File(['file'], 'foo.txt', {type: 'text/plain'}))").getJavaScriptResult(); + + final WebResponse response = wc.loadWebResponse(new WebRequest(UrlUtils.toUrlUnsafe(blobUrl))); + assertEquals("file", response.getContentAsString()); + assertEquals("text/plain", response.getResponseHeaderValue(HttpHeader.CONTENT_TYPE)); + assertEquals("inline; filename=\"foo.txt\"", response.getResponseHeaderValue(HttpHeader.CONTENT_DISPOSITION)); + } + + /** + * @throws Exception if the test fails + */ + @Test + public void revokeObjectURL() throws Exception { + final String html = DOCTYPE_HTML + ""; + getMockWebConnection().setResponse(URL_FIRST, html); + + final WebClient wc = getWebClientWithMockWebConnection(); + final HtmlPage page = wc.getPage(URL_FIRST); + + final String blobUrl = (String) page.executeJavaScript( + "URL.createObjectURL(new Blob(['blob'], {type: 'text/plain'}))").getJavaScriptResult(); + assertEquals("blob", wc.loadWebResponse(new WebRequest(UrlUtils.toUrlUnsafe(blobUrl))).getContentAsString()); + + page.executeJavaScript("URL.revokeObjectURL('" + blobUrl + "');"); + assertEquals(null, wc.getBlobUrlStore().resolve(blobUrl)); + } +} diff --git a/src/test/java/org/htmlunit/javascript/host/URLTest.java b/src/test/java/org/htmlunit/javascript/host/URLTest.java index 5096522b4c..353a334349 100644 --- a/src/test/java/org/htmlunit/javascript/host/URLTest.java +++ b/src/test/java/org/htmlunit/javascript/host/URLTest.java @@ -17,6 +17,7 @@ import static java.nio.charset.StandardCharsets.ISO_8859_1; import java.io.File; +import java.util.List; import org.apache.commons.io.FileUtils; import org.htmlunit.WebDriverTestCase; @@ -30,6 +31,7 @@ * Tests for {@link URL}. * * @author Ronald Brill + * @author Lai Quang Duong */ public class URLTest extends WebDriverTestCase { @@ -231,13 +233,24 @@ public void createObjectURL() throws Exception { + "\n" + "\n" + "\n" @@ -261,8 +274,12 @@ public void createObjectURL() throws Exception { driver.findElement(By.id("testBtn")).click(); - final String url = getCollectedAlerts(driver, 1).get(0); - assertTrue(url, url.startsWith("blob:")); + final List alerts = getCollectedAlerts(driver, 4); + final String blobUrl = alerts.get(0); + assertEquals("blob:http://localhost:" + PORT + "/", blobUrl.substring(0, blobUrl.lastIndexOf('/') + 1)); + assertEquals("200:Hello HtmlUnit", alerts.get(1)); + assertEquals("200:Hello HtmlUnit", alerts.get(2)); + assertEquals("No entry for '" + blobUrl + "' in the BlobUrlStore.", alerts.get(3)); } finally { FileUtils.deleteQuietly(tstFile);