Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/vs/workbench/api/browser/mainThreadWebviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ExtensionIdentifier } from '../../../platform/extensions/common/extensi
import { IOpenerService } from '../../../platform/opener/common/opener.js';
import { IProductService } from '../../../platform/product/common/productService.js';
import { IWebview, WebviewContentOptions, WebviewExtensionDescription } from '../../contrib/webview/browser/webview.js';
import { normalizeSharedSessionCookies } from '../../contrib/webview/common/webviewSharedSessionCookies.js';
import { IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
import { SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js';
import * as extHostProtocol from '../common/extHost.protocol.js';
Expand Down Expand Up @@ -155,5 +156,6 @@ export function reviveWebviewContentOptions(webviewOptions: extHostProtocol.IWeb
enableCommandUris: webviewOptions.enableCommandUris,
localResourceRoots: Array.isArray(webviewOptions.localResourceRoots) ? webviewOptions.localResourceRoots.map(r => URI.revive(r)) : undefined,
portMapping: webviewOptions.portMapping,
sharedSessionCookies: normalizeSharedSessionCookies(webviewOptions.sharedSessionCookies, { platform: isWeb ? 'browser' : 'electron' }),
};
}
5 changes: 5 additions & 0 deletions src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,12 +1007,17 @@ export interface IWebviewPortMapping {
readonly extensionHostPort: number;
}

export interface IWebviewSharedSessionCookies {
readonly allowedOrigins: readonly string[];
}

export interface IWebviewContentOptions {
readonly enableScripts?: boolean;
readonly enableForms?: boolean;
readonly enableCommandUris?: boolean | readonly string[];
readonly localResourceRoots?: readonly UriComponents[];
readonly portMapping?: readonly IWebviewPortMapping[];
readonly sharedSessionCookies?: IWebviewSharedSessionCookies;
}

export interface IWebviewPanelOptions {
Expand Down
6 changes: 5 additions & 1 deletion src/vs/workbench/api/common/extHostWebview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { Schemas } from '../../../base/common/network.js';
import * as objects from '../../../base/common/objects.js';
import { URI } from '../../../base/common/uri.js';
import { normalizeVersion, parseVersion } from '../../../platform/extensions/common/extensionValidator.js';
import { isWeb } from '../../../base/common/platform.js';
import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js';
import { ILogService } from '../../../platform/log/common/log.js';
import { IExtHostApiDeprecationService } from './extHostApiDeprecationService.js';
import { deserializeWebviewMessage, serializeWebviewMessage } from './extHostWebviewMessaging.js';
import { IExtHostWorkspace } from './extHostWorkspace.js';
import { WebviewRemoteInfo, asWebviewUri, webviewGenericCspSource } from '../../contrib/webview/common/webview.js';
import { normalizeSharedSessionCookies } from '../../contrib/webview/common/webviewSharedSessionCookies.js';
import { SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js';
import type * as vscode from 'vscode';
import * as extHostProtocol from './extHost.protocol.js';
Expand Down Expand Up @@ -269,7 +271,8 @@ export function serializeWebviewOptions(
enableScripts: options.enableScripts,
enableForms: options.enableForms,
portMapping: options.portMapping,
localResourceRoots: options.localResourceRoots || getDefaultLocalResourceRoots(extension, workspace)
localResourceRoots: options.localResourceRoots || getDefaultLocalResourceRoots(extension, workspace),
sharedSessionCookies: normalizeSharedSessionCookies(options.sharedSessionCookies, { platform: isWeb ? 'browser' : 'electron' }),
};
}

Expand All @@ -280,6 +283,7 @@ function reviveOptions(options: extHostProtocol.IWebviewContentOptions): vscode.
enableForms: options.enableForms,
portMapping: options.portMapping,
localResourceRoots: options.localResourceRoots?.map(components => URI.from(components)),
sharedSessionCookies: normalizeSharedSessionCookies(options.sharedSessionCookies, { platform: isWeb ? 'browser' : 'electron' }),
};
}

Expand Down
60 changes: 57 additions & 3 deletions src/vs/workbench/api/test/browser/extHostWebview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { NullApiDeprecationService } from '../../common/extHostApiDeprecationSer
import { IExtHostRpcService } from '../../common/extHostRpcService.js';
import { ExtHostWebviews } from '../../common/extHostWebview.js';
import { ExtHostWebviewPanels } from '../../common/extHostWebviewPanels.js';
import * as extHostProtocol from '../../common/extHost.protocol.js';
import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js';
import { decodeAuthority, webviewResourceBaseHost } from '../../../contrib/webview/common/webview.js';
import { EditorGroupColumn } from '../../../services/editor/common/editorGroupColumn.js';
Expand All @@ -25,12 +26,13 @@ import type * as vscode from 'vscode';
suite('ExtHostWebview', () => {
let disposables: DisposableStore;
let rpcProtocol: (IExtHostRpcService & IExtHostContext) | undefined;
let mainThreadWebviews: ReturnType<typeof createNoopMainThreadWebviews>;

setup(() => {
disposables = new DisposableStore();

const shape = createNoopMainThreadWebviews();
rpcProtocol = SingleProxyRPCProtocol(shape);
mainThreadWebviews = createNoopMainThreadWebviews();
rpcProtocol = SingleProxyRPCProtocol(mainThreadWebviews);
});

teardown(() => {
Expand Down Expand Up @@ -195,13 +197,65 @@ suite('ExtHostWebview', () => {
'Check decoded authority'
);
});

test('serializes shared session cookies on webview creation', () => {
const extHostWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, undefined, new NullLogService(), NullApiDeprecationService));
const extHostWebviewPanels = disposables.add(new ExtHostWebviewPanels(rpcProtocol!, extHostWebviews, undefined));

const panel = disposables.add(extHostWebviewPanels.createWebviewPanel({
extensionLocation: URI.file('/ext/path')
} as IExtensionDescription, 'type', 'title', 1, {
sharedSessionCookies: {
allowedOrigins: ['https://Example.com', 'https://example.com/']
}
}));

assert.deepStrictEqual(mainThreadWebviews.lastCreateWebviewOptions?.sharedSessionCookies, {
allowedOrigins: ['https://example.com']
});
assert.deepStrictEqual(panel.webview.options.sharedSessionCookies, {
allowedOrigins: ['https://Example.com', 'https://example.com/']
});
});

test('serializes shared session cookies on webview option updates', () => {
const panel = createWebview(rpcProtocol, undefined);

panel.webview.options = {
...panel.webview.options,
sharedSessionCookies: {
allowedOrigins: ['https://Example.com', 'https://example.com/']
}
};

assert.deepStrictEqual(mainThreadWebviews.lastSetOptions?.sharedSessionCookies, {
allowedOrigins: ['https://example.com']
});
});

test('does not serialize shared session cookies by default', () => {
createWebview(rpcProtocol, undefined);

assert.strictEqual(mainThreadWebviews.lastCreateWebviewOptions?.sharedSessionCookies, undefined);
});
});


function createNoopMainThreadWebviews() {
return new class extends mock<MainThreadWebviewManager>() {
lastCreateWebviewOptions: extHostProtocol.IWebviewContentOptions | undefined;
lastSetOptions: extHostProtocol.IWebviewContentOptions | undefined;

$disposeWebview() { /* noop */ }
$createWebviewPanel() { /* noop */ }

$createWebviewPanel(_extensionData: unknown, _handle: string, _viewType: string, initData: { webviewOptions: extHostProtocol.IWebviewContentOptions }) {
this.lastCreateWebviewOptions = initData.webviewOptions;
}

$setOptions(_handle: string, options: extHostProtocol.IWebviewContentOptions) {
this.lastSetOptions = options;
}

$registerSerializer() { /* noop */ }
$unregisterSerializer() { /* noop */ }
};
Expand Down
22 changes: 22 additions & 0 deletions src/vs/workbench/api/test/browser/mainThreadWebviews.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { isWeb } from '../../../../base/common/platform.js';
import { reviveWebviewContentOptions } from '../../browser/mainThreadWebviews.js';

suite('MainThreadWebviews', () => {
test('revives shared session cookies', () => {
const revived = reviveWebviewContentOptions({
sharedSessionCookies: {
allowedOrigins: ['https://Example.com', 'https://example.com/']
}
});

assert.deepStrictEqual(revived.sharedSessionCookies, isWeb ? undefined : {
allowedOrigins: ['https://example.com']
});
});
});
15 changes: 15 additions & 0 deletions src/vs/workbench/contrib/webview/browser/pre/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,20 @@
/** @type {Array<{ readonly message: any, transfer?: ArrayBuffer[] }>} */
let pendingMessages = [];

/**
* @param {{ allowedOrigins: readonly string[] } | undefined} sharedSessionCookies
*/
const updateSharedSessionCookies = (sharedSessionCookies) => {
if (!onElectron || disableServiceWorker || !navigator.serviceWorker.controller) {
return;
}

navigator.serviceWorker.controller.postMessage({
channel: 'set-shared-session-cookies',
data: sharedSessionCookies,
});
};

const initData = {
/** @type {number | undefined} */
initialScrollProgress: undefined,
Expand Down Expand Up @@ -1001,6 +1015,7 @@
}

const options = data.options;
updateSharedSessionCookies(options.sharedSessionCookies);
const newDocument = toContentHtml(data);

const initialStyleVersion = styleVersion;
Expand Down
28 changes: 28 additions & 0 deletions src/vs/workbench/contrib/webview/browser/pre/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ const resourceRequestStore = new RequestStore();
/** @type {RequestStore<string|undefined>} */
const localhostRequestStore = new RequestStore();

/**
* Allow this webview to send Electron session cookies to trusted origins.
*
* @type {{ readonly allowedOrigins: readonly string[] } | undefined}
*/
let sharedSessionCookies;

const unauthorized = () =>
new Response('Unauthorized', { status: 401, });

Expand Down Expand Up @@ -170,6 +177,10 @@ sw.addEventListener('message', async (event) => {
}
return;
}
case 'set-shared-session-cookies': {
sharedSessionCookies = event.data.data;
return;
}
default: {
console.log('Unknown message');
return;
Expand Down Expand Up @@ -220,6 +231,10 @@ sw.addEventListener('fetch', (event) => {
}
}

if (shouldUseSharedSessionCookies(requestUrl)) {
return event.respondWith(fetch(createSharedSessionCookieRequest(event.request)));
}

// See if it's a localhost request
if (requestUrl.origin !== sw.origin && requestUrl.host.match(/^(localhost|127.0.0.1|0.0.0.0):(\d+)$/)) {
return event.respondWith(processLocalhostRequest(event, requestUrl));
Expand All @@ -234,6 +249,19 @@ sw.addEventListener('activate', (event) => {
event.waitUntil(sw.clients.claim()); // Become available to all pages
});

function shouldUseSharedSessionCookies(requestUrl) {
return requestUrl.origin !== sw.origin
&& !!sharedSessionCookies
&& sharedSessionCookies.allowedOrigins.includes(requestUrl.origin);
}

/**
* @param {Request} request
* @returns {Request}
*/
function createSharedSessionCookieRequest(request) {
return new Request(request, { credentials: 'include' });
}

/**
* @typedef {Object} ResourceRequestUrlComponents
Expand Down
7 changes: 7 additions & 0 deletions src/vs/workbench/contrib/webview/browser/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IWebviewPortMapping } from '../../../../platform/webview/common/webviewPortMapping.js';
import { Memento } from '../../../common/memento.js';
import { areSharedSessionCookiesEqual, IWebviewSharedSessionCookies } from '../common/webviewSharedSessionCookies.js';

/**
* Set when the find widget in a webview in a webview is visible.
Expand Down Expand Up @@ -134,6 +135,11 @@ export interface WebviewContentOptions {
*/
readonly portMapping?: readonly IWebviewPortMapping[];

/**
* Allow this desktop webview to send the current Electron session cookies to a set of trusted origins.
*/
readonly sharedSessionCookies?: IWebviewSharedSessionCookies;

/**
* Are command uris enabled in the webview? Defaults to false.
*
Expand All @@ -152,6 +158,7 @@ export function areWebviewContentOptionsEqual(a: WebviewContentOptions, b: Webvi
&& a.allowForms === b.allowForms
&& equals(a.localResourceRoots, b.localResourceRoots, isEqual)
&& equals(a.portMapping, b.portMapping, (a, b) => a.extensionHostPort === b.extensionHostPort && a.webviewPort === b.webviewPort)
&& areSharedSessionCookiesEqual(a.sharedSessionCookies, b.sharedSessionCookies)
&& areEnableCommandUrisEqual(a, b)
);
}
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/webview/browser/webviewElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi
allowMultipleAPIAcquire: !!this._content.options.allowMultipleAPIAcquire,
allowScripts: allowScripts,
allowForms: this._content.options.allowForms ?? allowScripts, // For back compat, we allow forms by default when scripts are enabled
sharedSessionCookies: this.platform === 'electron' ? this._content.options.sharedSessionCookies : undefined,
},
state: this._content.state,
cspSource: webviewGenericCspSource,
Expand Down
3 changes: 3 additions & 0 deletions src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ interface UpdateContentEvent {
allowMultipleAPIAcquire: boolean;
allowScripts: boolean;
allowForms: boolean;
sharedSessionCookies?: {
allowedOrigins: readonly string[];
};
};
state: any;
cspSource: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { equals } from '../../../../base/common/arrays.js';

export interface IWebviewSharedSessionCookies {
readonly allowedOrigins: readonly string[];
}

export interface IWebviewSharedSessionCookiePolicyOptions {
readonly platform: 'browser' | 'electron';
}

const localhostHosts = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]']);

export function normalizeSharedSessionCookies(
value: { readonly allowedOrigins: readonly string[] } | undefined,
options: IWebviewSharedSessionCookiePolicyOptions,
): IWebviewSharedSessionCookies | undefined {
if (!value) {
return undefined;
}

const normalizedAllowedOrigins: string[] = [];
for (const candidate of value.allowedOrigins) {
const normalizedOrigin = normalizeAllowedOrigin(candidate);
if (!normalizedAllowedOrigins.includes(normalizedOrigin)) {
normalizedAllowedOrigins.push(normalizedOrigin);
}
}

if (!normalizedAllowedOrigins.length || options.platform !== 'electron') {
return undefined;
}

return { allowedOrigins: normalizedAllowedOrigins };
}

export function areSharedSessionCookiesEqual(
a: IWebviewSharedSessionCookies | undefined,
b: IWebviewSharedSessionCookies | undefined,
): boolean {
if (a === b) {
return true;
}

if (!a || !b) {
return false;
}

return equals(a.allowedOrigins, b.allowedOrigins);
}

export function isOriginAllowedForSharedSessionCookies(
policy: IWebviewSharedSessionCookies | undefined,
origin: string,
): boolean {
return !!policy && policy.allowedOrigins.includes(origin);
}

function normalizeAllowedOrigin(candidate: string): string {
let url: URL;
try {
url = new URL(candidate);
} catch {
throw new Error(`Invalid shared session cookie origin: ${candidate}`);
}

if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error(`Invalid shared session cookie origin: ${candidate}`);
}

if (!url.host || url.username || url.password || url.search || url.hash) {
throw new Error(`Invalid shared session cookie origin: ${candidate}`);
}

if (url.pathname && url.pathname !== '/') {
throw new Error(`Invalid shared session cookie origin: ${candidate}`);
}

if (url.protocol === 'http:' && !localhostHosts.has(url.hostname)) {
throw new Error(`Invalid shared session cookie origin: ${candidate}`);
}

return url.origin;
}
Loading
Loading