Use better uri checking for webview resource roots

Fixes #286373
This commit is contained in:
Matt Bierner 2026-02-03 09:25:53 -08:00
parent 424ef28d9b
commit 8e2c1ac873
No known key found for this signature in database
GPG Key ID: 87BD15F7203A4CF2
4 changed files with 261 additions and 18 deletions

View File

@ -5,12 +5,11 @@
import { VSBufferReadableStream } from '../../../../base/common/buffer.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { isUNC } from '../../../../base/common/extpath.js';
import { Schemas } from '../../../../base/common/network.js';
import { normalize, sep } from '../../../../base/common/path.js';
import { URI } from '../../../../base/common/uri.js';
import { FileOperationError, FileOperationResult, IFileService, IWriteFileOptions } from '../../../../platform/files/common/files.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
import { getWebviewContentMimeType } from '../../../../platform/webview/common/mimeTypes.js';
export namespace WebviewResourceResponse {
@ -48,11 +47,12 @@ export async function loadLocalResource(
ifNoneMatch: string | undefined;
roots: ReadonlyArray<URI>;
},
uriIdentityService: IUriIdentityService,
fileService: IFileService,
logService: ILogService,
token: CancellationToken,
): Promise<WebviewResourceResponse.StreamResponse> {
const resourceToLoad = getResourceToLoad(requestUri, options.roots);
const resourceToLoad = getResourceToLoad(requestUri, options.roots, uriIdentityService);
logService.trace(`Webview.loadLocalResource - trying to load resource. requestUri=${requestUri}, resourceToLoad=${resourceToLoad}`);
@ -84,12 +84,13 @@ export async function loadLocalResource(
}
}
function getResourceToLoad(
export function getResourceToLoad(
requestUri: URI,
roots: ReadonlyArray<URI>,
uriIdentityService: IUriIdentityService,
): URI | undefined {
for (const root of roots) {
if (containsResource(root, requestUri)) {
if (containsResource(root, requestUri, uriIdentityService)) {
return normalizeResourcePath(requestUri);
}
}
@ -97,20 +98,15 @@ function getResourceToLoad(
return undefined;
}
function containsResource(root: URI, resource: URI): boolean {
if (root.scheme !== resource.scheme) {
function containsResource(root: URI, resource: URI, uriIdentityService: IUriIdentityService): boolean {
if (uriIdentityService.extUri.isEqual(root, resource, /* ignoreFragment */ true)) {
return false;
}
let resourceFsPath = normalize(resource.fsPath);
let rootPath = normalize(root.fsPath + (root.fsPath.endsWith(sep) ? '' : sep));
if (isUNC(root.fsPath) && isUNC(resource.fsPath)) {
rootPath = rootPath.toLowerCase();
resourceFsPath = resourceFsPath.toLowerCase();
}
return resourceFsPath.startsWith(rootPath);
return uriIdentityService.extUri.isEqualOrParent(
resource.with({ query: '' }),
root,
/* ignoreFragment */ true);
}
function normalizeResourcePath(resource: URI): URI {

View File

@ -30,6 +30,7 @@ import { ILogService } from '../../../../platform/log/common/log.js';
import { INotificationService } from '../../../../platform/notification/common/notification.js';
import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js';
import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js';
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
import { WebviewPortMappingManager } from '../../../../platform/webview/common/webviewPortMapping.js';
import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';
import { decodeAuthority, webviewGenericCspSource, webviewRootResourceAuthority } from '../common/webview.js';
@ -163,6 +164,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi
@ITunnelService private readonly _tunnelService: ITunnelService,
@IInstantiationService instantiationService: IInstantiationService,
@IAccessibilityService private readonly _accessibilityService: IAccessibilityService,
@IUriIdentityService private readonly _uriIdentityService: IUriIdentityService,
) {
super();
@ -763,7 +765,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi
const result = await loadLocalResource(uri, {
ifNoneMatch,
roots: this._content.options.localResourceRoots || [],
}, this._fileService, this._logService, this._resourceLoadingCts.token);
}, this._uriIdentityService, this._fileService, this._logService, this._resourceLoadingCts.token);
switch (result.type) {
case WebviewResourceResponse.Type.Success: {

View File

@ -19,6 +19,7 @@ import { INativeHostService } from '../../../../platform/native/common/native.js
import { INotificationService } from '../../../../platform/notification/common/notification.js';
import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js';
import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js';
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
import { FindInFrameOptions, IWebviewManagerService } from '../../../../platform/webview/common/webviewManagerService.js';
import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';
import { WebviewThemeDataProvider } from '../browser/themeing.js';
@ -56,10 +57,11 @@ export class ElectronWebviewElement extends WebviewElement {
@INativeHostService private readonly _nativeHostService: INativeHostService,
@IInstantiationService instantiationService: IInstantiationService,
@IAccessibilityService accessibilityService: IAccessibilityService,
@IUriIdentityService uriIdentityService: IUriIdentityService,
) {
super(initInfo, webviewThemeDataProvider,
configurationService, contextMenuService, notificationService, environmentService,
fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService);
fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService, uriIdentityService);
this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, _nativeHostService);

View File

@ -0,0 +1,243 @@
/*---------------------------------------------------------------------------------------------
* 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 { isWindows } from '../../../../../base/common/platform.js';
import { URI } from '../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { FileService } from '../../../../../platform/files/common/fileService.js';
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js';
import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js';
import { getResourceToLoad } from '../../browser/resourceLoading.js';
suite('Webview Resource Loading - getResourceToLoad', () => {
const disposableStore = ensureNoDisposablesAreLeakedInTestSuite();
let uriIdentityService: IUriIdentityService;
setup(() => {
const instantiationService = disposableStore.add(new TestInstantiationService());
instantiationService.stub(ILogService, NullLogService);
const fileService = disposableStore.add(new FileService(instantiationService.get(ILogService)));
uriIdentityService = instantiationService.stub(IUriIdentityService, disposableStore.add(new UriIdentityService(fileService)));
});
test('Returns resource when file is under root', () => {
const root = URI.file('/home/user/project');
const resource = URI.file('/home/user/project/file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.toString(), resource.toString());
});
test('Returns resource when file is in nested directory', () => {
const root = URI.file('/home/user/project');
const resource = URI.file('/home/user/project/subdir/nested/file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.toString(), resource.toString());
});
test('Fails when file is outside root', () => {
const root = URI.file('/home/user/project');
const resource = URI.file('/home/user/other/file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
});
test('Fails when file is root', () => {
const root = URI.file('/home/user/project');
const result = getResourceToLoad(root, [root], uriIdentityService);
assert.strictEqual(result, undefined);
});
test('Fails when file is sibling of root directory', () => {
const root = URI.file('/home/user/project');
{
const resource = URI.file('/home/user/projectOther/file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
}
{
const resource = URI.file('/home/user/project.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
}
});
test('Returns resource when root ends with /', () => {
const root = URI.file('/home/user/project/');
const resource = URI.file('/home/user/project/file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.toString(), resource.toString());
});
test('Fails for sibling when root ends with / ', () => {
const root = URI.file('/home/user/project/');
const resource = URI.file('/home/user/projectOther/file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
});
(!isWindows /* UNC is windows only */ ? suite.skip : suite)('UNC paths', () => {
test('Returns resource when file is under UNC root', () => {
const root = URI.file('\\\\server\\share\\folder');
const resource = URI.file('\\\\server\\share\\folder\\file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.toString(), resource.toString());
});
test('Returns resource with case-insensitive comparison for UNC paths', () => {
const root = URI.file('\\\\SERVER\\SHARE\\folder');
const resource = URI.file('\\\\server\\share\\folder\\file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.toString(), resource.toString());
});
test('Fails when file is outside UNC root', () => {
const root = URI.file('\\\\server\\share\\folder');
const resource = URI.file('\\\\server\\share\\other\\file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
});
test('Fails when UNC server differs', () => {
const root = URI.file('\\\\server1\\share\\folder');
const resource = URI.file('\\\\server2\\share\\folder\\file.txt');
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
});
});
suite('Different authorities', () => {
test('Returns resource when authorities match', () => {
const root = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+myserver', path: '/home/user/project' });
const resource = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+myserver', path: '/home/user/project/file.txt' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.ok(result);
});
test('Fails when authorities differ', () => {
const root = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+server1', path: '/home/user/project' });
const resource = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+server2', path: '/home/user/project/file.txt' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
});
test('handles empty authority', () => {
const root = URI.from({ scheme: 'test-scheme', authority: '', path: '/home/user/project' });
const resource = URI.from({ scheme: 'test-scheme', authority: '', path: '/home/user/project/file.txt' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.toString(), resource.toString());
});
});
suite('Different schemes', () => {
test('Fails when schemes differ', () => {
const root = URI.from({ scheme: 'file', path: '/home/user/project' });
const resource = URI.from({ scheme: 'http', path: '/home/user/project/file.txt' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
});
test('Returns resource when schemes match', () => {
const root = URI.from({ scheme: 'custom-scheme', path: '/home/user/project' });
const resource = URI.from({ scheme: 'custom-scheme', path: '/home/user/project/file.txt' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.toString(), resource.toString());
});
test('normalizes vscode-remote scheme', () => {
const root = URI.from({ scheme: 'vscode-remote', authority: 'test', path: '/home/user/project' });
const resource = URI.from({ scheme: 'vscode-remote', authority: 'test', path: '/home/user/project/file.txt' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.ok(result);
assert.strictEqual(result.scheme, 'vscode-remote');
assert.strictEqual(result.authority, 'test');
assert.strictEqual(result.path, '/vscode-resource');
const query = JSON.parse(result.query);
assert.strictEqual(query.requestResourcePath, '/home/user/project/file.txt');
});
});
suite('Fragment and query strings', () => {
test('preserves fragment in returned URI', () => {
const root = URI.file('/home/user/project');
const resource = URI.file('/home/user/project/file.txt').with({ fragment: 'section1' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.fragment, 'section1');
});
test('preserves query in returned URI', () => {
const root = URI.file('/home/user/project');
const resource = URI.file('/home/user/project/file.txt').with({ query: 'version=2' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.query, 'version=2');
});
test('preserves both fragment and query', () => {
const root = URI.file('/home/user/project');
const resource = URI.file('/home/user/project/file.txt').with({ fragment: 'section1', query: 'version=2' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result?.fragment, 'section1');
assert.strictEqual(result?.query, 'version=2');
});
test('still validates path containment with query params', () => {
const root = URI.file('/home/user/project');
const resource = URI.file('/home/user/other/file.txt').with({ query: 'version=2' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
});
test('still validates path containment with fragment', () => {
const root = URI.file('/home/user/project');
const resource = URI.file('/home/user/other/file.txt').with({ fragment: 'section1' });
const result = getResourceToLoad(resource, [root], uriIdentityService);
assert.strictEqual(result, undefined);
});
});
suite('Multiple roots', () => {
test('Returns resource when file is under one of multiple roots', () => {
const roots = [
URI.file('/home/user/project1'),
URI.file('/home/user/project2'),
URI.file('/home/user/project3')
];
const resource = URI.file('/home/user/project2/file.txt');
const result = getResourceToLoad(resource, roots, uriIdentityService);
assert.strictEqual(result?.toString(), resource.toString());
});
test('Fails when file is not under any root', () => {
const roots = [
URI.file('/home/user/project1'),
URI.file('/home/user/project2')
];
const resource = URI.file('/home/user/other/file.txt');
const result = getResourceToLoad(resource, roots, uriIdentityService);
assert.strictEqual(result, undefined);
});
test('Returns resource matching first valid root', () => {
const roots = [
URI.file('/home/user/project'),
URI.file('/home/user/project/subdir')
];
const resource = URI.file('/home/user/project/subdir/file.txt');
const result = getResourceToLoad(resource, roots, uriIdentityService);
// Should match first root in the list
assert.strictEqual(result?.toString(), resource.toString());
});
test('handles empty roots array', () => {
const resource = URI.file('/home/user/project/file.txt');
const result = getResourceToLoad(resource, [], uriIdentityService);
assert.strictEqual(result, undefined);
});
});
});