diff --git a/package-lock.json b/package-lock.json index 06b022252b..8bad93256b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27535,6 +27535,490 @@ } } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/package.json b/package.json index 3f1931de06..b5ce31e39e 100644 --- a/package.json +++ b/package.json @@ -318,6 +318,7 @@ "typia": "^12.0.2" }, "overrides": { + "esbuild": "0.28.1", "app-builder-lib": { "minimatch": "10.2.5" }, @@ -336,6 +337,12 @@ "node-gyp": { "undici": "6.27.0" }, + "vite": { + "esbuild": "0.28.1" + }, + "tsup": { + "esbuild": "0.28.1" + }, "sockjs": { "uuid": "11.1.1" }, diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index b117b0cd7f..38cdbb0056 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -61,6 +61,7 @@ import { LS } from '../persistence/storage-keys.const'; import { Log } from '../log'; import { LayoutService } from '../../core-ui/layout/layout.service'; import { sanitizeIosKeyboardHeight } from './sanitize-ios-keyboard-height.util'; +import { sanitizeSvgIconContent } from '../../util/sanitize-svg-icon.util'; interface NavigationBarPlugin { setColor(options: { color: string; style: 'LIGHT' | 'DARK' }): Promise; @@ -385,9 +386,18 @@ export class GlobalThemeService { registerSvgIconFromContent(iconName: string, svgContent: string): void { // Plugin icon is already registered, skip if (this._registeredPluginIcons.has(iconName)) return; + const sanitizedSvg = sanitizeSvgIconContent(svgContent); + if ( + !sanitizedSvg || + !/]/i.test(sanitizedSvg) || + !/<\/svg>/i.test(sanitizedSvg) + ) { + Log.warn(`Skipping invalid SVG icon registration for ${iconName}`); + return; + } this._matIconRegistry.addSvgIconLiteral( iconName, - this._domSanitizer.bypassSecurityTrustHtml(svgContent), + this._domSanitizer.bypassSecurityTrustHtml(sanitizedSvg), ); this._registeredPluginIcons.add(iconName); } diff --git a/src/app/plugins/plugin.service.spec.ts b/src/app/plugins/plugin.service.spec.ts index 992c41a5b6..1ef86d03b8 100644 --- a/src/app/plugins/plugin.service.spec.ts +++ b/src/app/plugins/plugin.service.spec.ts @@ -31,6 +31,11 @@ describe('PluginService', () => { let pluginLoader: jasmine.SpyObj; let pluginBridge: jasmine.SpyObj; let pluginRunner: jasmine.SpyObj; + let globalThemeServiceMock: { + darkMode$: BehaviorSubject<'light'>; + darkMode: () => 'light'; + registerSvgIconFromContent: jasmine.Spy; + }; const mockManifest: PluginManifest = { id: 'test-plugin', @@ -89,6 +94,11 @@ describe('PluginService', () => { 'pingNodeBridge', 'sendMessageToPlugin', ]); + globalThemeServiceMock = { + darkMode$: new BehaviorSubject('light'), + darkMode: () => 'light', + registerSvgIconFromContent: jasmine.createSpy('registerSvgIconFromContent'), + }; TestBed.configureTestingModule({ providers: [ @@ -120,10 +130,7 @@ describe('PluginService', () => { }, { provide: GlobalThemeService, - useValue: { - darkMode$: new BehaviorSubject('light'), - darkMode: () => 'light', - }, + useValue: globalThemeServiceMock, }, { provide: PluginMetaPersistenceService, useValue: pluginMetaPersistenceService }, { @@ -338,6 +345,34 @@ describe('PluginService', () => { expect(pluginBridge.clearNodeExecutionConsent).toHaveBeenCalledOnceWith(pluginId); }); + it('sanitizes uploaded plugin SVG icons before registration', () => { + const sanitizeSvg = ( + service as unknown as { + _sanitizePluginIconSvg: (iconContent: string, iconPath: string) => string | null; + } + )._sanitizePluginIconSvg.bind(service); + + const result = sanitizeSvg( + '', + 'icon.svg', + ); + + expect(result).toContain(' { + const sanitizeSvg = ( + service as unknown as { + _sanitizePluginIconSvg: (iconContent: string, iconPath: string) => string | null; + } + )._sanitizePluginIconSvg.bind(service); + + const result = sanitizeSvg('
not svg
', 'icon.svg'); + + expect(result).toBeNull(); + }); + it('clearNodeExecutionConsent returns false when the persisted clear fails (caller can fail closed)', async () => { // A persistence failure must NOT throw (lifecycle bookkeeping ignores it), but must be // reported via the return value so loadPluginFromZip can abort before loading new code diff --git a/src/app/plugins/plugin.service.ts b/src/app/plugins/plugin.service.ts index a9755b47e4..e8b6557091 100644 --- a/src/app/plugins/plugin.service.ts +++ b/src/app/plugins/plugin.service.ts @@ -1,5 +1,5 @@ /* eslint-disable max-len */ -import { inject, Injectable, signal, OnDestroy } from '@angular/core'; +import { inject, Injectable, OnDestroy, signal } from '@angular/core'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { environment } from '../../environments/environment'; @@ -37,6 +37,7 @@ import { IssueSyncAdapterRegistryService } from '../features/issue/two-way-sync/ import { SnackService } from '../core/snack/snack.service'; import { pingWithRetry } from './util/ping-with-retry.util'; import { PluginBridgeService } from './plugin-bridge.service'; +import { sanitizeSvgIconContent } from '../util/sanitize-svg-icon.util'; // Each plugin's `id` (from its manifest.json, distinct from the asset path // here) becomes the entityId prefix for all data it persists via @@ -461,8 +462,24 @@ export class PluginService implements OnDestroy { } private _registerPluginIcon(pluginId: string, iconContent: string): void { + const sanitizedIcon = this._sanitizePluginIconSvg( + iconContent, + `${pluginId}/icon.svg`, + ); + if (!sanitizedIcon) { + return; + } const iconName = `plugin-${pluginId}-icon`; - this._globalThemeService.registerSvgIconFromContent(iconName, iconContent); + this._globalThemeService.registerSvgIconFromContent(iconName, sanitizedIcon); + } + + private _sanitizePluginIconSvg(iconContent: string, iconPath: string): string | null { + const sanitizedIcon = sanitizeSvgIconContent(iconContent); + if (!sanitizedIcon) { + PluginLog.err(`Plugin icon ${iconPath} does not appear to be a valid SVG`); + return null; + } + return sanitizedIcon; } private _setPluginState(pluginId: string, state: PluginState): void { @@ -1372,12 +1389,10 @@ export class PluginService implements OnDestroy { }), ); } - iconContent = new TextDecoder().decode(iconBytes); - // Basic SVG validation - if (!iconContent.includes('')) { - PluginLog.err(`Plugin icon ${manifest.icon} does not appear to be a valid SVG`); - iconContent = null; - } + iconContent = this._sanitizePluginIconSvg( + new TextDecoder().decode(iconBytes), + manifest.icon, + ); } // Extract config schema if specified in manifest diff --git a/src/app/plugins/ui/plugin-icon/plugin-icon.component.ts b/src/app/plugins/ui/plugin-icon/plugin-icon.component.ts index a049363e0c..03f3c1fabf 100644 --- a/src/app/plugins/ui/plugin-icon/plugin-icon.component.ts +++ b/src/app/plugins/ui/plugin-icon/plugin-icon.component.ts @@ -8,6 +8,7 @@ import { import { DomSanitizer } from '@angular/platform-browser'; import { PluginService } from '../../plugin.service'; import { MatIcon } from '@angular/material/icon'; +import { sanitizeSvgIconContent } from '../../../util/sanitize-svg-icon.util'; @Component({ selector: 'plugin-icon', @@ -69,6 +70,7 @@ export class PluginIconComponent { return null; } - return this._sanitizer.bypassSecurityTrustHtml(iconContent); + const sanitizedSvg = sanitizeSvgIconContent(iconContent); + return sanitizedSvg ? this._sanitizer.bypassSecurityTrustHtml(sanitizedSvg) : null; }); } diff --git a/src/app/util/sanitize-svg-icon.util.spec.ts b/src/app/util/sanitize-svg-icon.util.spec.ts new file mode 100644 index 0000000000..760b59869b --- /dev/null +++ b/src/app/util/sanitize-svg-icon.util.spec.ts @@ -0,0 +1,46 @@ +import { sanitizeSvgIconContent } from './sanitize-svg-icon.util'; + +describe('sanitizeSvgIconContent', () => { + it('removes scriptable event handlers', () => { + const result = sanitizeSvgIconContent( + '', + ); + + expect(result).toContain(' { + const result = sanitizeSvgIconContent( + '
x
', + ); + + expect(result).toContain(' { + const result = sanitizeSvgIconContent( + '', + ); + + expect(result).toContain(' { + const result = sanitizeSvgIconContent( + '', + ); + + expect(result).toContain(' { + expect(sanitizeSvgIconContent('
not svg
')).toBeNull(); + }); +}); diff --git a/src/app/util/sanitize-svg-icon.util.ts b/src/app/util/sanitize-svg-icon.util.ts new file mode 100644 index 0000000000..86116d7c34 --- /dev/null +++ b/src/app/util/sanitize-svg-icon.util.ts @@ -0,0 +1,56 @@ +const DANGEROUS_SVG_TAGS = [ + 'script', + 'foreignObject', + 'iframe', + 'object', + 'embed', + 'link', + 'meta', + 'style', +]; + +const URI_ATTRS = new Set(['href', 'xlink:href', 'src']); +const isSafeSvgReference = (attrValue: string): boolean => attrValue.startsWith('#'); + +export const sanitizeSvgIconContent = (svgContent: string): string | null => { + if (!svgContent.trim()) { + return null; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(svgContent, 'image/svg+xml'); + if (doc.querySelector('parsererror')) { + return null; + } + + const root = doc.documentElement; + if (!root || root.tagName.toLowerCase() !== 'svg') { + return null; + } + + for (const tagName of DANGEROUS_SVG_TAGS) { + for (const el of Array.from(doc.querySelectorAll(tagName))) { + el.remove(); + } + } + + for (const el of Array.from(doc.querySelectorAll('*'))) { + for (const attr of Array.from(el.attributes)) { + const attrName = attr.name.toLowerCase(); + const attrValue = attr.value.trim().toLowerCase(); + if (attrName.startsWith('on') || attrName === 'style') { + el.removeAttribute(attr.name); + continue; + } + if (URI_ATTRS.has(attrName) && !isSafeSvgReference(attrValue)) { + el.removeAttribute(attr.name); + } + } + } + + const sanitized = new XMLSerializer().serializeToString(root); + if (!/]/i.test(sanitized) || !/<\/svg>/i.test(sanitized)) { + return null; + } + return sanitized; +};