CVE-2026-113302
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/api/page_capture/page_capture_api.cc |
modified | |
RenderFrameHostchrome/browser/extensions/api/page_capture/page_capture_api.h |
modified | |
WebContentschrome/browser/extensions/api/page_capture/page_capture_api.h |
modified | |
ifcontent/browser/download/mhtml_generation_manager.cc |
modified |
Files Changed
content/browser/download/mhtml_generation_browsertest.ccchrome/browser/extensions/api/page_capture/page_capture_apitest.ccchrome/browser/extensions/api/page_capture/page_capture_api.cccontent/browser/download/mhtml_generation_manager.cccontent/public/common/mhtml_generation_params.cccontent/public/common/mhtml_generation_params.hchrome/browser/extensions/api/page_capture/page_capture_api.h
Patch
From 13288ee314a6ae0ad83839611c4522db3fd3719b Mon Sep 17 00:00:00 2001
From: Min Qin <qinmin@chromium.org>
Date: Thu, 17 Sep 2026 23:50:35 -0700
Subject: [PATCH] [Extensions] Validate frame permissions in pageCapture MHTML
Previously, `PageCaptureSaveAsMHTMLFunction` evaluated capture
permissions exclusively against the primary main frame's committed
origin. During MHTML serialization, all nodes in the primary
frame tree were queued, which could include subframes originating
from other extensions or contexts with restricted capture
permissions.
This change:
1. Introduces `MHTMLGenerationParams::frame_filter` allowing
callers to filter which subframes are included in the
generated MHTML archive.
2. In `MHTMLGenerationManager::Job::initializeJob()`, filters
subframes in breadth-first traversal order and automatically
excludes any descendant subtrees of filtered frames.
3. In `PageCaptureSaveAsMHTMLFunction`, implements
`CanCaptureFrame()` to validate frame permissions (including
precursor tuples for opaque frames, file:// permissions, and
activeTab requirements for foreign extension/chrome:
subframes).
4. Re-validates `CanCaptureCurrentPage()` in
`TemporaryFileCreatedOnUI()` to close the TOCTOU window where
the main frame navigates or permissions change while the
temporary file is being created.
5. Adds unit/browser tests in content/ and chrome/ verifying
subframe omission, subtree omission, and cross-extension
isolation.
Bug: 500491981
Change-Id: Ifd2e55081865704e48f4f68c5e026e81176f7a53
Fixed: 500491981
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8417682
Reviewed-by: Reilly Grant <reillyg@chromium.org>
Commit-Queue: Min Qin <qinmin@chromium.org>
Reviewed-by: Camille Lamy <clamy@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1700526}
---
diff --git a/chrome/browser/extensions/api/page_capture/page_capture_api.cc b/chrome/browser/extensions/api/page_capture/page_capture_api.cc
index 1ec9e560..162f123 100644
--- a/chrome/browser/extensions/api/page_capture/page_capture_api.cc
+++ b/chrome/browser/extensions/api/page_capture/page_capture_api.cc
@@ -16,6 +16,7 @@
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/child_process_security_policy.h"
+#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/web_contents.h"
#include "content/public/common/child_process_id.h"
#include "content/public/common/mhtml_generation_params.h"
@@ -23,7 +24,9 @@
#include "extensions/browser/extension_util.h"
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/permissions/permissions_data.h"
+#include "url/gurl.h"
#include "url/origin.h"
+#include "url/scheme_host_port.h"
static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));
@@ -102,29 +105,58 @@
return RespondLater();
}
-bool PageCaptureSaveAsMHTMLFunction::CanCaptureCurrentPage(
- WebContents& web_contents,
- std::string* error) {
- const url::Origin& origin =
- web_contents.GetPrimaryMainFrame()->GetLastCommittedOrigin();
- bool can_capture_page = false;
- if (origin.scheme() == url::kFileScheme) {
- // We special case file schemes, since we don't check for URL permissions
- // in CanCaptureVisiblePage() with the pageCapture API. This ensures
- // file:// URLs are only capturable with the proper permission.
- can_capture_page = extensions::util::AllowFileAccess(
- extension()->id(), web_contents.GetBrowserContext());
+bool PageCaptureSaveAsMHTMLFunction::CanCaptureFrame(
+ content::RenderFrameHost* render_frame_host) const {
+ CHECK(render_frame_host);
+ CHECK(extension());
+ auto* web_contents = WebContents::FromRenderFrameHost(render_frame_host);
+ CHECK(web_contents);
+
+ const url::Origin& origin = render_frame_host->GetLastCommittedOrigin();
+ GURL check_url;
+ const GURL& committed_url = render_frame_host->GetLastCommittedURL();
+ if (render_frame_host->IsInPrimaryMainFrame() &&
+ committed_url.SchemeIs(url::kDataScheme)) {
+ // Top-level data: URLs must be checked as data: URLs so
+ // CanCaptureVisiblePage enforces activeTab even if navigated from an
+ // http/https initiator.
+ check_url = committed_url;
+ } else if (origin.opaque()) {
+ const url::SchemeHostPort& precursor =
+ origin.GetTupleOrPrecursorTupleIfOpaque();
+ if (precursor.IsValid()) {
+ check_url = precursor.GetURL();
+ } else {
+ check_url = committed_url;
+ }
} else {
- std::string unused_error;
- // TODO(tjudkins): We should change CanCaptureVisiblePage to take the
- // url::Origin directly, as it converts the GURL to an origin itself anyway.
- can_capture_page = extension()->permissions_data()->CanCaptureVisiblePage(
- origin.GetURL(),
- sessions::SessionTabHelper::IdForTab(&web_contents).id(), &unused_error,
- extensions::CaptureRequirement::kPageCapture);
+ check_url = origin.GetURL();
}
- if (!can_capture_page) {
+ // Special case: file:// URLs. `CanCaptureVisiblePage()` checks host
+ // permissions for file:// URLs, which pageCapture does not require. Instead,
+ // check enterprise policy restrictions and whether the extension has file
+ // access enabled.
+ if (check_url.SchemeIs(url::kFileScheme)) {
+ if (extension()->location() != mojom::ManifestLocation::kComponent &&
+ extension()->permissions_data()->IsPolicyBlockedHost(check_url)) {
+ return false;
+ }
+ return extensions::util::AllowFileAccess(extension()->id(),
+ web_contents->GetBrowserContext());
+ }
+
+ std::string unused_error;
+ return extension()->permissions_data()->CanCaptureVisiblePage(
+ check_url, sessions::SessionTabHelper::IdForTab(web_contents).id(),
+ &unused_error, extensions::CaptureRequirement::kPageCapture);
+}
+
+bool PageCaptureSaveAsMHTMLFunction::CanCaptureCurrentPage(
+ WebContents& web_contents,
+ std::string* error) const {
+ bool can_capture_page = CanCaptureFrame(web_contents.GetPrimaryMainFrame());
+ if (!can_capture_page && error) {
*error = kPageCaptureNotAllowed;
}
return can_capture_page;
@@ -196,8 +228,17 @@
return;
}
+ std::string error;
+ if (!CanCaptureCurrentPage(*web_contents, &error)) {
+ ReturnFailure(error);
+ return;
+ }
+
+ content::MHTMLGenerationParams params(mhtml_path_);
+ params.frame_filter = base::BindRepeating(
+ &PageCaptureSaveAsMHTMLFunction::CanCaptureFrame, this);
web_contents->GenerateMHTML(
- content::MHTMLGenerationParams(mhtml_path_),
+ params,
base::BindOnce(&PageCaptureSaveAsMHTMLFunction::MHTMLGenerated, this));
}
@@ -252,7 +293,7 @@
Respond(WithArguments(std::move(response)));
}
-WebContents* PageCaptureSaveAsMHTMLFunction::GetWebContents() {
+WebContents* PageCaptureSaveAsMHTMLFunction::GetWebContents() const {
content::WebContents* web_contents = nullptr;
if (!ExtensionTabUtil::GetTabById(params_->details.tab_id, browser_context(),
include_incognito_information(),
diff --git a/chrome/browser/extensions/api/page_capture/page_capture_api.h b/chrome/browser/extensions/api/page_capture/page_capture_api.h
index c5b81981..ba8a358 100644
--- a/chrome/browser/extensions/api/page_capture/page_capture_api.h
+++ b/chrome/browser/extensions/api/page_capture/page_capture_api.h
@@ -23,8 +23,9 @@
}
namespace content {
+class RenderFrameHost;
class WebContents;
-}
+} // namespace content
namespace extensions {
@@ -48,10 +49,14 @@
ResponseAction Run() override;
void OnResponseAck() override;
+ // Returns whether or not the extension has permission to capture the given
+ // frame.
+ bool CanCaptureFrame(content::RenderFrameHost* render_frame_host) const;
+
// Returns whether or not the extension has permission to capture the current
// page. Sets |*error| to an error value on failure.
bool CanCaptureCurrentPage(content::WebContents& web_contents,
- std::string* error);
+ std::string* error) const;
// Called on the file thread.
void CreateTemporaryFile();
@@ -67,7 +72,7 @@
void MHTMLGenerated(int64_t mhtml_file_size);
// Returns the WebContents we are associated with, NULL if it's been closed.
- content::WebContents* GetWebContents();
+ content::WebContents* GetWebContents() const;
// The document ID for the page being captured. Used to check that the page
// hasn't navigated before the capture completes.
diff --git a/content/browser/download/mhtml_generation_manager.cc b/content/browser/download/mhtml_generation_manager.cc
index 3cf5060..3155e5b 100644
--- a/content/browser/download/mhtml_generation_manager.cc
+++ b/content/browser/download/mhtml_generation_manager.cc
@@ -4,6 +4,7 @@
#include "content/browser/download/mhtml_generation_manager.h"
+#include <set>
#include <tuple>
#include <utility>
@@ -42,6 +43,7 @@
#include "mojo/public/cpp/system/data_pipe.h"
#include "mojo/public/cpp/system/data_pipe_drainer.h"
#include "net/base/mime_util.h"
+#include "third_party/abseil-cpp/absl/container/flat_hash_set.h"
#include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
#include "third_party/perfetto/include/perfetto/tracing/track.h"
@@ -331,6 +333,10 @@
// The documents that still need to be processed.
base::queue<base::WeakPtr<RenderFrameHostImpl>> pending_render_frame_hosts_;
+ // Set of FrameTreeNodeIds whose subtrees should be skipped during
+ // serialization (either filtered out initially or after in-place navigation).
+ absl::flat_hash_set<FrameTreeNodeId> skipped_frame_tree_node_ids_;
+
// Identifies a frame to which we've sent through
// MhtmlFileWriter::SerializeAsMHTML but for which we didn't yet process
// the response via SerializeAsMHTMLResponse.
@@ -360,8 +366,6 @@
// MHTMLFileWriter instance for the frame being currently serialized.
mojo::AssociatedRemote<mojom::MhtmlFileWriter> writer_;
-
-
// Indicates whether there is currently data being streamed from the Renderer.
// Not used when the renderer is writing directly to file.
bool waiting_on_data_streaming_;
@@ -406,6 +410,16 @@
// Skip inner tree placeholder nodes.
continue;
}
+ if (node->parent() && skipped_frame_tree_node_ids_.contains(
+ node->parent()->GetFrameTreeNodeId())) {
+ skipped_frame_tree_node_ids_.insert(node->frame_tree_node_id());
+ continue;
+ }
+ if (params_.frame_filter && !node->IsMainFrame() &&
+ !params_.frame_filter.Run(node->current_frame_host())) {
+ skipped_frame_tree_node_ids_.insert(node->frame_tree_node_id());
+ continue;
+ }
pending_render_frame_hosts_.push(node->current_frame_host()->GetWeakPtr());
}
@@ -416,8 +430,9 @@
// Save off any extra data.
auto* extra_parts = static_cast<MHTMLExtraPartsImpl*>(
MHTMLExtraParts::FromWebContents(web_contents));
- if (extra_parts)
+ if (extra_parts) {
extra_data_parts_ = extra_parts->parts();
+ }
download::GetDownloadTaskRunner()->PostTaskAndReplyWithResult(
FROM_HERE, base::BindOnce(&CreateMHTMLFile, params_.file_path),
@@ -445,10 +460,34 @@
CHECK(browser_file_.IsValid());
CHECK(!pending_render_frame_hosts_.empty());
- RenderFrameHostImpl* rfh = pending_render_frame_hosts_.front().get();
- pending_render_frame_hosts_.pop();
- if (!rfh) { // The contents went away.
- return mojom::MhtmlSaveStatus::kFrameNoLongerExists;
+ RenderFrameHostImpl* rfh = nullptr;
+ while (!pending_render_frame_hosts_.empty()) {
+ rfh = pending_render_frame_hosts_.front().get();
+ pending_render_frame_hosts_.pop();
+ if (!rfh) {
+ // If we haven't bound |writer_| yet, the main frame went away.
+ if (!writer_) {
+ return mojom::MhtmlSaveStatus::kFrameNoLongerExists;
+ }
+ continue;
+ }
+ FrameTreeNode* node = rfh->frame_tree_node();
+ if (node->parent() && skipped_frame_tree_node_ids_.contains(
+ node->parent()->GetFrameTreeNodeId())) {
+ skipped_frame_tree_node_ids_.insert(node->frame_tree_node_id());
+ rfh = nullptr;
+ continue;
+ }
+ if (params_.frame_filter && !node->IsMainFrame() &&
+ !params_.frame_filter.Run(rfh)) {
+ skipped_frame_tree_node_ids_.insert(node->frame_tree_node_id());
+ rfh = nullptr;
+ continue;
+ }
+ break;
+ }
+ if (!rfh) {
+ return mojom::MhtmlSaveStatus::kSuccess;
}
if (writer_) {
@@ -515,8 +554,9 @@
// to the UI thread before it was reset in MarkAsFinished(). Since the Job
// is still alive waiting for the file to close, the weak pointer remains
// valid and this task will still run.
- if (is_finished_)
+ if (is_finished_) {
return;
+ }
pipe_reader_.Reset();
waiting_on_data_streaming_ = false;
@@ -542,8 +582,9 @@
browser_file_ = std::move(browser_file);
mojom::MhtmlSaveStatus save_status = SendToNextRenderFrame();
- if (save_status != mojom::MhtmlSaveStatus::kSuccess)
+ if (save_status != mojom::MhtmlSaveStatus::kSuccess) {
Finalize(save_status);
+ }
}
void MHTMLGenerationManager::Job::OnFinished(
@@ -591,8 +632,9 @@
// Only update the status if that won't hide an earlier error.
if (!browser_file_.IsValid() &&
- save_status == mojom::MhtmlSaveStatus::kSuccess)
+ save_status == mojom::MhtmlSaveStatus::kSuccess) {
save_status = mojom::MhtmlSaveStatus::kFileWritingError;
+ }
// If no previous error occurred the boundary should be sent.
download::GetDownloadTaskRunner()->PostTaskAndReplyWithResult(
@@ -614,8 +656,9 @@
frame_tree_node_id_of_busy_frame_ = FrameTreeNodeId();
// If the renderer succeeded, update the resource digests.
- if (save_status == mojom::MhtmlSaveStatus::kSuccess)
+ if (save_status == mojom::MhtmlSaveStatus::kSuccess) {
RecordDigests(digests_of_uris_of_serialized_resources);
+ }
MaybeSendToNextRenderFrame(save_status);
}
@@ -746,8 +789,9 @@
std::string serialized_extra_data_parts;
// Don't write an extra data part if there is none.
- if (extra_data_parts.empty())
+ if (extra_data_parts.empty()) {
return serialized_extra_data_parts;
+ }
// For each extra part, serialize that part and add to our accumulator
// string.
diff --git a/content/public/common/mhtml_generation_params.cc b/content/public/common/mhtml_generation_params.cc
index cc1254d0..1a63ca73 100644
--- a/content/public/common/mhtml_generation_params.cc
+++ b/content/public/common/mhtml_generation_params.cc
@@ -13,4 +13,18 @@
MHTMLGenerationParams::MHTMLGenerationParams(const base::FilePath& file_path)
: file_path(file_path) {}
+MHTMLGenerationParams::MHTMLGenerationParams(
+ const MHTMLGenerationParams& other) = default;
+
+MHTMLGenerationParams& MHTMLGenerationParams::operator=(
+ const MHTMLGenerationParams& other) = default;
+
+MHTMLGenerationParams::MHTMLGenerationParams(MHTMLGenerationParams&& other) =
+ default;
+
+MHTMLGenerationParams& MHTMLGenerationParams::operator=(
+ MHTMLGenerationParams&& other) = default;
+
+MHTMLGenerationParams::~MHTMLGenerationParams() = default;
+
} // namespace content
diff --git a/content/public/common/mhtml_generation_params.h b/content/public/common/mhtml_generation_params.h
index 99465ab..49547ea 100644
--- a/content/public/common/mhtml_generation_params.h
+++ b/content/public/common/mhtml_generation_params.h
@@ -6,13 +6,22 @@
... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/page_capture/page_capture_apitest.cc b/chrome/browser/extensions/api/page_capture/page_capture_apitest.cc
index c644649..1337fd5 100644
--- a/chrome/browser/extensions/api/page_capture/page_capture_apitest.cc
+++ b/chrome/browser/extensions/api/page_capture/page_capture_apitest.cc
@@ -6,12 +6,21 @@
#include "base/command_line.h"
#include "base/memory/weak_ptr.h"
+#include "base/strings/stringprintf.h"
+#include "base/values.h"
#include "build/build_config.h"
#include "chrome/browser/extensions/api/page_capture/page_capture_api.h"
#include "chrome/browser/extensions/extension_apitest.h"
+#include "chrome/browser/extensions/extension_tab_util.h"
+#include "chrome/browser/profiles/profile.h"
#include "content/public/test/browser_test.h"
+#include "content/public/test/browser_test_utils.h"
+#include "extensions/browser/background_script_executor.h"
+#include "extensions/browser/permissions/active_tab_permission_granter.h"
#include "extensions/buildflags/buildflags.h"
+#include "extensions/test/test_extension_dir.h"
#include "net/dns/mock_host_resolver.h"
+#include "testing/gmock/include/gmock/gmock.h"
#include "third_party/blink/public/common/switches.h"
static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));
@@ -98,4 +107,156 @@
<< message_;
}
+// Tests that chrome.pageCapture.saveAsMHTML excludes subframes belonging to
+// other extensions unless activeTab permission has been granted, while
+// preserving the capturing extension's own frames and the main document.
+#if BUILDFLAG(IS_ANDROID)
+#define MAYBE_SaveAsMHTMLWithExtensionSubframe \
+ DISABLED_SaveAsMHTMLWithExtensionSubframe
+#else
+#define MAYBE_SaveAsMHTMLWithExtensionSubframe SaveAsMHTMLWithExtensionSubframe
+#endif
+IN_PROC_BROWSER_TEST_F(ExtensionPageCaptureApiTest,
+ MAYBE_SaveAsMHTMLWithExtensionSubframe) {
+ ASSERT_TRUE(StartEmbeddedTestServer());
+
+ // Load a first extension that exposes a web accessible resource widget.
+ TestExtensionDir ext_dir1;
+ ext_dir1.WriteManifest(R"({
+ "name": "First Extension",
+ "version": "1.0",
+ "manifest_version": 3,
+ "web_accessible_resources": [{
+ "resources": ["widget.html", "sandbox_widget.html"],
+ "matches": ["<all_urls>"]
+ }]
+ })");
+ ext_dir1.WriteFile(FILE_PATH_LITERAL("widget.html"),
+ "<p id='ext1'>ext1_sample_data</p>");
+ ext_dir1.WriteFile(FILE_PATH_LITERAL("sandbox_widget.html"),
+ "<p id='ext1_sandbox'>ext1_sandbox_data</p>");
+ const Extension* extension1 = LoadExtension(ext_dir1.UnpackedPath());
+ ASSERT_TRUE(extension1);
+
+ // Load a second extension with pageCapture and activeTab permissions.
+ TestExtensionDir ext_dir2;
+ ext_dir2.WriteManifest(R"({
+ "name": "Second Extension",
+ "version": "1.0",
+ "manifest_version": 3,
+ "permissions": ["pageCapture", "tabs", "activeTab"],
+ "background": {
+ "service_worker": "bg.js"
+ },
+ "web_accessible_resources": [{
+ "resources": ["own_widget.html"],
+ "matches": ["<all_urls>"]
+ }]
+ })");
+ ext_dir2.WriteFile(FILE_PATH_LITERAL("bg.js"), "// background");
+ ext_dir2.WriteFile(FILE_PATH_LITERAL("own_widget.html"),
+ "<p id='ext2'>ext2_sample_data</p>");
+ const Extension* extension2 = LoadExtension(ext_dir2.UnpackedPath());
+ ASSERT_TRUE(extension2);
+
+ // Navigate to a test page in the main tab.
+ GURL page_url = embedded_test_server()->GetURL("example.com", "/title1.html");
+ content::WebContents* main_tab = GetActiveWebContents();
+ ASSERT_TRUE(main_tab);
+ ASSERT_TRUE(content::NavigateToURL(main_tab, page_url));
+ const int tab_id = ExtensionTabUtil::GetTabId(main_tab);
+
+ // Embed extension iframes (including a sandboxed iframe with an opaque
+ // origin whose precursor tuple is extension1) and wait for all to load.
+ GURL ext1_widget_url = extension1->GetResourceURL("widget.html");
+ GURL ext1_sandbox_url = extension1->GetResourceURL("sandbox_widget.html");
+ GURL ext2_widget_url = extension2->GetResourceURL("own_widget.html");
+
+ ASSERT_TRUE(content::ExecJs(
+ main_tab, content::JsReplace(
+ R"(
+ new Promise(resolve => {
+ let loaded = 0;
+ const onLoad = () => { if (++loaded === 3) resolve(); };
+
+ const iframe1 = document.createElement('iframe');
+ iframe1.onload = onLoad;
+ iframe1.src = $1;
+ document.body.appendChild(iframe1);
+
+ const iframe2 = document.createElement('iframe');
+ iframe2.sandbox = '';
+ iframe2.onload = onLoad;
+ iframe2.src = $2;
+ document.body.appendChild(iframe2);
+
+ const iframe3 = document.createElement('iframe');
+ iframe3.onload = onLoad;
+ iframe3.src = $3;
+ document.body.appendChild(iframe3);
+ })
+ )",
+ ext1_widget_url, ext1_sandbox_url, ext2_widget_url)));
+
+ auto capture_tab_fn = [this, extension2](int target_tab_id) -> std::string {
+ static constexpr char kScript[] = R"(
+ chrome.pageCapture.saveAsMHTML({tabId: %d}, async (blob) => {
+ if (chrome.runtime.lastError) {
+ chrome.test.sendScriptResult(
+ 'ERROR: ' + chrome.runtime.lastError.message);
+ return;
+ }
+ if (!blob) {
+ chrome.test.sendScriptResult('ERROR: No blob returned');
+ return;
+ }
+ const text = await blob.text();
+ chrome.test.sendScriptResult(text);
+ });
+ )";
+ base::Value result = BackgroundScriptExecutor::ExecuteScript(
+ profile(), extension2->id(), base::StringPrintf(kScript, target_tab_id),
+ BackgroundScriptExecutor::ResultCapture::kSendScriptResult);
+ EXPECT_TRUE(result.is_string());
+ return result.is_string() ? result.GetString() : std::string();
+ };
+
+ // 1. Request pageCapture from the second extension without activeTab granted.
+ std::string mhtml = capture_tab_fn(tab_id);
+
+ EXPECT_THAT(mhtml, testing::Not(testing::StartsWith("ERROR:")));
+ // The main page content should be present.
+ EXPECT_THAT(mhtml, testing::HasSubstr("This page has no title."));
+ // The capturing extension's own subframe should be included.
+ EXPECT_THAT(mhtml, testing::HasSubstr("ext2_sample_data"));
+ EXPECT_THAT(mhtml, testing::HasSubstr("own_widget.html"));
+ // The other extension's subframes (both normal and sandboxed) should NOT be
+ // included.
+ EXPECT_THAT(mhtml, testing::Not(testing::HasSubstr("ext1_sample_data")));
+ EXPECT_THAT(mhtml, testing::Not(testing::HasSubstr(ext1_widget_url.spec())));
+ EXPECT_THAT(mhtml, testing::Not(testing::HasSubstr("ext1_sandbox_data")));
+ EXPECT_THAT(mhtml, testing::Not(testing::HasSubstr(ext1_sandbox_url.spec())));
+
+ // 2. Grant activeTab permission on the main tab and capture again.
+ ActiveTabPermissionGranter::FromWebContents(main_tab)->GrantIfRequested(
+ extension2);
+ std::string mhtml_with_active_tab = capture_tab_fn(tab_id);
+ EXPECT_THAT(mhtml_with_active_tab,
+ testing::Not(testing::StartsWith("ERROR:")));
+ EXPECT_THAT(mhtml_with_active_tab, testing::HasSubstr("ext1_sample_data"));
+ EXPECT_THAT(mhtml_with_active_tab, testing::HasSubstr("ext1_sandbox_data"));
+ EXPECT_THAT(mhtml_with_active_tab, testing::HasSubstr("ext2_sample_data"));
+
+ // 3. Capturing a tab where the main frame itself belongs to the other
+ // extension should be rejected when activeTab is not granted.
+ content::WebContents* new_main_tab = GetActiveWebContents();
+ ASSERT_TRUE(new_main_tab);
+ ASSERT_TRUE(content::NavigateToURL(new_main_tab, ext1_widget_url));
+ const int new_tab_id = ExtensionTabUtil::GetTabId(new_main_tab);
+ std::string ext_main_frame_capture = capture_tab_fn(new_tab_id);
+ EXPECT_THAT(ext_main_frame_capture,
+ testing::HasSubstr(
+ "Don't have permissions required to capture this page."));
+}
+
} // namespace extensions
diff --git a/content/browser/download/mhtml_generation_browsertest.cc b/content/browser/download/mhtml_generation_browsertest.cc
index cfa602b..4b4eeef4 100644
--- a/content/browser/download/mhtml_generation_browsertest.cc
+++ b/content/browser/download/mhtml_generation_browsertest.cc
@@ -833,6 +833,54 @@
EndsWith("/title1.html")}));
}
+IN_PROC_BROWSER_TEST_F(MHTMLGenerationSitePerProcessTest,
+ GenerateMHTMLWithFrameFilter) {
+ base::FilePath path =
+ temp_dir_.GetPath().Append(FILE_PATH_LITERAL("test.mht"));
+
+ GURL url(embedded_test_server()->GetURL(
+ "a.com", "/frame_tree/page_with_one_frame.html"));
+ MHTMLGenerationParams params(path);
+ params.frame_filter =
+ base::BindRepeating([](RenderFrameHost* rfh) { return false; });
+
+ MHTMLFileInfo info = GenerateMHTML(params, url);
+
+ // Make sure the main frame is present, but the filtered subframe is omitted.
+ EXPECT_THAT(info.content(), HasSubstr("This page has one cross-site iframe"));
+ EXPECT_THAT(info.content(), Not(HasSubstr("This page has no title")));
+
+ EXPECT_THAT(
+ info.ContentLocations(),
+ testing::ElementsAre(EndsWith("/frame_tree/page_with_one_frame.html")));
+}
+
+IN_PROC_BROWSER_TEST_F(MHTMLGenerationSitePerProcessTest,
+ GenerateMHTMLWithFrameFilterSubtree) {
+ base::FilePath path =
+ temp_dir_.GetPath().Append(FILE_PATH_LITERAL("test.mht"));
+
+ GURL url(embedded_test_server()->GetURL(
+ "a.com", "/cross_site_iframe_factory.html?a(b(c),d(e))"));
+ MHTMLGenerationParams params(path);
+ // Filter out b.com, allowing only other frames. Because c.com is a child
+ // of b.com, it should also be excluded from the generated archive, while
+ // sibling branch d(e) should be preserved.
+ params.frame_filter = base::BindRepeating([](RenderFrameHost* rfh) {
+ return rfh->GetLastCommittedOrigin().host() != "b.com";
+ });
+
+ MHTMLFileInfo info = GenerateMHTML(params, url);
+
+ EXPECT_THAT(
+ info.ContentLocations(),
+ Contains(EndsWith("/cross_site_iframe_factory.html?a(b(c),d(e))")));
+ EXPECT_THAT(info.ContentLocations(), Not(Contains(HasSubstr("b.com"))));
+ EXPECT_THAT(info.ContentLocations(), Not(Contains(HasSubstr("c.com"))));
+ EXPECT_THAT(info.ContentLocations(), Contains(HasSubstr("d.com")));
+ EXPECT_THAT(info.ContentLocations(), Contains(HasSubstr("e.com")));
+}
+
IN_PROC_BROWSER_TEST_F(MHTMLGenerationTest, RemovePopupOverlay) {
base::FilePath path(temp_dir_.GetPath());
path = path.Append(FILE_PATH_LITERAL("test.mht"));
@@ -878,7 +926,6 @@
EXPECT_TRUE(has_mhtml_callback_run());
-
// Make sure that both extra data parts made it into the mhtml.
EXPECT_THAT(info.content(), HasSubstr(kFakeSignalData1));
EXPECT_THAT(info.content(), HasSubstr(kFakeSignalData2));
Original Bug Report
Cross-extension DOM leak in pageCapture API via subframe serialization
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.
Overview: The chrome.pageCapture.saveAsMHTML API validates capture permissions using only the main frame’s origin. During MHTML generation, all subframes in the primary frame tree are serialized without individual origin checks. This potentially allows a malicious extension to extract the sensitive live DOM of other extensions’ Web Accessible Resource (WAR) iframes.
Affected files:
chrome/browser/extensions/api/page_capture/page_capture_api.cccontent/browser/download/mhtml_generation_manager.ccextensions/common/permissions/permissions_data.cccontent/renderer/render_frame_impl.ccthird_party/blink/renderer/core/frame/frame_serializer.cc
Estimated timestamp from git blame: 2024-09-04
Overview
There is a potential information disclosure vulnerability in the chrome.pageCapture.saveAsMHTML API. The API validates permissions against the primary main frame’s origin but subsequently serializes all frames in the frame tree. This allows an attacker extension to bypass cross-extension isolation boundaries and capture the live DOM of chrome-extension:// Out-of-Process Iframes (OOPIFs) embedded by other extensions, circumventing the usual activeTab permission requirement.
Technical Details
-
Incomplete Permission Validation: In
chrome/browser/extensions/api/page_capture/page_capture_api.cc, thePageCaptureSaveAsMHTMLFunction::CanCaptureCurrentPagemethod validates permissions by passing only the main frame’s origin (web_contents.GetPrimaryMainFrame()->GetLastCommittedOrigin()) toPermissionsData::CanCaptureVisiblePage. If the main frame is a standard web URL (e.g.,https://), the check succeeds as long as the requesting extension has thepageCapturepermission. -
Unfiltered Frame Traversal: Once authorized, MHTML generation is triggered. In
content/browser/download/mhtml_generation_manager.cc(MHTMLGenerationManager::Job::initializeJob), the code iterates through everyFrameTreeNodein the primary frame tree. It indiscriminately queues all frames for serialization, regardless of their origin or scheme, meaningchrome-extension://WAR iframes are included. -
Bypass of Extension Isolation: Chrome’s security model typically requires the
activeTabpermission to capture or interact withchrome-extension://origins. Because the API only checks the main frame’s origin, thisactiveTabrequirement is completely bypassed for embedded extension iframes. -
Live DOM Disclosure: The renderer process serializes the live DOM of the requested frames via
FrameSerializer::SerializeFrame. Any sensitive data dynamically injected into the victim extension’s subframe DOM (e.g., decrypted credentials in an autofill widget) is serialized into the MHTML archive and returned to the attacker.
Potential Attacker Steps
(Note: These are suggested/potential steps based on code analysis; our tooling agent does not yet have the ability to run code to provide a working proof of concept.)
- An attacker publishes an extension that requests the
pageCapturepermission (and optionallytabsto find target pages), but no host permissions oractiveTab. - The user installs a victim extension (e.g., a password manager) that legitimately embeds a Web Accessible Resource (WAR) iframe into certain web pages, populating it with sensitive DOM data.
- The user navigates to a target website where the victim’s iframe is injected.
- The attacker extension invokes
chrome.pageCapture.saveAsMHTML({tabId: ...})for that tab. - The attacker receives the MHTML blob, parses it, and extracts the sensitive DOM content belonging to the victim extension’s
chrome-extension://iframe.
Suggested Fix
The MHTML generation process should be updated to respect isolation boundaries. Potential fixes include:
- Modifying
MHTMLGenerationManager::Job::initializeJobto filter out restricted subframes (such aschrome-extension://schemes) during the frame traversal step, unless the capturing context explicitly possesses the requisite privileges (e.g.,activeTabor specific host permissions for that extension ID). - Alternatively, enforce per-frame origin checks during the initial
CanCaptureCurrentPagevalidation, ensuring that the calling extension is authorized to capture every frame currently present in theWebContentstree.
Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.