diff --git a/vscode/packages/microsoft-kiota/src/providers/dependenciesViewProvider.ts b/vscode/packages/microsoft-kiota/src/providers/dependenciesViewProvider.ts index 5eb56a6752..3e505cc16a 100644 --- a/vscode/packages/microsoft-kiota/src/providers/dependenciesViewProvider.ts +++ b/vscode/packages/microsoft-kiota/src/providers/dependenciesViewProvider.ts @@ -4,6 +4,8 @@ import { } from '@microsoft/kiota'; import * as vscode from 'vscode'; +import { escapeHtml } from '../utilities/html'; + export class DependenciesViewProvider implements vscode.WebviewViewProvider { private _view?: vscode.WebviewView; public constructor( @@ -14,8 +16,7 @@ export class DependenciesViewProvider implements vscode.WebviewViewProvider { public resolveWebviewView(webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, token: vscode.CancellationToken): void | Thenable { this._view = webviewView; webviewView.webview.options = { - // Allow scripts in the webview - enableScripts: true, + enableScripts: false, localResourceRoots: [ this._extensionUri @@ -50,25 +51,26 @@ export class DependenciesViewProvider implements vscode.WebviewViewProvider { if (dependenciesList.filter(dep => dep.DependencyType === DependencyType.bundle).length > 0) { dependenciesList = dependenciesList.filter(dep => dep.DependencyType === DependencyType.bundle || dep.DependencyType === DependencyType.additional || dep.DependencyType === DependencyType.authentication); } - const installationBlock = this._languageInformation?.DependencyInstallCommand ? `

${installationCommands}

-
${dependenciesList.map(dep => this._languageInformation!.DependencyInstallCommand.replace(/\{0\}/g, dep.Name).replace(/\{1\}/g, dep.Version)).join('\n')}
` + const installationBlock = this._languageInformation?.DependencyInstallCommand ? `

${escapeHtml(installationCommands)}

+
${dependenciesList.map(dep => escapeHtml(this._languageInformation!.DependencyInstallCommand.replace(/\{0\}/g, dep.Name).replace(/\{1\}/g, dep.Version))).join('\n')}
` : ''; return ` + - ${title} + ${escapeHtml(title)} -

${this._language !== undefined ? generationLanguageToString(this._language) : noLanguageSelected}

-

${dependencies}

+

${this._language !== undefined ? escapeHtml(generationLanguageToString(this._language)) : escapeHtml(noLanguageSelected)}

+

${escapeHtml(dependencies)}

- - ${dependenciesList.map(dep => ``).join('')} + + ${dependenciesList.map(dep => ``).join('')}
${name}${version}${type}
${dep.Name}${dep.Version}${dependencyTypeToString(dep.DependencyType)}
${escapeHtml(name)}${escapeHtml(version)}${escapeHtml(type)}
${escapeHtml(dep.Name)}${escapeHtml(dep.Version)}${escapeHtml(dependencyTypeToString(dep.DependencyType))}
${installationBlock} diff --git a/vscode/packages/microsoft-kiota/src/test/suite/providers/dependenciesViewProvider.test.ts b/vscode/packages/microsoft-kiota/src/test/suite/providers/dependenciesViewProvider.test.ts new file mode 100644 index 0000000000..d9077c4bd7 --- /dev/null +++ b/vscode/packages/microsoft-kiota/src/test/suite/providers/dependenciesViewProvider.test.ts @@ -0,0 +1,85 @@ +import { DependencyType, KiotaGenerationLanguage, LanguageInformation, MaturityLevel } from "@microsoft/kiota"; +import assert from "assert"; +import * as vscode from 'vscode'; + +import { DependenciesViewProvider } from "../../../providers/dependenciesViewProvider"; + +function createFakeWebview(): vscode.Webview { + return { + cspSource: "vscode-webview://fake-csp-source", + asWebviewUri: (uri: vscode.Uri) => uri, + html: "", + options: {}, + onDidReceiveMessage: (() => ({ dispose: () => { } })) as any, + postMessage: (async () => true) as any, + } as unknown as vscode.Webview; +} + +suite('DependenciesViewProvider Test Suite', () => { + const extensionUri = vscode.Uri.parse('file:///fake-extension'); + + test('escapes malicious dependency name/version to prevent XSS', () => { + const maliciousLanguageInformation: LanguageInformation = { + // eslint-disable-next-line @typescript-eslint/naming-convention + MaturityLevel: MaturityLevel.stable, + // eslint-disable-next-line @typescript-eslint/naming-convention + ClientNamespaceName: 'ns', + // eslint-disable-next-line @typescript-eslint/naming-convention + ClientClassName: 'cls', + // eslint-disable-next-line @typescript-eslint/naming-convention + StructuredMimeTypes: [], + // eslint-disable-next-line @typescript-eslint/naming-convention + DependencyInstallCommand: 'install {0}@{1}', + // eslint-disable-next-line @typescript-eslint/naming-convention + Dependencies: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + Name: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + Version: '">', + // eslint-disable-next-line @typescript-eslint/naming-convention + DependencyType: DependencyType.bundle + } + ] + }; + + const provider = new DependenciesViewProvider(extensionUri, maliciousLanguageInformation, KiotaGenerationLanguage.CSharp); + const webview = createFakeWebview(); + // resolveWebviewView sets webview.html via the private _getHtmlForWebview method + (provider as any).resolveWebviewView({ webview, show: () => { } } as unknown as vscode.WebviewView, {} as vscode.WebviewViewResolveContext, {} as vscode.CancellationToken); + + const html = webview.html; + + assert.ok(!html.includes(''), 'raw script tag must not appear unescaped'); + assert.ok(!html.includes(''), 'raw injected img tag must not appear unescaped'); + assert.ok(html.includes('<script>'), 'dependency name should be HTML-escaped'); + assert.ok(html.includes('"><img'), 'dependency version should be HTML-escaped'); + }); + + test('includes a Content-Security-Policy meta tag', () => { + const languageInformation: LanguageInformation = { + // eslint-disable-next-line @typescript-eslint/naming-convention + MaturityLevel: MaturityLevel.stable, + // eslint-disable-next-line @typescript-eslint/naming-convention + ClientNamespaceName: 'ns', + // eslint-disable-next-line @typescript-eslint/naming-convention + ClientClassName: 'cls', + // eslint-disable-next-line @typescript-eslint/naming-convention + StructuredMimeTypes: [], + // eslint-disable-next-line @typescript-eslint/naming-convention + DependencyInstallCommand: 'install {0}@{1}', + // eslint-disable-next-line @typescript-eslint/naming-convention + Dependencies: [ + // eslint-disable-next-line @typescript-eslint/naming-convention + { Name: 'SomeDep', Version: '1.0.0', DependencyType: DependencyType.bundle } + ] + }; + + const provider = new DependenciesViewProvider(extensionUri, languageInformation, KiotaGenerationLanguage.CSharp); + const webview = createFakeWebview(); + (provider as any).resolveWebviewView({ webview, show: () => { } } as unknown as vscode.WebviewView, {} as vscode.WebviewViewResolveContext, {} as vscode.CancellationToken); + + assert.ok(webview.html.includes('Content-Security-Policy'), 'expected a CSP meta tag in the webview HTML'); + assert.ok(webview.html.includes("script-src 'none'"), 'expected script-src to be disabled entirely'); + }); +}); diff --git a/vscode/packages/microsoft-kiota/src/utilities/html.ts b/vscode/packages/microsoft-kiota/src/utilities/html.ts new file mode 100644 index 0000000000..58df3fa0f6 --- /dev/null +++ b/vscode/packages/microsoft-kiota/src/utilities/html.ts @@ -0,0 +1,12 @@ +/** + * Escapes HTML-significant characters in a string so it can be safely + * interpolated into webview HTML without introducing script/markup injection. + */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}