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..2d2293e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.18.1", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.18.1", + "version": "0.19.0", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.3", @@ -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..3964a66 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.18.1", + "version": "0.19.0", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", @@ -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 `