mirror of
https://github.com/electron/electron.git
synced 2026-10-28 11:44:46 +08:00
feat: preload script URL filters (#54903)
* feat: preload script URL filters * feat: reject an empty preload script matches array An explicit `matches: []` meant "every URL", the same ambiguity webRequest filters dropped in favor of `<all_urls>`. Registering one now throws and points at `<all_urls>`; omitting `matches` still matches every URL, so existing registrations are unaffected. Also adds coverage that `<all_urls>` matches both an http page and about:blank. * docs: link preload URL filters to MDN match patterns Matches the webRequest filter docs, which already link to MDN. * test: extension protocol * feat: add matchOriginAsFallback to preload script URL filters A document whose URL has no origin of its own (about:, blob:, data:, filesystem:) was only matched by its literal URL, so an about:blank popup escaped its opener's excludeMatches and excludeGlobs. With matchOriginAsFallback, frame preloads match such documents by the origin they inherited from their creator, like match_origin_as_fallback for content scripts. * fix: reject an empty preload script includeGlobs array An explicitly empty includeGlobs array converted to the same state as an omitted one, so the preload ran for every URL instead of none. Reject it at registration, as an empty matches array already is. * test: use vitest assertions in preload URL filter specs main dropped chai in #54439, so the chai-style assertions these specs added no longer match the rest of the suite, and the dirty-chai call forms (to.be.empty(), to.be.false()) are no longer available. * fix: address review feedback on preload script URL filters - Match a data: URL loaded with baseURLForDataURL by its base URL, which is the URL the document commits at. - Never match when URL patterns were not parsed, instead of matching every URL. - Explain a scheme separator mismatch in the invalid pattern error. - Document custom scheme requirements, error page matching and opaque origins with matchOriginAsFallback. - Add specs for baseURLForDataURL, subframes and redirects. * docs: list preload script URL filter conditions Break the URL filter description for ses.registerPreloadScript into bullet lists, and note that in-page navigations do not rerun the preload script.
This commit is contained in:
1 parent
7979da6478
commit
3c4e6463fc
16 files changed
+746
-39
No files matched your search
+34
-1
@@ -1621,7 +1621,40 @@ for `frame` context types.
|
||||
|
||||
Registers preload script that will be executed in its associated context type in this session. For
|
||||
`frame` contexts, this will run prior to any preload defined in the web preferences of a
|
||||
WebContents.
|
||||
WebContents. In-page navigations do not rerun the preload script.
|
||||
|
||||
URL filters can limit which documents or service workers a script runs in. They behave like
|
||||
[content script URL matching](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/content_scripts#matching_url_patterns).
|
||||
A script runs for a URL that meets all of these conditions:
|
||||
|
||||
* It matches at least one pattern in `matches`, if set.
|
||||
* It matches at least one glob in `includeGlobs`, if set.
|
||||
* It does not match any pattern in `excludeMatches`.
|
||||
* It does not match any glob in `excludeGlobs`.
|
||||
|
||||
The URL that the filters are matched against depends on the context type:
|
||||
|
||||
* For `service-worker` contexts, it is the service worker's scope.
|
||||
* For `frame` contexts, it is the URL of the document being loaded, with these special cases:
|
||||
* The initial empty document of a frame or popup is matched as `about:blank`. Set
|
||||
`matchOriginAsFallback` to match such documents by the origin of the frame that created them
|
||||
instead, for example to keep a script out of the `about:blank` popups of an excluded site.
|
||||
* A `data:` URL loaded with `baseURLForDataURL` is matched by its base URL.
|
||||
* The error page of a failed navigation is matched by the URL that failed to load.
|
||||
|
||||
Throws an error if any of the URL patterns are invalid, or if `matches` or `includeGlobs` is an
|
||||
empty array.
|
||||
|
||||
```js
|
||||
const { session } = require('electron')
|
||||
|
||||
session.defaultSession.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: '/path/to/preload.js',
|
||||
matches: ['https://*.example.com/*'],
|
||||
excludeGlobs: ['*/logout*']
|
||||
})
|
||||
```
|
||||
|
||||
Returns `string` - The ID of the registered preload script.
|
||||
|
||||
|
||||
@@ -4,3 +4,21 @@
|
||||
Possible values include `frame` or `service-worker`.
|
||||
* `id` string (optional) - Unique ID of preload script. Defaults to a random UUID.
|
||||
* `filePath` string - Path of the script file. Must be an absolute path.
|
||||
* `matches` string[] (optional) - [Match patterns](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns)
|
||||
for URLs the script runs in. A custom protocol can be used if its scheme is registered as
|
||||
`standard` with [`protocol.registerSchemesAsPrivileged`](../protocol.md#protocolregisterschemesasprivilegedcustomschemes).
|
||||
To filter documents served from a non-standard scheme, use `includeGlobs` or `excludeGlobs`,
|
||||
which match against the whole URL. If omitted, the script runs for all URLs not excluded by
|
||||
other filters. Must not be empty.
|
||||
* `excludeMatches` string[] (optional) - Match patterns for URLs the script will not run in.
|
||||
* `includeGlobs` string[] (optional) - Glob patterns applied after `matches`. A URL must
|
||||
also match one of these globs. `*` matches any sequence of characters and `?` matches
|
||||
any single character. Must not be empty.
|
||||
* `excludeGlobs` string[] (optional) - Glob patterns for URLs the script will not run in.
|
||||
* `matchOriginAsFallback` boolean (optional) - Whether a document whose URL has no origin of
|
||||
its own (`about:`, `blob:`, `data:` or `filesystem:`) is matched by the origin of the frame
|
||||
that created it instead of by its URL. This includes documents with an opaque origin, such
|
||||
as a `data:` frame or a sandboxed `<iframe srcdoc>`. For example, an `about:blank` popup
|
||||
opened by `https://example.com/page` is matched as `https://example.com/`. When set, every
|
||||
pattern in `matches` must have a path of `/*`. Only applies to `frame` contexts. Default is
|
||||
`false`.
|
||||
@@ -4,3 +4,21 @@
|
||||
Possible values include `frame` or `service-worker`.
|
||||
* `id` string - Unique ID of preload script.
|
||||
* `filePath` string - Path of the script file. Must be an absolute path.
|
||||
* `matches` string[] (optional) - [Match patterns](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns)
|
||||
for URLs the script runs in. A custom protocol can be used if its scheme is registered as
|
||||
`standard` with [`protocol.registerSchemesAsPrivileged`](../protocol.md#protocolregisterschemesasprivilegedcustomschemes).
|
||||
To filter documents served from a non-standard scheme, use `includeGlobs` or `excludeGlobs`,
|
||||
which match against the whole URL. If omitted, the script runs for all URLs not excluded by
|
||||
other filters.
|
||||
* `excludeMatches` string[] (optional) - Match patterns for URLs the script will not run in.
|
||||
* `includeGlobs` string[] (optional) - Glob patterns applied after `matches`. A URL must
|
||||
also match one of these globs. `*` matches any sequence of characters and `?` matches
|
||||
any single character.
|
||||
* `excludeGlobs` string[] (optional) - Glob patterns for URLs the script will not run in.
|
||||
* `matchOriginAsFallback` boolean (optional) - Whether a document whose URL has no origin of
|
||||
its own (`about:`, `blob:`, `data:` or `filesystem:`) is matched by the origin of the frame
|
||||
that created it instead of by its URL. This includes documents with an opaque origin, such
|
||||
as a `data:` frame or a sandboxed `<iframe srcdoc>`. For example, an `about:blank` popup
|
||||
opened by `https://example.com/page` is matched as `https://example.com/`. When set, every
|
||||
pattern in `matches` must have a path of `/*`. Only applies to `frame` contexts. Default is
|
||||
`false`.
|
||||
@@ -1150,9 +1150,8 @@ void Session::CreateInterruptedDownload(const gin_helper::Dictionary& options) {
|
||||
base::Time::FromSecondsSinceUnixEpoch(start_time)));
|
||||
}
|
||||
|
||||
std::string Session::RegisterPreloadScript(
|
||||
gin_helper::ErrorThrower thrower,
|
||||
const PreloadScript& new_preload_script) {
|
||||
std::string Session::RegisterPreloadScript(gin_helper::ErrorThrower thrower,
|
||||
PreloadScript new_preload_script) {
|
||||
auto* prefs = SessionPreferences::FromBrowserContext(browser_context());
|
||||
DCHECK(prefs);
|
||||
|
||||
@@ -1183,11 +1182,16 @@ std::string Session::RegisterPreloadScript(
|
||||
}
|
||||
}
|
||||
|
||||
preload_scripts.push_back(new_preload_script);
|
||||
if (std::string error; !new_preload_script.ParseUrlPatterns(&error)) {
|
||||
thrower.ThrowError(error);
|
||||
return "";
|
||||
}
|
||||
|
||||
preload_scripts.push_back(std::move(new_preload_script));
|
||||
// A service worker that starts after this point picks up the new preload
|
||||
// automatically — GetServiceWorkerStartupData() rebuilds from
|
||||
// SessionPreferences on every StartWorker.
|
||||
return new_preload_script.id;
|
||||
return preload_scripts.back().id;
|
||||
}
|
||||
|
||||
void Session::UnregisterPreloadScript(gin_helper::ErrorThrower thrower,
|
||||
|
||||
@@ -145,7 +145,7 @@ class Session final : public gin::Wrappable<Session>,
|
||||
void DownloadURL(const GURL& url, gin::Arguments* args);
|
||||
void CreateInterruptedDownload(const gin_helper::Dictionary& options);
|
||||
std::string RegisterPreloadScript(gin_helper::ErrorThrower thrower,
|
||||
const PreloadScript& new_preload_script);
|
||||
PreloadScript new_preload_script);
|
||||
void UnregisterPreloadScript(gin_helper::ErrorThrower thrower,
|
||||
const std::string& script_id);
|
||||
std::vector<PreloadScript> GetPreloadScripts() const;
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
#include "mojo/public/cpp/bindings/remote.h"
|
||||
#include "mojo/public/cpp/platform/platform_handle.h"
|
||||
#include "mojo/public/cpp/system/platform_handle.h"
|
||||
#include "net/base/net_errors.h"
|
||||
#include "printing/buildflags/buildflags.h"
|
||||
#include "services/network/public/cpp/web_sandbox_flags.h"
|
||||
#include "services/network/public/mojom/web_sandbox_flags.mojom-shared.h"
|
||||
@@ -199,6 +200,8 @@
|
||||
#include "ui/events/base_event_utils.h"
|
||||
#include "ui/gfx/image/image_skia.h"
|
||||
#include "ui/views/widget/widget.h"
|
||||
#include "url/gurl.h"
|
||||
#include "url/url_constants.h"
|
||||
#include "v8/include/cppgc/allocation.h"
|
||||
#include "v8/include/v8-cppgc.h"
|
||||
|
||||
@@ -3044,7 +3047,8 @@ void WebContents::HandleNewRenderFrame(
|
||||
const bool allow_subframes =
|
||||
web_preferences && web_preferences->AllowsNodeIntegrationInSubFrames();
|
||||
if (!render_frame_host->GetParent() || allow_subframes)
|
||||
SendRendererStartupData(render_frame_host);
|
||||
SendRendererStartupData(render_frame_host, GURL(url::kAboutBlankURL),
|
||||
render_frame_host->GetLastCommittedOrigin());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3498,10 +3502,24 @@ void WebContents::MaybeSendRendererStartupData(
|
||||
if (!main_frame && !allow_subframes && !is_devtools_like)
|
||||
return;
|
||||
|
||||
SendRendererStartupData(rfh);
|
||||
// loadURL(dataUrl, {baseURLForDataURL}) commits the data: URL as a document
|
||||
// at the base URL, so match URL filters against that. Same condition as
|
||||
// content::NavigationRequest::IsLoadDataWithBaseURL().
|
||||
const GURL& base_url = navigation_handle->GetBaseURLForDataURL();
|
||||
const bool is_load_data_with_base_url =
|
||||
navigation_handle->IsInPrimaryMainFrame() &&
|
||||
navigation_handle->GetNetErrorCode() == net::OK &&
|
||||
url.SchemeIs(url::kDataScheme) && base_url.is_valid();
|
||||
|
||||
// Has a value whenever the navigation has a RenderFrameHost to commit in.
|
||||
SendRendererStartupData(
|
||||
rfh, is_load_data_with_base_url ? base_url : url,
|
||||
navigation_handle->GetOriginToCommit().value_or(url::Origin()));
|
||||
}
|
||||
|
||||
void WebContents::SendRendererStartupData(content::RenderFrameHost* rfh) {
|
||||
void WebContents::SendRendererStartupData(content::RenderFrameHost* rfh,
|
||||
const GURL& url,
|
||||
const url::Origin& origin) {
|
||||
if (!rfh || !rfh->IsRenderFrameLive())
|
||||
return;
|
||||
|
||||
@@ -3512,7 +3530,7 @@ void WebContents::SendRendererStartupData(content::RenderFrameHost* rfh) {
|
||||
// parked waiting on us here — we haven't sent CommitNavigation yet — so
|
||||
// unlike the old sync IPC handler this can't amplify under contention.
|
||||
ScopedAllowBlockingForElectron allow_blocking;
|
||||
data = renderer_startup_data::BuildForFrame(rfh);
|
||||
data = renderer_startup_data::BuildForFrame(rfh, url, origin);
|
||||
}
|
||||
|
||||
// GetRemoteAssociatedInterfaces() routes over the same channel as
|
||||
|
||||
@@ -696,7 +696,11 @@ class WebContents final : public gin::Wrappable<WebContents>,
|
||||
// CommitNavigation, for documents that will run preloads.
|
||||
void MaybeSendRendererStartupData(
|
||||
content::NavigationHandle* navigation_handle);
|
||||
void SendRendererStartupData(content::RenderFrameHost* rfh);
|
||||
// |url| and |origin| are those of the document the preloads will run in,
|
||||
// matched against each session preload's URL filters.
|
||||
void SendRendererStartupData(content::RenderFrameHost* rfh,
|
||||
const GURL& url,
|
||||
const url::Origin& origin);
|
||||
void DidFinishNavigation(content::NavigationHandle* navigation_handle);
|
||||
void DidReceiveFindReply(int request_id,
|
||||
int number_of_matches,
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
#include "third_party/blink/public/mojom/devtools/console_message.mojom.h"
|
||||
#include "ui/base/resource/resource_bundle.h"
|
||||
#include "ui/native_theme/native_theme.h"
|
||||
#include "url/url_constants.h"
|
||||
#include "v8/include/v8.h"
|
||||
|
||||
#if BUILDFLAG(USE_NSS_CERTS)
|
||||
@@ -805,7 +806,11 @@ ElectronBrowserClient::GetExtraCreateNewWindowReplyData(
|
||||
mojom::RendererStartupDataPtr data;
|
||||
{
|
||||
ScopedAllowBlockingForElectron allow_blocking;
|
||||
data = renderer_startup_data::BuildForFrame(new_window_main_frame);
|
||||
// The popup's initial empty document has already inherited its opener's
|
||||
// origin, if it has one.
|
||||
data = renderer_startup_data::BuildForFrame(
|
||||
new_window_main_frame, GURL(url::kAboutBlankURL),
|
||||
new_window_main_frame->GetLastCommittedOrigin());
|
||||
}
|
||||
// Opaque blob — Chromium can't depend on Electron's mojom types.
|
||||
return mojo_base::BigBuffer(mojom::RendererStartupData::Serialize(&data));
|
||||
@@ -819,14 +824,14 @@ ElectronBrowserClient::GetServiceWorkerStartupData(
|
||||
// when SW preloads are registered. Skip the asar reads + serialization
|
||||
// otherwise — this runs on every service worker start.
|
||||
auto* session_prefs = SessionPreferences::FromBrowserContext(browser_context);
|
||||
if (!session_prefs || !session_prefs->HasServiceWorkerPreloadScript())
|
||||
if (!session_prefs || !session_prefs->HasServiceWorkerPreloadScript(scope))
|
||||
return std::nullopt;
|
||||
|
||||
mojom::RendererStartupDataPtr data;
|
||||
{
|
||||
ScopedAllowBlockingForElectron allow_blocking;
|
||||
data = renderer_startup_data::Build(
|
||||
browser_context, PreloadScript::ScriptType::kServiceWorker);
|
||||
browser_context, PreloadScript::ScriptType::kServiceWorker, scope);
|
||||
}
|
||||
return mojo_base::BigBuffer(mojom::RendererStartupData::Serialize(&data));
|
||||
}
|
||||
|
||||
@@ -4,11 +4,160 @@
|
||||
|
||||
#include "shell/browser/preload_script.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include "base/containers/fixed_flat_map.h"
|
||||
#include "base/files/file_path.h"
|
||||
#include "base/strings/pattern.h"
|
||||
#include "base/uuid.h"
|
||||
#include "shell/common/gin_converters/file_path_converter.h"
|
||||
#include "shell/common/gin_helper/dictionary.h"
|
||||
#include "url/gurl.h"
|
||||
#include "url/origin.h"
|
||||
#include "url/scheme_host_port.h"
|
||||
#include "url/url_constants.h"
|
||||
|
||||
namespace electron {
|
||||
|
||||
namespace {
|
||||
|
||||
bool ParsePatterns(const std::vector<std::string>& patterns,
|
||||
std::vector<URLPattern>* out,
|
||||
std::string* error) {
|
||||
out->clear();
|
||||
for (const std::string& pattern_string : patterns) {
|
||||
// Electron apps commonly serve content from custom protocols, so accept
|
||||
// any scheme rather than the extension-restricted default set.
|
||||
URLPattern pattern(URLPattern::SCHEME_ALL);
|
||||
const URLPattern::ParseResult result = pattern.Parse(pattern_string);
|
||||
if (result != URLPattern::ParseResult::kSuccess) {
|
||||
*error = "Invalid URL pattern '" + pattern_string +
|
||||
"': " + URLPattern::GetParseResultString(result);
|
||||
// URLPattern requires "scheme://host/path" for standard schemes and
|
||||
// rejects it for any other scheme.
|
||||
if (result == URLPattern::ParseResult::kWrongSchemeSeparator) {
|
||||
const bool has_standard_separator =
|
||||
pattern_string.find(url::kStandardSchemeSeparator) !=
|
||||
std::string::npos;
|
||||
*error += has_standard_separator
|
||||
? " A custom scheme must be registered as standard to "
|
||||
"be matched with '://'. Use includeGlobs or "
|
||||
"excludeGlobs otherwise."
|
||||
: " A pattern for a standard scheme must use "
|
||||
"'scheme://host/path'.";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
out->push_back(std::move(pattern));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MatchesAnyPattern(const std::vector<URLPattern>& patterns,
|
||||
const GURL& url) {
|
||||
for (const auto& pattern : patterns) {
|
||||
if (pattern.MatchesURL(url))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MatchesAnyGlob(const std::vector<std::string>& globs, const GURL& url) {
|
||||
for (const auto& glob : globs) {
|
||||
if (base::MatchPattern(url.spec(), glob))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The URL to match filters against for a document at |url| with security
|
||||
// origin |origin| when matching the origin as a fallback. Mirrors
|
||||
// extensions::ContentScriptInjectionUrlGetter::Get() for
|
||||
// MatchOriginAsFallbackBehavior::kAlways.
|
||||
GURL GetOriginFallbackURL(const GURL& url, const url::Origin& origin) {
|
||||
static constexpr std::string_view kSchemesWithoutOwnOrigin[] = {
|
||||
url::kAboutScheme,
|
||||
url::kBlobScheme,
|
||||
url::kDataScheme,
|
||||
url::kFileSystemScheme,
|
||||
};
|
||||
if (!std::ranges::contains(kSchemesWithoutOwnOrigin, url.GetScheme()))
|
||||
return url;
|
||||
// An opaque origin with no precursor has no creator to fall back to, e.g. a
|
||||
// browser-initiated navigation to a data: URL.
|
||||
const url::SchemeHostPort& tuple = origin.GetTupleOrPrecursorTupleIfOpaque();
|
||||
if (!tuple.IsValid())
|
||||
return url;
|
||||
return tuple.GetURL();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PreloadScript::PreloadScript() = default;
|
||||
PreloadScript::PreloadScript(const PreloadScript&) = default;
|
||||
PreloadScript::PreloadScript(PreloadScript&&) = default;
|
||||
PreloadScript& PreloadScript::operator=(const PreloadScript&) = default;
|
||||
PreloadScript& PreloadScript::operator=(PreloadScript&&) = default;
|
||||
PreloadScript::~PreloadScript() = default;
|
||||
|
||||
bool PreloadScript::ParseUrlPatterns(std::string* error) {
|
||||
// Content scripts require at least one match pattern, and webRequest
|
||||
// filters dropped "empty means everything". Keep that unambiguous here.
|
||||
if (matches && matches->empty()) {
|
||||
*error =
|
||||
"Preload script 'matches' must not be empty. Use '<all_urls>' to "
|
||||
"match all URLs.";
|
||||
return false;
|
||||
}
|
||||
// Likewise, an empty include list must not mean every URL.
|
||||
if (include_globs && include_globs->empty()) {
|
||||
*error = "Preload script 'includeGlobs' must not be empty.";
|
||||
return false;
|
||||
}
|
||||
if (!ParsePatterns(matches.value_or(std::vector<std::string>()),
|
||||
&match_patterns_, error) ||
|
||||
!ParsePatterns(exclude_matches, &exclude_match_patterns_, error)) {
|
||||
return false;
|
||||
}
|
||||
// An origin has no path, so a pattern with one could never match a document
|
||||
// by its origin. Same rule as content scripts.
|
||||
if (match_origin_as_fallback) {
|
||||
for (const URLPattern& pattern : match_patterns_) {
|
||||
if (pattern.path() != "/*") {
|
||||
*error = "Invalid URL pattern '" + pattern.GetAsString() +
|
||||
"': the path must be '/*' when matchOriginAsFallback is set";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mirrors extensions::UserScript::MatchesURL() for content scripts.
|
||||
bool PreloadScript::MatchesURL(const GURL& url) const {
|
||||
// Fail closed if ParseUrlPatterns() was skipped: unparsed patterns would
|
||||
// match every URL and drop every exclusion.
|
||||
if (match_patterns_.size() != (matches ? matches->size() : 0u) ||
|
||||
exclude_match_patterns_.size() != exclude_matches.size()) {
|
||||
return false;
|
||||
}
|
||||
if (MatchesAnyPattern(exclude_match_patterns_, url))
|
||||
return false;
|
||||
if (MatchesAnyGlob(exclude_globs, url))
|
||||
return false;
|
||||
return (!matches || MatchesAnyPattern(match_patterns_, url)) &&
|
||||
(!include_globs || MatchesAnyGlob(*include_globs, url));
|
||||
}
|
||||
|
||||
bool PreloadScript::MatchesDocument(const GURL& url,
|
||||
const url::Origin& origin) const {
|
||||
return MatchesURL(match_origin_as_fallback ? GetOriginFallbackURL(url, origin)
|
||||
: url);
|
||||
}
|
||||
|
||||
} // namespace electron
|
||||
|
||||
namespace gin {
|
||||
|
||||
@@ -47,6 +196,16 @@ v8::Local<v8::Value> Converter<PreloadScript>::ToV8(
|
||||
dict.Set("filePath", script.file_path.AsUTF8Unsafe());
|
||||
dict.Set("id", script.id);
|
||||
dict.Set("type", script.script_type);
|
||||
if (script.matches)
|
||||
dict.Set("matches", *script.matches);
|
||||
if (!script.exclude_matches.empty())
|
||||
dict.Set("excludeMatches", script.exclude_matches);
|
||||
if (script.include_globs)
|
||||
dict.Set("includeGlobs", *script.include_globs);
|
||||
if (!script.exclude_globs.empty())
|
||||
dict.Set("excludeGlobs", script.exclude_globs);
|
||||
if (script.match_origin_as_fallback)
|
||||
dict.Set("matchOriginAsFallback", true);
|
||||
return ConvertToV8(isolate, dict).As<v8::Object>();
|
||||
}
|
||||
|
||||
@@ -73,6 +232,31 @@ bool Converter<PreloadScript>::FromV8(v8::Isolate* isolate,
|
||||
} else {
|
||||
out->id = base::Uuid::GenerateRandomV4().AsLowercaseString();
|
||||
}
|
||||
// URL filters are optional, but reject a present value that isn't a
|
||||
// string array rather than silently running the preload everywhere.
|
||||
auto get_filter = [&](std::string_view key, std::vector<std::string>* field) {
|
||||
v8::Local<v8::Value> value;
|
||||
if (!options.Get(key, &value) || value->IsUndefined())
|
||||
return true;
|
||||
return ConvertFromV8(isolate, value, field);
|
||||
};
|
||||
// |matches| and |includeGlobs| also record whether they were given, so
|
||||
// that an explicit empty list can be told apart from an omitted one.
|
||||
auto get_include_filter =
|
||||
[&](std::string_view key,
|
||||
std::optional<std::vector<std::string>>* field) {
|
||||
v8::Local<v8::Value> value;
|
||||
if (!options.Get(key, &value) || value->IsUndefined())
|
||||
return true;
|
||||
return ConvertFromV8(isolate, value, &field->emplace());
|
||||
};
|
||||
if (!get_include_filter("matches", &out->matches) ||
|
||||
!get_filter("excludeMatches", &out->exclude_matches) ||
|
||||
!get_include_filter("includeGlobs", &out->include_globs) ||
|
||||
!get_filter("excludeGlobs", &out->exclude_globs)) {
|
||||
return false;
|
||||
}
|
||||
options.Get("matchOriginAsFallback", &out->match_origin_as_fallback);
|
||||
if (bool deprecated; options.Get("_deprecated", &deprecated)) {
|
||||
out->deprecated = deprecated;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,21 @@
|
||||
#ifndef ELECTRON_SHELL_BROWSER_PRELOAD_SCRIPT_H_
|
||||
#define ELECTRON_SHELL_BROWSER_PRELOAD_SCRIPT_H_
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "base/files/file_path.h"
|
||||
#include "extensions/common/url_pattern.h"
|
||||
#include "gin/converter.h"
|
||||
#include "v8/include/v8-forward.h"
|
||||
|
||||
class GURL;
|
||||
|
||||
namespace url {
|
||||
class Origin;
|
||||
}
|
||||
|
||||
namespace electron {
|
||||
|
||||
struct PreloadScript {
|
||||
@@ -20,8 +29,50 @@ struct PreloadScript {
|
||||
ScriptType script_type;
|
||||
base::FilePath file_path;
|
||||
|
||||
// URL filters modeled after Chromium content scripts. A preload with no
|
||||
// filters runs for every URL. |matches| and |include_globs| are unset when
|
||||
// omitted; an explicit empty list is rejected by ParseUrlPatterns().
|
||||
std::optional<std::vector<std::string>> matches;
|
||||
std::vector<std::string> exclude_matches;
|
||||
std::optional<std::vector<std::string>> include_globs;
|
||||
std::vector<std::string> exclude_globs;
|
||||
|
||||
// Frame preloads only. Matches a document whose URL carries no origin of
|
||||
// its own (about:, blob:, data:, filesystem:) by the origin it inherited
|
||||
// from its creator instead, like "match_origin_as_fallback" for content
|
||||
// scripts. Without it an about:blank popup escapes the opener's filters.
|
||||
bool match_origin_as_fallback = false;
|
||||
|
||||
// If set, use the deprecated validation behavior of Session.setPreloads
|
||||
bool deprecated = false;
|
||||
|
||||
PreloadScript();
|
||||
PreloadScript(const PreloadScript&);
|
||||
PreloadScript(PreloadScript&&);
|
||||
PreloadScript& operator=(const PreloadScript&);
|
||||
PreloadScript& operator=(PreloadScript&&);
|
||||
~PreloadScript();
|
||||
|
||||
// Parses |matches| and |exclude_matches| into URL patterns. Must succeed
|
||||
// before MatchesURL() or MatchesDocument() is used. On failure, |error|
|
||||
// describes the first invalid pattern.
|
||||
bool ParseUrlPatterns(std::string* error);
|
||||
|
||||
// Whether this preload should run for a document (or service worker scope)
|
||||
// at |url|. Uses the same rules as Chromium content scripts: the URL must
|
||||
// match |matches| (if any) and |include_globs| (if any), and must not match
|
||||
// |exclude_matches| or |exclude_globs|.
|
||||
bool MatchesURL(const GURL& url) const;
|
||||
|
||||
// Whether this frame preload should run for a document at |url| with
|
||||
// security origin |origin|. Same as MatchesURL(), except that with
|
||||
// |match_origin_as_fallback| the filters are matched against the origin (or
|
||||
// its precursor if opaque) when |url| has no origin of its own.
|
||||
bool MatchesDocument(const GURL& url, const url::Origin& origin) const;
|
||||
|
||||
private:
|
||||
std::vector<URLPattern> match_patterns_;
|
||||
std::vector<URLPattern> exclude_match_patterns_;
|
||||
};
|
||||
|
||||
} // namespace electron
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
#include "shell/browser/web_contents_preferences.h"
|
||||
#include "shell/common/asar/asar_util.h"
|
||||
#include "shell/common/node_includes.h"
|
||||
#include "url/gurl.h"
|
||||
#include "url/origin.h"
|
||||
|
||||
namespace electron::renderer_startup_data {
|
||||
|
||||
@@ -108,9 +110,13 @@ mojom::PreloadScriptDataPtr ReadPreloadScript(
|
||||
}
|
||||
|
||||
// Session-registered preloads of |type| plus the env/execPath snapshot.
|
||||
// |origin| is the security origin of the frame document at |url|, or null for
|
||||
// a service worker scope.
|
||||
mojom::RendererStartupDataPtr BuildInternal(
|
||||
content::BrowserContext* browser_context,
|
||||
PreloadScript::ScriptType type,
|
||||
const GURL& url,
|
||||
const url::Origin* origin,
|
||||
const preload_code_cache::Scope& scope,
|
||||
std::vector<preload_code_cache::ServedPreload>* served,
|
||||
bool sandboxed) {
|
||||
@@ -123,6 +129,10 @@ mojom::RendererStartupDataPtr BuildInternal(
|
||||
continue;
|
||||
if (!script.file_path.IsAbsolute())
|
||||
continue;
|
||||
if (!(origin ? script.MatchesDocument(url, *origin)
|
||||
: script.MatchesURL(url))) {
|
||||
continue;
|
||||
}
|
||||
data->preload_scripts.push_back(ReadPreloadScript(
|
||||
script.id, script.file_path, scope, served, sandboxed));
|
||||
}
|
||||
@@ -140,20 +150,24 @@ mojom::RendererStartupDataPtr BuildInternal(
|
||||
} // namespace
|
||||
|
||||
mojom::RendererStartupDataPtr Build(content::BrowserContext* browser_context,
|
||||
PreloadScript::ScriptType type) {
|
||||
return BuildInternal(browser_context, type, preload_code_cache::Scope(),
|
||||
nullptr, /*sandboxed=*/true);
|
||||
PreloadScript::ScriptType type,
|
||||
const GURL& url) {
|
||||
return BuildInternal(browser_context, type, url, /*origin=*/nullptr,
|
||||
preload_code_cache::Scope(), nullptr,
|
||||
/*sandboxed=*/true);
|
||||
}
|
||||
|
||||
mojom::RendererStartupDataPtr BuildForFrame(content::RenderFrameHost* rfh) {
|
||||
mojom::RendererStartupDataPtr BuildForFrame(content::RenderFrameHost* rfh,
|
||||
const GURL& url,
|
||||
const url::Origin& origin) {
|
||||
const preload_code_cache::Scope scope =
|
||||
preload_code_cache::ScopeForFrame(rfh);
|
||||
auto* web_contents = content::WebContents::FromRenderFrameHost(rfh);
|
||||
const bool sandboxed = IsFrameInSandboxedRenderer(rfh);
|
||||
std::vector<preload_code_cache::ServedPreload> served;
|
||||
auto data = BuildInternal(rfh->GetBrowserContext(),
|
||||
PreloadScript::ScriptType::kWebFrame, scope,
|
||||
sandboxed ? &served : nullptr, sandboxed);
|
||||
PreloadScript::ScriptType::kWebFrame, url, &origin,
|
||||
scope, sandboxed ? &served : nullptr, sandboxed);
|
||||
|
||||
// The per-WebContents webPreferences.preload runs last. May be absent for a
|
||||
// WebContents that never went through a BrowserWindow/webContents
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
#include "shell/browser/preload_script.h"
|
||||
#include "shell/common/api/api.mojom.h"
|
||||
|
||||
class GURL;
|
||||
|
||||
namespace url {
|
||||
class Origin;
|
||||
}
|
||||
|
||||
namespace content {
|
||||
class BrowserContext;
|
||||
class RenderFrameHost;
|
||||
@@ -16,24 +22,30 @@ class RenderFrameHost;
|
||||
namespace electron::renderer_startup_data {
|
||||
|
||||
// Builds a RendererStartupData for the given session containing the registered
|
||||
// preload scripts of |type| (kServiceWorker for the GetServiceWorkerStartupData
|
||||
// blob), the browser env snapshot, and process.helperExecPath. No preload code
|
||||
// cache is attached — that only exists for frame realms. Reads preload
|
||||
// contents synchronously — call behind a ScopedAllowBlockingForElectron.
|
||||
// preload scripts of |type| whose URL filters match |url| (kServiceWorker and
|
||||
// the worker's scope for the GetServiceWorkerStartupData blob), the browser env
|
||||
// snapshot, and process.helperExecPath. No preload code cache is attached —
|
||||
// that only exists for frame realms. Reads preload contents synchronously —
|
||||
// call behind a ScopedAllowBlockingForElectron.
|
||||
mojom::RendererStartupDataPtr Build(content::BrowserContext* browser_context,
|
||||
PreloadScript::ScriptType type);
|
||||
PreloadScript::ScriptType type,
|
||||
const GURL& url);
|
||||
|
||||
// Builds the RendererStartupData pushed to a frame: session-registered
|
||||
// kWebFrame preloads (in registration order) followed by the per-WebContents
|
||||
// webPreferences.preload — the same order as the legacy BROWSER_SANDBOX_LOAD
|
||||
// handler's getPreloadScriptsFromEvent(). Attaches any preload code cache
|
||||
// previously produced for |rfh|'s site, and records the served (id, hash)
|
||||
// tuples with preload_code_cache so SetPreloadCodeCache writes from this
|
||||
// frame can be validated against exactly what was served. Reads preload
|
||||
// contents synchronously — call behind a ScopedAllowBlockingForElectron.
|
||||
// Builds the RendererStartupData pushed to a frame for a document at |url|
|
||||
// with security origin |origin|: session-registered kWebFrame preloads whose
|
||||
// URL filters match the document (in registration order) followed by the
|
||||
// per-WebContents webPreferences.preload — the same order as the legacy
|
||||
// BROWSER_SANDBOX_LOAD handler's getPreloadScriptsFromEvent(). Attaches any
|
||||
// preload code cache previously produced for |rfh|'s site, and records the
|
||||
// served (id, hash) tuples with preload_code_cache so SetPreloadCodeCache
|
||||
// writes from this frame can be validated against exactly what was served.
|
||||
// Reads preload contents synchronously — call behind a
|
||||
// ScopedAllowBlockingForElectron.
|
||||
// For a frame without the sandbox (Node.js integration) only the preload
|
||||
// paths are filled in: that renderer loads the files itself.
|
||||
mojom::RendererStartupDataPtr BuildForFrame(content::RenderFrameHost* rfh);
|
||||
mojom::RendererStartupDataPtr BuildForFrame(content::RenderFrameHost* rfh,
|
||||
const GURL& url,
|
||||
const url::Origin& origin);
|
||||
|
||||
// Whether |rfh| lives in a sandboxed renderer process (sandbox bundle) rather
|
||||
// than one with Node.js integration.
|
||||
|
||||
@@ -30,11 +30,13 @@ SessionPreferences* SessionPreferences::FromBrowserContext(
|
||||
return static_cast<SessionPreferences*>(context->GetUserData(&kLocatorKey));
|
||||
}
|
||||
|
||||
bool SessionPreferences::HasServiceWorkerPreloadScript() {
|
||||
bool SessionPreferences::HasServiceWorkerPreloadScript(const GURL& scope) {
|
||||
const auto& preloads = preload_scripts();
|
||||
auto it = std::find_if(
|
||||
preloads.begin(), preloads.end(), [](const PreloadScript& script) {
|
||||
return script.script_type == PreloadScript::ScriptType::kServiceWorker;
|
||||
preloads.begin(), preloads.end(), [&scope](const PreloadScript& script) {
|
||||
return script.script_type ==
|
||||
PreloadScript::ScriptType::kServiceWorker &&
|
||||
script.MatchesURL(scope);
|
||||
});
|
||||
return it != preloads.end();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#include "base/supports_user_data.h"
|
||||
#include "shell/browser/preload_script.h"
|
||||
|
||||
class GURL;
|
||||
|
||||
namespace content {
|
||||
class BrowserContext;
|
||||
}
|
||||
@@ -27,7 +29,8 @@ class SessionPreferences : public base::SupportsUserData::Data {
|
||||
|
||||
std::vector<PreloadScript>& preload_scripts() { return preload_scripts_; }
|
||||
|
||||
bool HasServiceWorkerPreloadScript();
|
||||
// Whether any service worker preload's URL filters match |scope|.
|
||||
bool HasServiceWorkerPreloadScript(const GURL& scope);
|
||||
|
||||
private:
|
||||
SessionPreferences();
|
||||
|
||||
@@ -4388,7 +4388,278 @@ describe('BrowserWindow module', () => {
|
||||
await waitUntil(() => locations.length > 0);
|
||||
expect(locations).toEqual(['about:blank']);
|
||||
});
|
||||
|
||||
it(`only runs session preloads whose URL filters match the document (sandbox: ${sandbox})`, async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
response.end('<html></html>');
|
||||
});
|
||||
defer(() => server.close());
|
||||
const { url } = await listen(server);
|
||||
const ses = session.fromPartition(`preload-url-filters-${sandbox}`);
|
||||
const preload = path.join(fixtures, 'module', 'preload-location.js');
|
||||
const ids = [
|
||||
ses.registerPreloadScript({ type: 'frame', filePath: preload, matches: [`${url}/allowed/*`] }),
|
||||
ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: preload,
|
||||
matches: [`${url}/*`],
|
||||
excludeMatches: [`${url}/allowed/*`],
|
||||
includeGlobs: ['*included=1']
|
||||
}),
|
||||
ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: preload,
|
||||
excludeGlobs: ['*/allowed/*', '*included=1']
|
||||
})
|
||||
];
|
||||
defer(() => ids.forEach((id) => ses.unregisterPreloadScript(id)));
|
||||
const locations: string[] = [];
|
||||
ipcMain.on('preload-location', (_e, href: string) => locations.push(href));
|
||||
defer(() => ipcMain.removeAllListeners('preload-location'));
|
||||
const w = new BrowserWindow({ show: false, webPreferences: { sandbox, session: ses } });
|
||||
await w.loadURL(`${url}/allowed/page`);
|
||||
await w.loadURL(`${url}/other?included=1`);
|
||||
await w.loadURL(`${url}/other`);
|
||||
expect(locations).toEqual([`${url}/allowed/page`, `${url}/other?included=1`, `${url}/other`]);
|
||||
});
|
||||
|
||||
it(`runs a session preload matching <all_urls> on any document (sandbox: ${sandbox})`, async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
response.end('<html></html>');
|
||||
});
|
||||
defer(() => server.close());
|
||||
const { url } = await listen(server);
|
||||
const ses = session.fromPartition(`preload-all-urls-${sandbox}`);
|
||||
const id = ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: path.join(fixtures, 'module', 'preload-location.js'),
|
||||
matches: ['<all_urls>']
|
||||
});
|
||||
defer(() => ses.unregisterPreloadScript(id));
|
||||
const locations: string[] = [];
|
||||
ipcMain.on('preload-location', (_e, href: string) => locations.push(href));
|
||||
defer(() => ipcMain.removeAllListeners('preload-location'));
|
||||
const w = new BrowserWindow({ show: false, webPreferences: { sandbox, session: ses } });
|
||||
await w.loadURL(`${url}/page`);
|
||||
await w.loadURL('about:blank');
|
||||
expect(locations).toEqual([`${url}/page`, 'about:blank']);
|
||||
});
|
||||
|
||||
it(`filters session preloads by chrome-extension:// URL (sandbox: ${sandbox})`, async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
response.end('<html></html>');
|
||||
});
|
||||
defer(() => server.close());
|
||||
const { url } = await listen(server);
|
||||
// Extensions can only be loaded into a persistent session.
|
||||
const ses = session.fromPartition(`persist:preload-extension-filters-${crypto.randomUUID()}`);
|
||||
const extension = await ses.extensions.loadExtension(path.join(fixtures, 'extensions', 'ui-page'));
|
||||
defer(() => ses.extensions.removeExtension(extension.id));
|
||||
const preload = path.join(fixtures, 'module', 'preload-location.js');
|
||||
const ids = [
|
||||
ses.registerPreloadScript({ type: 'frame', filePath: preload, matches: ['chrome-extension://*/*'] }),
|
||||
ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: preload,
|
||||
excludeMatches: ['chrome-extension://*/*']
|
||||
})
|
||||
];
|
||||
defer(() => ids.forEach((id) => ses.unregisterPreloadScript(id)));
|
||||
const locations: string[] = [];
|
||||
ipcMain.on('preload-location', (_e, href: string) => locations.push(href));
|
||||
defer(() => ipcMain.removeAllListeners('preload-location'));
|
||||
const w = new BrowserWindow({ show: false, webPreferences: { sandbox, session: ses } });
|
||||
const extensionPage = `${extension.url}bare-page.html`;
|
||||
await w.loadURL(extensionPage);
|
||||
await w.loadURL(`${url}/page`);
|
||||
// Each page runs exactly one of the two preloads.
|
||||
expect(locations).toEqual([extensionPage, `${url}/page`]);
|
||||
});
|
||||
|
||||
it(`matches a popup's initial empty document by its opener's origin with matchOriginAsFallback (sandbox: ${sandbox})`, async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
response.end('<html></html>');
|
||||
});
|
||||
defer(() => server.close());
|
||||
const { url } = await listen(server);
|
||||
// Preload A carries the filters under test. Preload B is unfiltered
|
||||
// and registered after it, so once B reports from a document, A has
|
||||
// already reported from that document if it ran there.
|
||||
const preloadA = path.join(fixtures, 'module', 'preload-window-open-a.js');
|
||||
const preloadB = path.join(fixtures, 'module', 'preload-window-open-b.js');
|
||||
let partitions = 0;
|
||||
const collectFilteredPreloads = async (filters: Partial<Electron.PreloadScriptRegistration>) => {
|
||||
const ses = session.fromPartition(`preload-origin-fallback-${sandbox}-${partitions++}`);
|
||||
ses.registerPreloadScript({ type: 'frame', filePath: preloadA, ...filters });
|
||||
ses.registerPreloadScript({ type: 'frame', filePath: preloadB });
|
||||
const reports: Array<{ which: string; href: string }> = [];
|
||||
const handler = (_e: any, which: string, href: string) => reports.push({ which, href });
|
||||
ipcMain.on('preload-ran', handler);
|
||||
try {
|
||||
const w = new BrowserWindow({ show: false, webPreferences: { sandbox, session: ses } });
|
||||
await w.loadURL(`${url}/page`);
|
||||
await w.webContents.executeJavaScript('void window.open()');
|
||||
await waitUntil(() => reports.some((r) => r.which === 'b' && r.href === 'about:blank'));
|
||||
} finally {
|
||||
ipcMain.removeListener('preload-ran', handler);
|
||||
}
|
||||
return reports.filter((r) => r.which === 'a').map((r) => r.href);
|
||||
};
|
||||
// By default the popup is matched as about:blank, whatever opened it...
|
||||
expect(await collectFilteredPreloads({ excludeMatches: [`${url}/*`] })).toEqual(['about:blank']);
|
||||
expect(await collectFilteredPreloads({ matches: [`${url}/*`] })).toEqual([`${url}/page`]);
|
||||
// ...and with matchOriginAsFallback as the origin it inherited.
|
||||
expect(
|
||||
await collectFilteredPreloads({ excludeMatches: [`${url}/*`], matchOriginAsFallback: true })
|
||||
).toEqual([]);
|
||||
expect(await collectFilteredPreloads({ matches: [`${url}/*`], matchOriginAsFallback: true })).toEqual([
|
||||
`${url}/page`,
|
||||
'about:blank'
|
||||
]);
|
||||
});
|
||||
|
||||
it(`matches a data: URL loaded with baseURLForDataURL by its base URL (sandbox: ${sandbox})`, async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
response.end('<html></html>');
|
||||
});
|
||||
defer(() => server.close());
|
||||
const { url } = await listen(server);
|
||||
const ses = session.fromPartition(`preload-base-url-filters-${sandbox}`);
|
||||
const ids = [
|
||||
ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: path.join(fixtures, 'module', 'preload-window-open-a.js'),
|
||||
matches: [`${url}/*`]
|
||||
}),
|
||||
ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: path.join(fixtures, 'module', 'preload-window-open-b.js'),
|
||||
matches: ['data:*']
|
||||
})
|
||||
];
|
||||
defer(() => ids.forEach((id) => ses.unregisterPreloadScript(id)));
|
||||
const reports: Array<{ which: string; href: string }> = [];
|
||||
ipcMain.on('preload-ran', (_e, which: string, href: string) => reports.push({ which, href }));
|
||||
defer(() => ipcMain.removeAllListeners('preload-ran'));
|
||||
const w = new BrowserWindow({ show: false, webPreferences: { sandbox, session: ses } });
|
||||
// The document commits at the base URL, so only preload A matches.
|
||||
await w.loadURL('data:text/html,<html></html>', { baseURLForDataURL: `${url}/app/` });
|
||||
expect(reports).toEqual([{ which: 'a', href: `${url}/app/` }]);
|
||||
});
|
||||
|
||||
it(`matches each frame by its own URL (sandbox: ${sandbox})`, async () => {
|
||||
const server = http.createServer((request, response) => {
|
||||
const child = new URL(request.url!, `http://${request.headers.host}`).searchParams.get('child');
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
response.end(child ? `<iframe src="${child}"></iframe>` : '<html></html>');
|
||||
});
|
||||
defer(() => server.close());
|
||||
const { url } = await listen(server);
|
||||
const ses = session.fromPartition(`preload-subframe-filters-${sandbox}`);
|
||||
const id = ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: path.join(fixtures, 'module', 'preload-location.js'),
|
||||
matches: [`${url}/allowed/*`]
|
||||
});
|
||||
defer(() => ses.unregisterPreloadScript(id));
|
||||
const locations: string[] = [];
|
||||
ipcMain.on('preload-location', (_e, href: string) => locations.push(href));
|
||||
defer(() => ipcMain.removeAllListeners('preload-location'));
|
||||
const w = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { sandbox, session: ses, nodeIntegrationInSubFrames: true }
|
||||
});
|
||||
// A matching frame in a non-matching page, then the reverse.
|
||||
await w.loadURL(`${url}/other/page?child=/allowed/frame`);
|
||||
await w.loadURL(`${url}/allowed/page?child=/other/frame`);
|
||||
expect(locations).toEqual([`${url}/allowed/frame`, `${url}/allowed/page?child=/other/frame`]);
|
||||
});
|
||||
|
||||
it(`matches the final URL of a redirected navigation (sandbox: ${sandbox})`, async () => {
|
||||
const server = http.createServer((request, response) => {
|
||||
const to = new URL(request.url!, `http://${request.headers.host}`).searchParams.get('to');
|
||||
if (to) {
|
||||
response.writeHead(302, { Location: to });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
response.end('<html></html>');
|
||||
});
|
||||
defer(() => server.close());
|
||||
const { url } = await listen(server);
|
||||
const ses = session.fromPartition(`preload-redirect-filters-${sandbox}`);
|
||||
const id = ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: path.join(fixtures, 'module', 'preload-location.js'),
|
||||
matches: [`${url}/allowed/*`]
|
||||
});
|
||||
defer(() => ses.unregisterPreloadScript(id));
|
||||
const locations: string[] = [];
|
||||
ipcMain.on('preload-location', (_e, href: string) => locations.push(href));
|
||||
defer(() => ipcMain.removeAllListeners('preload-location'));
|
||||
const w = new BrowserWindow({ show: false, webPreferences: { sandbox, session: ses } });
|
||||
await w.loadURL(`${url}/allowed/redirect?to=/other/landing`);
|
||||
await w.loadURL(`${url}/other/redirect?to=/allowed/landing`);
|
||||
expect(locations).toEqual([`${url}/allowed/landing`]);
|
||||
});
|
||||
}
|
||||
|
||||
it('rejects invalid preload URL filters', () => {
|
||||
const ses = session.fromPartition('preload-url-filters-invalid');
|
||||
const filePath = path.join(fixtures, 'module', 'empty.js');
|
||||
expect(() => ses.registerPreloadScript({ type: 'frame', filePath, matches: ['not a pattern'] })).toThrow(
|
||||
/Invalid URL pattern 'not a pattern'/
|
||||
);
|
||||
expect(() => ses.registerPreloadScript({ type: 'frame', filePath, excludeMatches: ['https://'] })).toThrow(
|
||||
/Invalid URL pattern/
|
||||
);
|
||||
// 'cors' is registered as a non-standard scheme in spec/index.js.
|
||||
expect(() => ses.registerPreloadScript({ type: 'frame', filePath, matches: ['cors://host/*'] })).toThrow(
|
||||
/must be registered as standard/
|
||||
);
|
||||
expect(() => ses.registerPreloadScript({ type: 'frame', filePath, matches: ['https:*'] })).toThrow(
|
||||
/a standard scheme must use 'scheme:\/\/host\/path'/
|
||||
);
|
||||
expect(() => ses.registerPreloadScript({ type: 'frame', filePath, matches: [] })).toThrow(
|
||||
/'matches' must not be empty/
|
||||
);
|
||||
expect(() => ses.registerPreloadScript({ type: 'frame', filePath, includeGlobs: [] })).toThrow(
|
||||
/'includeGlobs' must not be empty/
|
||||
);
|
||||
expect(() =>
|
||||
ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath,
|
||||
matches: ['https://example.com/path/*'],
|
||||
matchOriginAsFallback: true
|
||||
})
|
||||
).toThrow(/the path must be '\/\*' when matchOriginAsFallback is set/);
|
||||
expect(ses.getPreloadScripts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns URL filters from getPreloadScripts', () => {
|
||||
const ses = session.fromPartition('preload-url-filters-get');
|
||||
const filters = {
|
||||
matches: ['https://example.com/*'],
|
||||
excludeMatches: ['https://example.com/private/*'],
|
||||
includeGlobs: ['*foo*'],
|
||||
excludeGlobs: ['*bar*'],
|
||||
matchOriginAsFallback: true
|
||||
};
|
||||
const id = ses.registerPreloadScript({
|
||||
type: 'frame',
|
||||
filePath: path.join(fixtures, 'module', 'empty.js'),
|
||||
...filters
|
||||
});
|
||||
defer(() => ses.unregisterPreloadScript(id));
|
||||
expect(ses.getPreloadScripts().find((script) => script.id === id)).toMatchObject(filters);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak any node globals on the window object with nodeIntegration is disabled', async () => {
|
||||
|
||||
@@ -448,6 +448,41 @@ describe('ServiceWorkerMain module', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('preload URL filters', () => {
|
||||
function registerFilteredPreload(scriptName: string, filters: Partial<Electron.PreloadScriptRegistration>) {
|
||||
ses.registerPreloadScript({
|
||||
type: 'service-worker',
|
||||
filePath: path.resolve(preloadRealmFixtures, scriptName),
|
||||
...filters
|
||||
});
|
||||
}
|
||||
|
||||
it('runs a preload whose filters match the worker scope', async () => {
|
||||
registerFilteredPreload('preload-send-ping.js', { matches: [`${baseUrl}/*`] });
|
||||
loadWorkerScript();
|
||||
const serviceWorker = await waitForServiceWorker();
|
||||
await once(serviceWorker.ipc, 'ping');
|
||||
});
|
||||
|
||||
it('skips a preload whose filters do not match the worker scope', async () => {
|
||||
registerFilteredPreload('preload-send-ping.js', { excludeMatches: [`${baseUrl}/*`] });
|
||||
registerPreload('preload-tests.js');
|
||||
let pingReceived = false;
|
||||
serviceWorkers.on('running-status-changed', ({ versionId, runningStatus }) => {
|
||||
if (runningStatus !== 'starting') return;
|
||||
serviceWorkers.getWorkerFromVersionID(versionId)?.ipc.on('ping', () => {
|
||||
pingReceived = true;
|
||||
});
|
||||
});
|
||||
loadWorkerScript();
|
||||
const serviceWorker = await waitForServiceWorker('running');
|
||||
const pongPromise = once(serviceWorker.ipc, 'pong');
|
||||
runTest(serviceWorker, { name: 'testSend', args: ['pong'] });
|
||||
await pongPromise;
|
||||
expect(pingReceived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextBridge', () => {
|
||||
beforeEach(() => {
|
||||
registerPreload('preload-tests.js');
|
||||
@@ -499,5 +534,40 @@ describe('ServiceWorkerMain module', () => {
|
||||
expect(result.id).toBe(extension.id);
|
||||
expect(result.manifest).toEqual(result.manifest);
|
||||
});
|
||||
|
||||
it('runs a preload filtered to chrome-extension:// scopes', async () => {
|
||||
ses.registerPreloadScript({
|
||||
type: 'service-worker',
|
||||
filePath: path.resolve(preloadRealmFixtures, 'preload-send-ping.js'),
|
||||
matches: ['chrome-extension://*/*']
|
||||
});
|
||||
const serviceWorkerPromise = waitForServiceWorker();
|
||||
ses.extensions.loadExtension(testExtensionFixture);
|
||||
const serviceWorker = await serviceWorkerPromise;
|
||||
await once(serviceWorker.ipc, 'ping');
|
||||
});
|
||||
|
||||
it('skips a preload that excludes chrome-extension:// scopes', async () => {
|
||||
ses.registerPreloadScript({
|
||||
type: 'service-worker',
|
||||
filePath: path.resolve(preloadRealmFixtures, 'preload-send-ping.js'),
|
||||
excludeMatches: ['chrome-extension://*/*']
|
||||
});
|
||||
registerPreload('preload-tests.js');
|
||||
let pingReceived = false;
|
||||
serviceWorkers.on('running-status-changed', ({ versionId, runningStatus }) => {
|
||||
if (runningStatus !== 'starting') return;
|
||||
serviceWorkers.getWorkerFromVersionID(versionId)?.ipc.on('ping', () => {
|
||||
pingReceived = true;
|
||||
});
|
||||
});
|
||||
ses.extensions.loadExtension(testExtensionFixture);
|
||||
const serviceWorker = await waitForServiceWorker('running');
|
||||
expect(serviceWorker.scope).toMatch(/^chrome-extension:\/\//);
|
||||
const pongPromise = once(serviceWorker.ipc, 'pong');
|
||||
runTest(serviceWorker, { name: 'testSend', args: ['pong'] });
|
||||
await pongPromise;
|
||||
expect(pingReceived).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user