Skip to content

Commit

Permalink
Check subframes against extension permissions
Browse files Browse the repository at this point in the history
Fixed: 1467751
Change-Id: Iee04d8fa9dfd84ac4ed4b8f4ffb6334fff922c71
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/4735433
Reviewed-by: Danil Somsikov <dsv@chromium.org>
Commit-Queue: Philip Pfaffe <pfaffe@chromium.org>
  • Loading branch information
pfaffe authored and Devtools-frontend LUCI CQ committed Aug 1, 2023
1 parent 04799e3 commit 27078e3
Show file tree
Hide file tree
Showing 2 changed files with 181 additions and 26 deletions.
64 changes: 39 additions & 25 deletions front_end/models/extensions/ExtensionServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,7 @@ export class HostsPolicy {
private constructor(readonly runtimeAllowedHosts: HostUrlPattern[], readonly runtimeBlockedHosts: HostUrlPattern[]) {
}

isAllowedOnCurrentTarget(): boolean {
const inspectedURL = SDK.TargetManager.TargetManager.instance().primaryPageTarget()?.inspectedURL();
isAllowedOnURL(inspectedURL?: Platform.DevToolsPath.UrlString): boolean {
if (!inspectedURL) {
// If there aren't any blocked hosts retain the old behavior and don't worry about the inspectedURL
return this.runtimeBlockedHosts.length === 0;
Expand All @@ -107,18 +106,34 @@ export class HostsPolicy {
}
}

function currentTargetIsFile(): boolean {
const inspectedURL = SDK.TargetManager.TargetManager.instance().primaryPageTarget()?.inspectedURL();
if (!inspectedURL) {
return false;
class RegisteredExtension {
constructor(readonly name: string, readonly hostsPolicy: HostsPolicy, readonly allowFileAccess: boolean) {
}
let parsedURL;
try {
parsedURL = new URL(inspectedURL);
} catch (exception) {
return false;

isAllowedOnTarget(inspectedURL?: Platform.DevToolsPath.UrlString): boolean {
if (!inspectedURL) {
inspectedURL = SDK.TargetManager.TargetManager.instance().primaryPageTarget()?.inspectedURL();
}

if (!this.hostsPolicy.isAllowedOnURL(inspectedURL)) {
return false;
}

if (!this.allowFileAccess) {
if (!inspectedURL) {
return false;
}
let parsedURL;
try {
parsedURL = new URL(inspectedURL);
} catch (exception) {
return false;
}
return parsedURL.protocol !== 'file:';
}

return true;
}
return parsedURL.protocol === 'file:';
}

export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
Expand All @@ -132,11 +147,7 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
private requests: Map<number, TextUtils.ContentProvider.ContentProvider>;
private readonly requestIds: Map<TextUtils.ContentProvider.ContentProvider, number>;
private lastRequestId: number;
private registeredExtensions: Map<string, {
name: string,
hostsPolicy: HostsPolicy,
allowFileAccess: boolean,
}>;
private registeredExtensions: Map<string, RegisteredExtension>;
private status: ExtensionStatus;
private readonly sidebarPanesInternal: ExtensionSidebarPane[];
private extensionsEnabled: boolean;
Expand Down Expand Up @@ -1033,13 +1044,17 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
return;
}
const hostsPolicy = HostsPolicy.create(extensionInfo.hostsPolicy);
if (!hostsPolicy || !hostsPolicy.isAllowedOnCurrentTarget() ||
(!extensionInfo.allowFileAccess && currentTargetIsFile())) {
if (!hostsPolicy) {
return;
}
try {
const startPageURL = new URL((startPage as string));
const extensionOrigin = startPageURL.origin;
const name = extensionInfo.name || `Extension ${extensionOrigin}`;
const extensionRegistration = new RegisteredExtension(name, hostsPolicy, Boolean(extensionInfo.allowFileAccess));
if (!extensionRegistration.isAllowedOnTarget(inspectedURL)) {
return;
}
if (!this.registeredExtensions.get(extensionOrigin)) {
// See ExtensionAPI.js for details.
const injectedAPI = self.buildExtensionAPIInjectedScript(
Expand All @@ -1048,9 +1063,7 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
ExtensionServer.instance().extensionAPITestHook);
Host.InspectorFrontendHost.InspectorFrontendHostInstance.setInjectedScriptForOrigin(
extensionOrigin, injectedAPI);
const name = extensionInfo.name || `Extension ${extensionOrigin}`;
this.registeredExtensions.set(
extensionOrigin, {name, hostsPolicy, allowFileAccess: Boolean(extensionInfo.allowFileAccess)});
this.registeredExtensions.set(extensionOrigin, extensionRegistration);
}
this.addExtensionFrame(extensionInfo);
} catch (e) {
Expand Down Expand Up @@ -1090,7 +1103,7 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
if (!extension) {
return false;
}
return extension.hostsPolicy.isAllowedOnCurrentTarget() && (extension.allowFileAccess || !currentTargetIsFile());
return extension.isAllowedOnTarget();
}

private async onmessage(event: MessageEvent): Promise<void> {
Expand Down Expand Up @@ -1211,7 +1224,8 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
}
// We shouldn't get here if the outermost frame can't be inspected by an extension, but
// let's double check for subframes.
if (!this.canInspectURL(frame.url)) {
const extension = this.registeredExtensions.get(securityOrigin);
if (!this.canInspectURL(frame.url) || !extension?.isAllowedOnTarget(frame.url)) {
return this.status.E_FAILED('Permission denied');
}

Expand Down Expand Up @@ -1247,7 +1261,7 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
return this.status.E_FAILED(frame.url + ' has no execution context');
}
}
if (!this.canInspectURL(context.origin)) {
if (!this.canInspectURL(context.origin) || !extension?.isAllowedOnTarget(context.origin)) {
return this.status.E_FAILED('Permission denied');
}

Expand Down
143 changes: 142 additions & 1 deletion test/unittests/front_end/models/extensions/ExtensionServer_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// 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 Platform from '../../../../../front_end/core/platform/platform.js';
import * as SDK from '../../../../../front_end/core/sdk/sdk.js';
import * as Protocol from '../../../../../front_end/generated/protocol.js';
import * as Extensions from '../../../../../front_end/models/extensions/extensions.js';
import * as UI from '../../../../../front_end/ui/legacy/legacy.js';

Expand Down Expand Up @@ -277,6 +278,146 @@ describeWithDevtoolsExtension('Runtime hosts policy', {hostsPolicy}, context =>
assert.hasAnyKeys(result, ['entries']);
}
});

function setUpFrame(
name: string, url: Platform.DevToolsPath.UrlString, parentFrame?: SDK.ResourceTreeModel.ResourceTreeFrame,
executionContextOrigin?: Platform.DevToolsPath.UrlString) {
const mimeType = 'text/html';
const secureContextType = Protocol.Page.SecureContextType.Secure;
const crossOriginIsolatedContextType = Protocol.Page.CrossOriginIsolatedContextType.Isolated;
const loaderId = 'loader' as Protocol.Network.LoaderId;

const parentTarget = parentFrame?.resourceTreeModel()?.target();
const target = createTarget({id: `${name}-target-id` as Protocol.Target.TargetID, parentTarget});
const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
Platform.assertNotNullOrUndefined(resourceTreeModel);

const id = `${name}-frame-id` as Protocol.Page.FrameId;
resourceTreeModel.frameNavigated(
{
id,
parentId: parentFrame?.id,
loaderId,
url,
domainAndRegistry: new URL(url).hostname,
securityOrigin: new URL(url).origin,
mimeType,
secureContextType,
crossOriginIsolatedContextType,
gatedAPIFeatures: [],
},
Protocol.Page.NavigationType.Navigation);

if (executionContextOrigin) {
executionContextOrigin = new URL(executionContextOrigin).origin as Platform.DevToolsPath.UrlString;
const parentRuntimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
Platform.assertNotNullOrUndefined(parentRuntimeModel);
parentRuntimeModel.executionContextCreated({
id: 0 as Protocol.Runtime.ExecutionContextId,
origin: executionContextOrigin,
name: executionContextOrigin,
uniqueId: executionContextOrigin,
auxData: {frameId: id, isDefault: true},
});
}

const frame = resourceTreeModel.frameForId(id);
Platform.assertNotNullOrUndefined(frame);
return frame;
}

it('blocks evaluation on blocked subframes', async () => {
assert.isUndefined(context.chrome.devtools);
const parentFrameUrl = 'http://example.com' as Platform.DevToolsPath.UrlString;
const childFrameUrl = 'http://web.dev' as Platform.DevToolsPath.UrlString;
const parentFrame = setUpFrame('parent', parentFrameUrl);
setUpFrame('child', childFrameUrl, parentFrame);

const result = await new Promise<{result: unknown, error?: {details: unknown[]}}>(
r => context.chrome.devtools?.inspectedWindow.eval(
'4', {frameURL: childFrameUrl}, (result, error) => r({result, error})));

assert.deepStrictEqual(result.error?.details, ['Permission denied']);
});

it('doesn\'t block evaluation on blocked sub-executioncontexts with useContentScriptContext', async () => {
assert.isUndefined(context.chrome.devtools);

const parentFrameUrl = 'http://example.com' as Platform.DevToolsPath.UrlString;
const childFrameUrl = 'http://example.com/2' as Platform.DevToolsPath.UrlString;
const childExeContextOrigin = 'http://web.dev' as Platform.DevToolsPath.UrlString;
const parentFrame = setUpFrame('parent', parentFrameUrl, undefined, parentFrameUrl);
const childFrame = setUpFrame('child', childFrameUrl, parentFrame, childExeContextOrigin);

// Create a fake content script execution context, i.e., a non-default context with the extension's (== window's)
// origin.
const runtimeModel = childFrame.resourceTreeModel()?.target().model(SDK.RuntimeModel.RuntimeModel);
Platform.assertNotNullOrUndefined(runtimeModel);
runtimeModel.executionContextCreated({
id: 1 as Protocol.Runtime.ExecutionContextId,
origin: window.location.origin,
name: window.location.origin,
uniqueId: window.location.origin,
auxData: {frameId: childFrame.id, isDefault: false},
});
const contentScriptExecutionContext = runtimeModel.executionContext(1);
Platform.assertNotNullOrUndefined(contentScriptExecutionContext);
sinon.stub(contentScriptExecutionContext, 'evaluate').returns(Promise.resolve({
object: SDK.RemoteObject.RemoteObject.fromLocalObject(4),
}));

const result = await new Promise<{result: unknown, error?: {details: unknown[]}}>(
r => context.chrome.devtools?.inspectedWindow.eval(
'4', {frameURL: childFrameUrl, useContentScriptContext: true}, (result, error) => r({result, error})));

assert.deepStrictEqual(result.result, 4);
});

it('blocks evaluation on blocked sub-executioncontexts with explicit scriptExecutionContextOrigin', async () => {
assert.isUndefined(context.chrome.devtools);

const parentFrameUrl = 'http://example.com' as Platform.DevToolsPath.UrlString;
const childFrameUrl = 'http://example.com/2' as Platform.DevToolsPath.UrlString;
const parentFrame = setUpFrame('parent', parentFrameUrl, undefined, parentFrameUrl);
const childFrame = setUpFrame('child', childFrameUrl, parentFrame, parentFrameUrl);

// Create a non-default context with a blocked origin.
const childExeContextOrigin = 'http://web.dev' as Platform.DevToolsPath.UrlString;
const runtimeModel = childFrame.resourceTreeModel()?.target().model(SDK.RuntimeModel.RuntimeModel);
Platform.assertNotNullOrUndefined(runtimeModel);
runtimeModel.executionContextCreated({
id: 1 as Protocol.Runtime.ExecutionContextId,
origin: childExeContextOrigin,
name: childExeContextOrigin,
uniqueId: childExeContextOrigin,
auxData: {frameId: childFrame.id, isDefault: false},
});

const result = await new Promise<{result: unknown, error?: {details: unknown[]}}>(
r => context.chrome.devtools?.inspectedWindow.eval(
// The typings don't match the implementation, so we need to cast to any here to make ts happy.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
'4', {frameURL: childFrameUrl, scriptExecutionContext: childExeContextOrigin} as any,
(result, error) => r({result, error})));

assert.deepStrictEqual(result.error?.details, ['Permission denied']);
});

it('blocks evaluation on blocked sub-executioncontexts', async () => {
assert.isUndefined(context.chrome.devtools);

const parentFrameUrl = 'http://example.com' as Platform.DevToolsPath.UrlString;
const childFrameUrl = 'http://example.com/2' as Platform.DevToolsPath.UrlString;
const childExeContextOrigin = 'http://web.dev' as Platform.DevToolsPath.UrlString;
const parentFrame = setUpFrame('parent', parentFrameUrl, undefined, parentFrameUrl);
setUpFrame('child', childFrameUrl, parentFrame, childExeContextOrigin);

const result = await new Promise<{result: unknown, error?: {details: unknown[]}}>(
r => context.chrome.devtools?.inspectedWindow.eval(
'4', {frameURL: childFrameUrl}, (result, error) => r({result, error})));

assert.deepStrictEqual(result.error?.details, ['Permission denied']);
});
});

describe('ExtensionServer', () => {
Expand Down

0 comments on commit 27078e3

Please sign in to comment.