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/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()
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);