From bcd4cd19dc4126affc56c8d1f5ed20eab27b36b4 Mon Sep 17 00:00:00 2001 From: ghostkeni Date: Thu, 16 Jul 2026 23:12:19 +0900 Subject: [PATCH] fix(plugins): sanitize untrusted plugin svg icons Harden plugin icon handling by sanitizing uploaded SVG content before persistence, registration, and rendering to prevent scriptable payloads in trusted HTML sinks. Also tighten SVG URL handling to fragment-only references and add tests for sanitizer behavior across plugin flows. Add dependency overrides to mitigate vulnerable esbuild usage in the audited toolchain where possible. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 484 ++++++++++++++++++ package.json | 7 + src/app/core/theme/global-theme.service.ts | 12 +- src/app/plugins/plugin.service.spec.ts | 43 +- src/app/plugins/plugin.service.ts | 31 +- .../ui/plugin-icon/plugin-icon.component.ts | 4 +- src/app/util/sanitize-svg-icon.util.spec.ts | 46 ++ src/app/util/sanitize-svg-icon.util.ts | 56 ++ 8 files changed, 669 insertions(+), 14 deletions(-) create mode 100644 src/app/util/sanitize-svg-icon.util.spec.ts create mode 100644 src/app/util/sanitize-svg-icon.util.ts 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; +};