diff --git a/docs/api/extensions.md b/docs/api/extensions.md index 91560a2a33..afaff0ef63 100644 --- a/docs/api/extensions.md +++ b/docs/api/extensions.md @@ -100,6 +100,8 @@ The following methods of `chrome.tabs` are supported: - `chrome.tabs.sendMessage` - `chrome.tabs.executeScript` +- `chrome.tabs.update` (partial support) + - supported properties: `url`, `muted`. > **Note:** In Chrome, passing `-1` as a tab ID signifies the "currently active > tab". Since Electron has no such concept, passing `-1` as a tab ID is not diff --git a/shell/browser/extensions/api/tabs/tabs_api.cc b/shell/browser/extensions/api/tabs/tabs_api.cc index e84c4e7cb7..9829b4454a 100644 --- a/shell/browser/extensions/api/tabs/tabs_api.cc +++ b/shell/browser/extensions/api/tabs/tabs_api.cc @@ -7,6 +7,9 @@ #include #include +#include "chrome/common/url_constants.h" +#include "components/url_formatter/url_fixer.h" +#include "content/public/browser/navigation_entry.h" #include "extensions/browser/extension_api_frame_id_map.h" #include "extensions/common/error_utils.h" #include "extensions/common/manifest_constants.h" @@ -319,4 +322,182 @@ ExtensionFunction::ResponseAction TabsSetZoomSettingsFunction::Run() { return RespondNow(NoArguments()); } +bool IsKillURL(const GURL& url) { +#if DCHECK_IS_ON() + // Caller should ensure that |url| is already "fixed up" by + // url_formatter::FixupURL, which (among many other things) takes care + // of rewriting about:kill into chrome://kill/. + if (url.SchemeIs(url::kAboutScheme)) + DCHECK(url.IsAboutBlank() || url.IsAboutSrcdoc()); +#endif + + static const char* const kill_hosts[] = { + chrome::kChromeUICrashHost, chrome::kChromeUIDelayedHangUIHost, + chrome::kChromeUIHangUIHost, chrome::kChromeUIKillHost, + chrome::kChromeUIQuitHost, chrome::kChromeUIRestartHost, + content::kChromeUIBrowserCrashHost, content::kChromeUIMemoryExhaustHost, + }; + + if (!url.SchemeIs(content::kChromeUIScheme)) + return false; + + return base::Contains(kill_hosts, url.host_piece()); +} + +GURL ResolvePossiblyRelativeURL(const std::string& url_string, + const Extension* extension) { + GURL url = GURL(url_string); + if (!url.is_valid() && extension) + url = extension->GetResourceURL(url_string); + + return url; +} +bool PrepareURLForNavigation(const std::string& url_string, + const Extension* extension, + GURL* return_url, + std::string* error) { + GURL url = ResolvePossiblyRelativeURL(url_string, extension); + + // Ideally, the URL would only be "fixed" for user input (e.g. for URLs + // entered into the Omnibox), but some extensions rely on the legacy behavior + // where all navigations were subject to the "fixing". See also + // https://crbug.com/1145381. + url = url_formatter::FixupURL(url.spec(), "" /* = desired_tld */); + + // Reject invalid URLs. + if (!url.is_valid()) { + const char kInvalidUrlError[] = "Invalid url: \"*\"."; + *error = ErrorUtils::FormatErrorMessage(kInvalidUrlError, url_string); + return false; + } + + // Don't let the extension crash the browser or renderers. + if (IsKillURL(url)) { + const char kNoCrashBrowserError[] = + "I'm sorry. I'm afraid I can't do that."; + *error = kNoCrashBrowserError; + return false; + } + + // Don't let the extension navigate directly to devtools scheme pages, unless + // they have applicable permissions. + if (url.SchemeIs(content::kChromeDevToolsScheme) && + !(extension->permissions_data()->HasAPIPermission( + extensions::mojom::APIPermissionID::kDevtools) || + extension->permissions_data()->HasAPIPermission( + extensions::mojom::APIPermissionID::kDebugger))) { + const char kCannotNavigateToDevtools[] = + "Cannot navigate to a devtools:// page without either the devtools or " + "debugger permission."; + *error = kCannotNavigateToDevtools; + return false; + } + + return_url->Swap(&url); + return true; +} + +TabsUpdateFunction::TabsUpdateFunction() : web_contents_(nullptr) {} + +ExtensionFunction::ResponseAction TabsUpdateFunction::Run() { + std::unique_ptr params( + tabs::Update::Params::Create(*args_)); + EXTENSION_FUNCTION_VALIDATE(params.get()); + + int tab_id = params->tab_id ? *params->tab_id : -1; + auto* contents = electron::api::WebContents::FromID(tab_id); + if (!contents) + return RespondNow(Error("No such tab")); + + web_contents_ = contents->web_contents(); + + // Navigate the tab to a new location if the url is different. + std::string error; + if (params->update_properties.url.get()) { + std::string updated_url = *params->update_properties.url; + if (!UpdateURL(updated_url, tab_id, &error)) + return RespondNow(Error(std::move(error))); + } + + if (params->update_properties.muted.get()) { + contents->SetAudioMuted(*params->update_properties.muted); + } + + return RespondNow(GetResult()); +} + +bool TabsUpdateFunction::UpdateURL(const std::string& url_string, + int tab_id, + std::string* error) { + GURL url; + if (!PrepareURLForNavigation(url_string, extension(), &url, error)) { + return false; + } + + const bool is_javascript_scheme = url.SchemeIs(url::kJavaScriptScheme); + // JavaScript URLs are forbidden in chrome.tabs.update(). + if (is_javascript_scheme) { + const char kJavaScriptUrlsNotAllowedInTabsUpdate[] = + "JavaScript URLs are not allowed in chrome.tabs.update. Use " + "chrome.tabs.executeScript instead."; + *error = kJavaScriptUrlsNotAllowedInTabsUpdate; + return false; + } + + content::NavigationController::LoadURLParams load_params(url); + + // Treat extension-initiated navigations as renderer-initiated so that the URL + // does not show in the omnibox until it commits. This avoids URL spoofs + // since URLs can be opened on behalf of untrusted content. + load_params.is_renderer_initiated = true; + // All renderer-initiated navigations need to have an initiator origin. + load_params.initiator_origin = extension()->origin(); + // |source_site_instance| needs to be set so that a renderer process + // compatible with |initiator_origin| is picked by Site Isolation. + load_params.source_site_instance = content::SiteInstance::CreateForURL( + web_contents_->GetBrowserContext(), + load_params.initiator_origin->GetURL()); + + // Marking the navigation as initiated via an API means that the focus + // will stay in the omnibox - see https://crbug.com/1085779. + load_params.transition_type = ui::PAGE_TRANSITION_FROM_API; + + web_contents_->GetController().LoadURLWithParams(load_params); + + DCHECK_EQ(url, + web_contents_->GetController().GetPendingEntry()->GetVirtualURL()); + + return true; +} + +ExtensionFunction::ResponseValue TabsUpdateFunction::GetResult() { + if (!has_callback()) + return NoArguments(); + + tabs::Tab tab; + + auto* api_web_contents = electron::api::WebContents::From(web_contents_); + tab.id = + std::make_unique(api_web_contents ? api_web_contents->ID() : -1); + // TODO(nornagon): in Chrome, the tab URL is only available to extensions + // that have the "tabs" (or "activeTab") permission. We should do the same + // permission check here. + tab.url = std::make_unique( + web_contents_->GetLastCommittedURL().spec()); + + return ArgumentList(tabs::Get::Results::Create(std::move(tab))); +} + +void TabsUpdateFunction::OnExecuteCodeFinished( + const std::string& error, + const GURL& url, + const base::ListValue& script_result) { + if (!error.empty()) { + Respond(Error(error)); + return; + } + + return Respond(GetResult()); +} + } // namespace extensions diff --git a/shell/browser/extensions/api/tabs/tabs_api.h b/shell/browser/extensions/api/tabs/tabs_api.h index fe3261fcfd..6a41748db1 100644 --- a/shell/browser/extensions/api/tabs/tabs_api.h +++ b/shell/browser/extensions/api/tabs/tabs_api.h @@ -88,6 +88,25 @@ class TabsGetZoomSettingsFunction : public ExtensionFunction { DECLARE_EXTENSION_FUNCTION("tabs.getZoomSettings", TABS_GETZOOMSETTINGS) }; +class TabsUpdateFunction : public ExtensionFunction { + public: + TabsUpdateFunction(); + + protected: + ~TabsUpdateFunction() override {} + bool UpdateURL(const std::string& url, int tab_id, std::string* error); + ResponseValue GetResult(); + + content::WebContents* web_contents_; + + private: + ResponseAction Run() override; + void OnExecuteCodeFinished(const std::string& error, + const GURL& on_url, + const base::ListValue& script_result); + + DECLARE_EXTENSION_FUNCTION("tabs.update", TABS_UPDATE) +}; } // namespace extensions #endif // SHELL_BROWSER_EXTENSIONS_API_TABS_TABS_API_H_ diff --git a/shell/common/extensions/api/tabs.json b/shell/common/extensions/api/tabs.json index 562cbff34b..36276d2f40 100644 --- a/shell/common/extensions/api/tabs.json +++ b/shell/common/extensions/api/tabs.json @@ -352,6 +352,80 @@ ] } ] + }, + { + "name": "update", + "type": "function", + "description": "Modifies the properties of a tab. Properties that are not specified in updateProperties are not modified.", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "Defaults to the selected tab of the current window." + }, + { + "type": "object", + "name": "updateProperties", + "properties": { + "url": { + "type": "string", + "optional": true, + "description": "A URL to navigate the tab to. JavaScript URLs are not supported; use $(ref:scripting.executeScript) instead." + }, + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be active. Does not affect whether the window is focused (see $(ref:windows.update))." + }, + "highlighted": { + "type": "boolean", + "optional": true, + "description": "Adds or removes the tab from the current selection." + }, + "selected": { + "deprecated": "Please use highlighted.", + "type": "boolean", + "optional": true, + "description": "Whether the tab should be selected." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be pinned." + }, + "muted": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be muted." + }, + "openerTabId": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab." + }, + "autoDiscardable": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be discarded automatically by the browser when resources are low." + } + } + } + ], + "returns_async": { + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true, + "description": "Details about the updated tab. The $(ref:tabs.Tab) object does not contain url, pendingUrl, title, and favIconUrl if the \"tabs\" permission has not been requested." + } + ] + } } ], "events": [ diff --git a/spec-main/extensions-spec.ts b/spec-main/extensions-spec.ts index d83aa08f0e..a71c2b75bd 100644 --- a/spec-main/extensions-spec.ts +++ b/spec-main/extensions-spec.ts @@ -337,9 +337,13 @@ describe('chrome extensions', () => { }); describe('chrome.tabs', () => { - it('executeScript', async () => { - const customSession = session.fromPartition(`persist:${uuid.v4()}`); + let customSession: Session; + before(async () => { + customSession = session.fromPartition(`persist:${uuid.v4()}`); await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-api')); + }); + + it('executeScript', async () => { const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } }); await w.loadURL(url); @@ -353,8 +357,6 @@ describe('chrome extensions', () => { }); it('connect', async () => { - const customSession = session.fromPartition(`persist:${uuid.v4()}`); - await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-api')); const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } }); await w.loadURL(url); @@ -368,9 +370,7 @@ describe('chrome extensions', () => { expect(response[1]).to.equal('howdy'); }); - it('sendMessage receives the response', async function () { - const customSession = session.fromPartition(`persist:${uuid.v4()}`); - await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-api')); + it('sendMessage receives the response', async () => { const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } }); await w.loadURL(url); @@ -383,6 +383,28 @@ describe('chrome extensions', () => { expect(response.message).to.equal('Hello World!'); expect(response.tabId).to.equal(w.webContents.id); }); + + it('update', async () => { + const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } }); + await w.loadURL(url); + + const w2 = new BrowserWindow({ show: false, webPreferences: { session: customSession } }); + await w2.loadURL('about:blank'); + + const w2Navigated = emittedOnce(w2.webContents, 'did-navigate'); + + const message = { method: 'update', args: [w2.webContents.id, { url }] }; + w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`); + + const [,, responseString] = await emittedOnce(w.webContents, 'console-message'); + const response = JSON.parse(responseString); + + await w2Navigated; + + expect(new URL(w2.getURL()).toString()).to.equal(new URL(url).toString()); + + expect(response.id).to.equal(w2.webContents.id); + }); }); describe('background pages', () => { diff --git a/spec-main/fixtures/extensions/chrome-api/background.js b/spec-main/fixtures/extensions/chrome-api/background.js index a1adc1193c..e3f2129a1b 100644 --- a/spec-main/fixtures/extensions/chrome-api/background.js +++ b/spec-main/fixtures/extensions/chrome-api/background.js @@ -23,6 +23,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { port.postMessage('howdy'); break; } + + case 'update': { + const [tabId, props] = args; + chrome.tabs.update(tabId, props, sendResponse); + } } // Respond asynchronously return true; diff --git a/spec-main/fixtures/extensions/chrome-api/main.js b/spec-main/fixtures/extensions/chrome-api/main.js index b60a647bdc..14331534b7 100644 --- a/spec-main/fixtures/extensions/chrome-api/main.js +++ b/spec-main/fixtures/extensions/chrome-api/main.js @@ -37,6 +37,11 @@ const testMap = { }); }); chrome.runtime.sendMessage({ method: 'connectTab', args: [name] }); + }, + update (tabId, props) { + chrome.runtime.sendMessage({ method: 'update', args: [tabId, props] }, response => { + console.log(JSON.stringify(response)); + }); } };