Apply runtime_blocked_hosts and runtime_allowed_hosts for extensions

Bug: 1429353
Change-Id: I2605e77f74f08e0e3619b88440641838adf34679
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/4637291
Reviewed-by: Danil Somsikov <dsv@chromium.org>
Commit-Queue: Philip Pfaffe <pfaffe@chromium.org>
This commit is contained in:
Philip Pfaffe
2023-06-26 15:20:59 +00:00
committed by Devtools-frontend LUCI CQ
parent 5406f386c0
commit 2d19bcb52a
6 changed files with 146 additions and 17 deletions
@@ -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;
+7 -1
View File
@@ -6,6 +6,12 @@
// DevToolsAPI ----------------------------------------------------------------
/**
* @typedef {{runtimeAllowedHosts: !Array<string>, runtimeBlockedHosts: !Array<string>}} 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;
+73 -8
View File
@@ -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<MessagePort, Platform.DevToolsPath.UrlString> = 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<EventTypes> {
private readonly clientObjects: Map<string, unknown>;
private readonly handlers:
@@ -81,6 +107,7 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
private lastRequestId: number;
private registeredExtensions: Map<string, {
name: string,
hostsPolicy: HostsPolicy,
}>;
private status: ExtensionStatus;
private readonly sidebarPanesInternal: ExtensionSidebarPane[];
@@ -363,7 +390,9 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
}
const message = {command: 'notify-' + type, arguments: Array.prototype.slice.call(arguments, 1)};
for (const subscriber of subscribers) {
subscriber.postMessage(message);
if (this.extensionEnabled(subscriber)) {
subscriber.postMessage(message);
}
}
}
@@ -953,7 +982,11 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
addExtensionForTest(extensionInfo: Host.InspectorFrontendHostAPI.ExtensionDescriptor, origin: string): boolean
|undefined {
const name = extensionInfo.name || `Extension ${origin}`;
this.registeredExtensions.set(origin, {name});
const hostsPolicy = HostsPolicy.create(extensionInfo.hostsPolicy);
if (!hostsPolicy) {
return false;
}
this.registeredExtensions.set(origin, {name, hostsPolicy});
return true;
}
@@ -967,6 +1000,10 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
if (!this.extensionsEnabled) {
return;
}
const hostsPolicy = HostsPolicy.create(extensionInfo.hostsPolicy);
if (!hostsPolicy) {
return;
}
try {
const startPageURL = new URL((startPage as string));
const extensionOrigin = startPageURL.origin;
@@ -979,7 +1016,7 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
Host.InspectorFrontendHost.InspectorFrontendHostInstance.setInjectedScriptForOrigin(
extensionOrigin, injectedAPI);
const name = extensionInfo.name || `Extension ${extensionOrigin}`;
this.registeredExtensions.set(extensionOrigin, {name});
this.registeredExtensions.set(extensionOrigin, {name, hostsPolicy});
}
const iframe = document.createElement('iframe');
@@ -1012,15 +1049,42 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
}
};
private extensionEnabled(port: MessagePort): boolean {
if (!this.extensionsEnabled) {
return false;
}
const origin = extensionOrigins.get(port);
if (!origin) {
return false;
}
const extension = this.registeredExtensions.get(origin);
if (!extension) {
return false;
}
const inspectedURL = SDK.TargetManager.TargetManager.instance().primaryPageTarget()?.inspectedURL();
if (!inspectedURL) {
// If there aren't any blocked hosts retain the old behavior and don't worry about the inspectedURL
return extension.hostsPolicy.runtimeBlockedHosts.length === 0;
}
if (extension.hostsPolicy.runtimeBlockedHosts.some(pattern => pattern.matchesUrl(inspectedURL)) &&
!extension.hostsPolicy.runtimeAllowedHosts.some(pattern => pattern.matchesUrl(inspectedURL))) {
return false;
}
return true;
}
private async onmessage(event: MessageEvent): Promise<void> {
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<EventTyp
[]).includes(parsedURL.origin)) {
return false;
}
return true;
}
@@ -252,6 +252,7 @@ export async function initializeGlobalVars({reset = true} = {}) {
Common.Settings.Settings.instance(
{forceNew: reset, syncedStorage: storage, globalStorage: storage, localStorage: storage});
Root.Runtime.experiments.clearForTest();
for (const experimentName of REGISTERED_EXPERIMENTS) {
Root.Runtime.experiments.register(experimentName, '');
}
@@ -2,17 +2,19 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import type * as Platform from '../../../../../front_end/core/platform/platform.js';
import * as SDK from '../../../../../front_end/core/sdk/sdk.js';
import * as Extensions from '../../../../../front_end/models/extensions/extensions.js';
import * as UI from '../../../../../front_end/ui/legacy/legacy.js';
import type * as Platform from '../../../../../front_end/core/platform/platform.js';
const {assert} = chai;
import {describeWithDummyExtension} from './helpers.js';
import {describeWithDevtoolsExtension} from './helpers.js';
import {type Chrome} from '../../../../../extension-api/ExtensionAPI.js';
import {createTarget} from '../../helpers/EnvironmentHelpers.js';
import {describeWithMockConnection} from '../../helpers/MockConnection.js';
describeWithDummyExtension('Extensions', context => {
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<object>(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<object>(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<object>(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<object>(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.
@@ -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<Chrome.DevTools.Chrome>;
}
export function describeWithDummyExtension(title: string, fn: (this: Mocha.Suite, context: ExtensionContext) => void) {
export function describeWithDevtoolsExtension(
title: string, extension: Partial<Host.InspectorFrontendHostAPI.ExtensionDescriptor>,
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<Chrome.DevTools.Chrome> = {};
@@ -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<Host.InspectorFrontendHostAPI.ExtensionDescriptor>,
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);
});
};