diff --git a/src/vs/workbench/api/browser/mainThreadWebviews.ts b/src/vs/workbench/api/browser/mainThreadWebviews.ts index 51fb615d502a0..a424e1036715c 100644 --- a/src/vs/workbench/api/browser/mainThreadWebviews.ts +++ b/src/vs/workbench/api/browser/mainThreadWebviews.ts @@ -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'; @@ -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' }), }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index bcc636d192757..33845f2c3cc7e 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -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 { diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index 3c66bc3eda68b..2783ad8a70636 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -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'; @@ -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' }), }; } @@ -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' }), }; } diff --git a/src/vs/workbench/api/test/browser/extHostWebview.test.ts b/src/vs/workbench/api/test/browser/extHostWebview.test.ts index 9bbf074c7c600..9a6f2a0cb3394 100644 --- a/src/vs/workbench/api/test/browser/extHostWebview.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWebview.test.ts @@ -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'; @@ -25,12 +26,13 @@ import type * as vscode from 'vscode'; suite('ExtHostWebview', () => { let disposables: DisposableStore; let rpcProtocol: (IExtHostRpcService & IExtHostContext) | undefined; + let mainThreadWebviews: ReturnType; setup(() => { disposables = new DisposableStore(); - const shape = createNoopMainThreadWebviews(); - rpcProtocol = SingleProxyRPCProtocol(shape); + mainThreadWebviews = createNoopMainThreadWebviews(); + rpcProtocol = SingleProxyRPCProtocol(mainThreadWebviews); }); teardown(() => { @@ -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() { + 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 */ } }; diff --git a/src/vs/workbench/api/test/browser/mainThreadWebviews.test.ts b/src/vs/workbench/api/test/browser/mainThreadWebviews.test.ts new file mode 100644 index 0000000000000..4fb3a00cfa0bf --- /dev/null +++ b/src/vs/workbench/api/test/browser/mainThreadWebviews.test.ts @@ -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'] + }); + }); +}); diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index 9bd67086fd042..502f8411757d7 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -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, @@ -1001,6 +1015,7 @@ } const options = data.options; + updateSharedSessionCookies(options.sharedSessionCookies); const newDocument = toContentHtml(data); const initialStyleVersion = styleVersion; diff --git a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js index 2ae1ee4bfa3ac..0abffc96dae84 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +++ b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js @@ -121,6 +121,13 @@ const resourceRequestStore = new RequestStore(); /** @type {RequestStore} */ 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, }); @@ -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; @@ -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)); @@ -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 diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index 936b635aa6cf1..d8d438a5290cb 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -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. @@ -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. * @@ -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) ); } diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 5d0d61f811308..1adf5400168c1 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -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, diff --git a/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts b/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts index f7f6f8421c54d..4823026e3a978 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts @@ -51,6 +51,9 @@ interface UpdateContentEvent { allowMultipleAPIAcquire: boolean; allowScripts: boolean; allowForms: boolean; + sharedSessionCookies?: { + allowedOrigins: readonly string[]; + }; }; state: any; cspSource: string; diff --git a/src/vs/workbench/contrib/webview/common/webviewSharedSessionCookies.ts b/src/vs/workbench/contrib/webview/common/webviewSharedSessionCookies.ts new file mode 100644 index 0000000000000..0bf8eec10b7bc --- /dev/null +++ b/src/vs/workbench/contrib/webview/common/webviewSharedSessionCookies.ts @@ -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; +} diff --git a/src/vs/workbench/contrib/webview/test/browser/webviewSharedSessionCookies.test.ts b/src/vs/workbench/contrib/webview/test/browser/webviewSharedSessionCookies.test.ts new file mode 100644 index 0000000000000..9aeeaf8c674a2 --- /dev/null +++ b/src/vs/workbench/contrib/webview/test/browser/webviewSharedSessionCookies.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { isOriginAllowedForSharedSessionCookies, normalizeSharedSessionCookies } from '../../common/webviewSharedSessionCookies.js'; + +suite('webview shared session cookies', () => { + test('normalizes allowlisted origins', () => { + const policy = normalizeSharedSessionCookies({ + allowedOrigins: ['https://Example.com', 'https://example.com/', 'http://127.0.0.1:3000'] + }, { platform: 'electron' }); + + assert.deepStrictEqual(policy, { + allowedOrigins: ['https://example.com', 'http://127.0.0.1:3000'] + }); + assert.strictEqual(isOriginAllowedForSharedSessionCookies(policy, 'https://example.com'), true); + assert.strictEqual(isOriginAllowedForSharedSessionCookies(policy, 'https://example.org'), false); + }); + + test('denies access by default', () => { + assert.strictEqual(isOriginAllowedForSharedSessionCookies(undefined, 'https://example.com'), false); + }); + + test('returns undefined for empty allowlists', () => { + assert.strictEqual(normalizeSharedSessionCookies({ allowedOrigins: [] }, { platform: 'electron' }), undefined); + }); + + test('is disabled outside of electron', () => { + assert.strictEqual(normalizeSharedSessionCookies({ + allowedOrigins: ['https://example.com'] + }, { platform: 'browser' }), undefined); + }); + + test('rejects invalid origins', () => { + assert.throws(() => normalizeSharedSessionCookies({ + allowedOrigins: ['https://example.com/path'] + }, { platform: 'electron' })); + + assert.throws(() => normalizeSharedSessionCookies({ + allowedOrigins: ['http://example.com'] + }, { platform: 'electron' })); + + assert.throws(() => normalizeSharedSessionCookies({ + allowedOrigins: ['not-an-origin'] + }, { platform: 'electron' })); + }); +}); diff --git a/src/vs/workbench/test/browser/webview.test.ts b/src/vs/workbench/test/browser/webview.test.ts index 4cc711b8e0bdd..c94155aed62a5 100644 --- a/src/vs/workbench/test/browser/webview.test.ts +++ b/src/vs/workbench/test/browser/webview.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { parentOriginHash } from '../../../base/browser/iframe.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { areWebviewContentOptionsEqual } from '../../contrib/webview/browser/webview.js'; suite('parentOriginHash', () => { @@ -26,3 +27,20 @@ suite('parentOriginHash', () => { ensureNoDisposablesAreLeakedInTestSuite(); }); + + +suite('areWebviewContentOptionsEqual', () => { + test('includes shared session cookies', () => { + assert.strictEqual(areWebviewContentOptionsEqual({ + sharedSessionCookies: { allowedOrigins: ['https://example.com'] } + }, { + sharedSessionCookies: { allowedOrigins: ['https://example.com'] } + }), true); + + assert.strictEqual(areWebviewContentOptionsEqual({ + sharedSessionCookies: { allowedOrigins: ['https://example.com'] } + }, { + sharedSessionCookies: { allowedOrigins: ['https://example.org'] } + }), false); + }); +}); diff --git a/src/vscode-dts/vscode.d.ts b/src/vscode-dts/vscode.d.ts index 329826a2894cc..5b0ee472db6f0 100644 --- a/src/vscode-dts/vscode.d.ts +++ b/src/vscode-dts/vscode.d.ts @@ -9942,6 +9942,24 @@ declare module 'vscode' { * cannot be mapped to another port. */ readonly portMapping?: readonly WebviewPortMapping[]; + + /** + * Allow this desktop webview to send VS Code's existing Electron session cookies to a list of trusted origins. + * + * This setting is ignored in web/browser environments. + * + * Only the exact origins in {@link WebviewOptions.sharedSessionCookies.allowedOrigins allowedOrigins} are eligible. + * Cookie values remain inaccessible to webview JavaScript, including `document.cookie`. + */ + readonly sharedSessionCookies?: { + /** + * Trusted origins that may receive the current Electron session cookies. + * + * Each entry must be an absolute origin such as `https://example.com`. + * Non-HTTPS origins are only supported for localhost development origins. + */ + readonly allowedOrigins: readonly string[]; + }; } /**