diff --git a/front_end/core/host/InspectorFrontendHostAPI.ts b/front_end/core/host/InspectorFrontendHostAPI.ts index 282c0b44f2..1768e9e848 100644 --- a/front_end/core/host/InspectorFrontendHostAPI.ts +++ b/front_end/core/host/InspectorFrontendHostAPI.ts @@ -324,6 +324,11 @@ export interface ExtensionDescriptor { startPage: string; name: string; exposeExperimentalAPIs: boolean; + hostsPolicy?: ExtensionHostsPolicy; +} +export interface ExtensionHostsPolicy { + runtimeAllowedHosts: string[]; + runtimeBlockedHosts: string[]; } export interface ShowSurveyResult { surveyShown: boolean; diff --git a/front_end/devtools_compatibility.js b/front_end/devtools_compatibility.js index 7953e89f2e..548ccfc853 100644 --- a/front_end/devtools_compatibility.js +++ b/front_end/devtools_compatibility.js @@ -6,6 +6,12 @@ // DevToolsAPI ---------------------------------------------------------------- +/** + * @typedef {{runtimeAllowedHosts: !Array, runtimeBlockedHosts: !Array}} ExtensionHostsPolicy + */ +/** + * @typedef {{startPage: string, name: string, exposeExperimentalAPIs: boolean, hostsPolicy?: ExtensionHostsPolicy}} ExtensionDescriptor + */ const DevToolsAPIImpl = class { constructor() { /** @@ -24,7 +30,7 @@ const DevToolsAPIImpl = class { this._pendingExtensionDescriptors = []; /** - * @type {?function(!ExtensionDescriptor)} + * @type {?function(!ExtensionDescriptor): void} */ this._addExtensionCallback = null; diff --git a/front_end/models/extensions/ExtensionServer.ts b/front_end/models/extensions/ExtensionServer.ts index 00f94d0731..85aded3768 100644 --- a/front_end/models/extensions/ExtensionServer.ts +++ b/front_end/models/extensions/ExtensionServer.ts @@ -31,6 +31,7 @@ // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration /* eslint-disable @typescript-eslint/naming-convention */ +import {type Chrome} from '../../../extension-api/ExtensionAPI.js'; // eslint-disable-line rulesdir/es_modules_import import * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; @@ -38,6 +39,7 @@ import * as Platform from '../../core/platform/platform.js'; import * as _ProtocolClient from '../../core/protocol_client/protocol_client.js'; // eslint-disable-line @typescript-eslint/no-unused-vars import * as Root from '../../core/root/root.js'; import * as SDK from '../../core/sdk/sdk.js'; +import type * as Protocol from '../../generated/protocol.js'; import * as Logs from '../../models/logs/logs.js'; import * as Components from '../../ui/legacy/components/utils/utils.js'; import * as UI from '../../ui/legacy/legacy.js'; @@ -46,15 +48,13 @@ import * as Bindings from '../bindings/bindings.js'; import * as HAR from '../har/har.js'; import type * as TextUtils from '../text_utils/text_utils.js'; import * as Workspace from '../workspace/workspace.js'; -import type * as Protocol from '../../generated/protocol.js'; +import {PrivateAPI} from './ExtensionAPI.js'; import {ExtensionButton, ExtensionPanel, ExtensionSidebarPane} from './ExtensionPanel.js'; - +import {HostUrlPattern} from './HostUrlPattern.js'; import {LanguageExtensionEndpoint} from './LanguageExtensionEndpoint.js'; import {RecorderExtensionEndpoint} from './RecorderExtensionEndpoint.js'; -import {PrivateAPI} from './ExtensionAPI.js'; import {RecorderPluginManager} from './RecorderPluginManager.js'; -import {type Chrome} from '../../../extension-api/ExtensionAPI.js'; // eslint-disable-line rulesdir/es_modules_import const extensionOrigins: WeakMap = new WeakMap(); @@ -68,6 +68,32 @@ const kAllowedOrigins = [].map(url => (new URL(url)).origin); let extensionServerInstance: ExtensionServer|null; +export class HostsPolicy { + static create(policy?: Host.InspectorFrontendHostAPI.ExtensionHostsPolicy): HostsPolicy|null { + const runtimeAllowedHosts = []; + const runtimeBlockedHosts = []; + if (policy) { + for (const pattern of policy.runtimeAllowedHosts) { + const parsedPattern = HostUrlPattern.parse(pattern); + if (!parsedPattern) { + return null; + } + runtimeAllowedHosts.push(parsedPattern); + } + for (const pattern of policy.runtimeBlockedHosts) { + const parsedPattern = HostUrlPattern.parse(pattern); + if (!parsedPattern) { + return null; + } + runtimeBlockedHosts.push(parsedPattern); + } + } + return new HostsPolicy(runtimeAllowedHosts, runtimeBlockedHosts); + } + private constructor(readonly runtimeAllowedHosts: HostUrlPattern[], readonly runtimeBlockedHosts: HostUrlPattern[]) { + } +} + export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper { private readonly clientObjects: Map; private readonly handlers: @@ -81,6 +107,7 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper; private status: ExtensionStatus; private readonly sidebarPanesInternal: ExtensionSidebarPane[]; @@ -363,7 +390,9 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper pattern.matchesUrl(inspectedURL)) && + !extension.hostsPolicy.runtimeAllowedHosts.some(pattern => pattern.matchesUrl(inspectedURL))) { + return false; + } + + return true; + } + private async onmessage(event: MessageEvent): Promise { const message = event.data; let result; + const port = event.currentTarget as MessagePort; const handler = this.handlers.get(message.command); if (!handler) { result = this.status.E_NOTSUPPORTED(message.command); - } else if (!this.extensionsEnabled) { + } else if (!this.extensionEnabled(port)) { result = this.status.E_FAILED('Permission denied'); } else { result = await handler(message, event.target as MessagePort); @@ -1217,6 +1281,7 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper { +describeWithDevtoolsExtension('Extensions', {}, context => { it('can register a recorder extension for export', async () => { class RecorderPlugin { async stringify(recording: object) { @@ -197,6 +199,50 @@ describeWithDummyExtension('Extensions', context => { }); }); +const hostsPolicy = { + runtimeAllowedHosts: ['http://example.com'], + runtimeBlockedHosts: ['http://example.com', 'http://web.dev'], +}; + +describeWithMockConnection('Extensions', () => { + describeWithDevtoolsExtension('Runtime hosts policy', {hostsPolicy}, context => { + it('blocks API calls on blocked hosts', async () => { + const target = createTarget({type: SDK.Target.Type.Frame}); + + { + const result = await new Promise(cb => context.chrome.devtools?.network.getHAR(cb)); + assert.strictEqual('isError' in result && result.isError, true); + } + + target.setInspectedURL('http://web.dev' as Platform.DevToolsPath.UrlString); + { + const result = await new Promise(cb => context.chrome.devtools?.network.getHAR(cb)); + assert.strictEqual('isError' in result && result.isError, true); + } + }); + + it('allows API calls on allowlisted hosts', async () => { + const target = createTarget({type: SDK.Target.Type.Frame}); + target.setInspectedURL('http://example.com' as Platform.DevToolsPath.UrlString); + { + const result = await new Promise(cb => context.chrome.devtools?.network.getHAR(cb)); + // eslint-disable-next-line rulesdir/compare_arrays_with_assert_deepequal + assert.doesNotHaveAnyKeys(result, ['isError']); + } + }); + + it('allows API calls on non-blocked hosts', async () => { + const target = createTarget({type: SDK.Target.Type.Frame}); + target.setInspectedURL('http://example.com2' as Platform.DevToolsPath.UrlString); + { + const result = await new Promise(cb => context.chrome.devtools?.network.getHAR(cb)); + // eslint-disable-next-line rulesdir/compare_arrays_with_assert_deepequal + assert.doesNotHaveAnyKeys(result, ['isError']); + } + }); + }); +}); + describe('ExtensionServer', () => { it('can correctly expand resource paths', async () => { // Ideally this would be a chrome-extension://, but that doesn't work with URL in chrome headless. diff --git a/test/unittests/front_end/models/extensions/helpers.ts b/test/unittests/front_end/models/extensions/helpers.ts index 01932bbea1..fb451e1796 100644 --- a/test/unittests/front_end/models/extensions/helpers.ts +++ b/test/unittests/front_end/models/extensions/helpers.ts @@ -2,15 +2,18 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import * as Extensions from '../../../../../front_end/models/extensions/extensions.js'; import {type Chrome} from '../../../../../extension-api/ExtensionAPI.js'; +import type * as Host from '../../../../../front_end/core/host/host.js'; +import * as Extensions from '../../../../../front_end/models/extensions/extensions.js'; import {describeWithEnvironment} from '../../helpers/EnvironmentHelpers.js'; interface ExtensionContext { chrome: Partial; } -export function describeWithDummyExtension(title: string, fn: (this: Mocha.Suite, context: ExtensionContext) => void) { +export function describeWithDevtoolsExtension( + title: string, extension: Partial, + fn: (this: Mocha.Suite, context: ExtensionContext) => void) { const context: ExtensionContext = { chrome: {}, }; @@ -21,6 +24,7 @@ export function describeWithDummyExtension(title: string, fn: (this: Mocha.Suite startPage: 'blank.html', name: 'TestExtension', exposeExperimentalAPIs: true, + ...extension, }; server.addExtensionForTest(extensionDescriptor, window.location.origin); const chrome: Partial = {}; @@ -46,9 +50,11 @@ export function describeWithDummyExtension(title: string, fn: (this: Mocha.Suite }); } -describeWithDummyExtension.only = function(title: string, fn: (this: Mocha.Suite, context: ExtensionContext) => void) { +describeWithDevtoolsExtension.only = function( + title: string, extension: Partial, + fn: (this: Mocha.Suite, context: ExtensionContext) => void) { // eslint-disable-next-line rulesdir/no_only return describe.only('.only', function() { - return describeWithDummyExtension(title, fn); + return describeWithDevtoolsExtension(title, extension, fn); }); };