From 79f0cf82183aedce424d8f7648e43e068fcf5dde Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:53:03 +0100 Subject: [PATCH 1/2] refactor(webview): render webview documents from TSX views (#325) Move every webview document from template literals into TSX views rendered with preact-render-to-string. Escaping is now on by default, markup errors fail tsc, and lint bans inline styles, handlers and raw HTML outside Page.tsx. --- eslint.config.js | 47 +- package-lock.json | 31 ++ package.json | 2 + src/providers/componentBrowserProvider.ts | 519 +++------------------ src/webview/csp.ts | 10 +- src/webview/render.ts | 23 + src/webview/views/ComponentBrowserView.tsx | 224 +++++++++ src/webview/views/ComponentDetailsView.tsx | 200 ++++++++ src/webview/views/ErrorView.tsx | 43 ++ src/webview/views/ErrorsView.tsx | 51 ++ src/webview/views/LoadingView.tsx | 18 + src/webview/views/NoSourcesView.tsx | 48 ++ src/webview/views/Page.tsx | 78 ++++ src/webview/webviewHtml.ts | 6 +- tests/unit/csp.test.ts | 22 +- tests/unit/loadingView.test.ts | 33 ++ tests/unit/webviewBuilderMarkup.test.ts | 290 +++++++----- tsconfig.json | 2 + 18 files changed, 1061 insertions(+), 586 deletions(-) create mode 100644 src/webview/render.ts create mode 100644 src/webview/views/ComponentBrowserView.tsx create mode 100644 src/webview/views/ComponentDetailsView.tsx create mode 100644 src/webview/views/ErrorView.tsx create mode 100644 src/webview/views/ErrorsView.tsx create mode 100644 src/webview/views/LoadingView.tsx create mode 100644 src/webview/views/NoSourcesView.tsx create mode 100644 src/webview/views/Page.tsx create mode 100644 tests/unit/loadingView.test.ts diff --git a/eslint.config.js b/eslint.config.js index f5cf907..601370b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,37 @@ const js = require('@eslint/js'); const tseslint = require('typescript-eslint'); const globals = require('globals'); +/** + * JSX the webview views must not contain. + * + * @param {{ allowRawHtml?: boolean }} [options] `allowRawHtml` admits `dangerouslySetInnerHTML`, for `Page.tsx` only. + * @returns {{ selector: string, message: string }[]} `no-restricted-syntax` entries. + */ +function viewRestrictions({ allowRawHtml = false } = {}) { + return [ + { + selector: "JSXAttribute[name.name='style']", + message: 'Inline styles are blocked by the webview CSP; add a class in src/webview/styles.', + }, + { + selector: 'JSXAttribute[name.name=/^on/]', + message: 'Inline handlers are blocked by the webview CSP; use a data-action attribute.', + }, + { + selector: "JSXOpeningElement[name.name='script']:not(:has(JSXAttribute[name.name=/^(src|type)$/]))", + message: 'Inline scripts are blocked by the webview CSP; put code in src/webview/client.', + }, + { + selector: "JSXOpeningElement[name.name='style']", + message: 'Inline styles are blocked by the webview CSP; add a stylesheet in src/webview/styles.', + }, + ...(allowRawHtml ? [] : [{ + selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']", + message: 'Raw HTML bypasses escaping; use JsonScript or InlineMarkdown from Page.tsx.', + }]), + ]; +} + /** @type {import('eslint').Linter.Config[]} */ module.exports = [ { @@ -20,7 +51,7 @@ module.exports = [ }, }, ...tseslint.config({ - files: ['**/*.ts'], + files: ['**/*.ts', '**/*.tsx'], extends: [...tseslint.configs.recommended], rules: { '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], @@ -51,6 +82,20 @@ module.exports = [ ...js.configs.recommended.rules, }, }, + { + // Webview views render under a nonce CSP: inline styles and handlers are blocked, and raw HTML bypasses escaping. + files: ['src/webview/views/**/*.tsx'], + rules: { + 'no-restricted-syntax': ['error', ...viewRestrictions()], + }, + }, + { + // The two helpers that emit pre-sanitised content are the only place raw HTML is allowed. + files: ['src/webview/views/Page.tsx'], + rules: { + 'no-restricted-syntax': ['error', ...viewRestrictions({ allowRawHtml: true })], + }, + }, { // Webview client scripts run in the Electron renderer, not the extension host files: ['src/webview/client/**/*.ts'], diff --git a/package-lock.json b/package-lock.json index 2bfee12..41da2ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,8 @@ "minimatch": "^10.2.6", "mocha": "^12.0.2", "npm-run-all": "^4.1.5", + "preact": "^10.29.8", + "preact-render-to-string": "^6.7.0", "release-it": "^21.1.0", "semver": "^7.8.5", "stylelint": "^17.15.0", @@ -8175,6 +8177,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/preact-render-to-string": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.7.0.tgz", + "integrity": "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": ">=10 || >= 11.0.0-0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index faf0e9b..39137cc 100644 --- a/package.json +++ b/package.json @@ -349,6 +349,8 @@ "minimatch": "^10.2.6", "mocha": "^12.0.2", "npm-run-all": "^4.1.5", + "preact": "^10.29.8", + "preact-render-to-string": "^6.7.0", "release-it": "^21.1.0", "semver": "^7.8.5", "stylelint": "^17.15.0", diff --git a/src/providers/componentBrowserProvider.ts b/src/providers/componentBrowserProvider.ts index 6a63baa..f1bc29d 100644 --- a/src/providers/componentBrowserProvider.ts +++ b/src/providers/componentBrowserProvider.ts @@ -2,22 +2,27 @@ import * as vscode from 'vscode'; import { getComponentService } from '../services/component'; import { ComponentCacheManager } from '../services/cache/componentCacheManager'; import { GitLabCatalogComponent, GitLabCatalogVariable } from '../types/gitlab-catalog'; -import type { ComponentParameter, Component } from './componentDetector'; +import type { Component } from './componentDetector'; import { resolveServerFqdn } from './componentDetector'; import { isVersionLookupShape } from '../services/component/versionLookupShape'; import type { SourceGroup, ComponentGroup, ComponentVersion } from './componentBrowserTypes'; import type { HoverContext } from './hoverContentBuilder'; import { Logger } from '../utils/logger'; import { templateFileUrlForResolved } from '../utils/templateFileUrl'; -import { escapeHtml, renderInlineMarkdown } from '../webview/inlineMarkdown'; -import { serializeForScript } from '../webview/scriptData'; import { generateComponentText } from './componentBrowserGenerate'; import { findComponentLineRange, parseExistingComponentText } from './componentBrowserEdit'; import { chooseComponentVersion, transformCachedComponentsToGroups } from './componentBrowserTransform'; import { LATEST_VERSION_PREFERENCE, versionPreferenceKey } from './versionPreferences'; import { readVersionPreferences, saveVersionPreference } from './versionPreferenceSettings'; -import { buildVersionLabels, compileTagTemplate, stripTagPrefix } from '../services/component/tagScoping'; -import { assetRoots, assetUri, createNonce, cspMetaTag } from '../webview/webviewHtml'; +import { buildVersionLabels } from '../services/component/tagScoping'; +import { assetRoots, assetUri, createNonce } from '../webview/webviewHtml'; +import { renderDocument } from '../webview/render'; +import { ComponentBrowserView } from '../webview/views/ComponentBrowserView'; +import { ComponentDetailsView } from '../webview/views/ComponentDetailsView'; +import { ErrorsView } from '../webview/views/ErrorsView'; +import { ErrorView } from '../webview/views/ErrorView'; +import { LoadingView } from '../webview/views/LoadingView'; +import { NoSourcesView } from '../webview/views/NoSourcesView'; import { safeHttpUrl } from '../webview/safeUrl'; /** @@ -611,50 +616,7 @@ export class ComponentBrowserProvider { private getLoadingHtml(webview: vscode.Webview): string { const nonce = createNonce(); const styleUri = assetUri(webview, this.context.extensionUri, 'styles/loading.css'); - return ` - - - - - - ${cspMetaTag(webview.cspSource, nonce)} - - GitLab CI/CD Components - - -
-
-

Loading GitLab CI/CD components...

-
- - - `; - } - - /** - * Render the `