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>
This commit is contained in:
ghostkeni 2026-07-16 23:12:19 +09:00
parent 71a9a4338a
commit bcd4cd19dc
8 changed files with 669 additions and 14 deletions

484
package-lock.json generated
View file

@ -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",

View file

@ -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"
},

View file

@ -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<void>;
@ -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 ||
!/<svg[\s>]/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);
}

View file

@ -31,6 +31,11 @@ describe('PluginService', () => {
let pluginLoader: jasmine.SpyObj<PluginLoaderService>;
let pluginBridge: jasmine.SpyObj<PluginBridgeService>;
let pluginRunner: jasmine.SpyObj<PluginRunner>;
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(
'<svg onload="alert(1)"><circle cx="5" cy="5" r="4"></circle></svg>',
'icon.svg',
);
expect(result).toContain('<svg');
expect(result).not.toContain('onload');
});
it('rejects uploaded plugin icon content that is not SVG', () => {
const sanitizeSvg = (
service as unknown as {
_sanitizePluginIconSvg: (iconContent: string, iconPath: string) => string | null;
}
)._sanitizePluginIconSvg.bind(service);
const result = sanitizeSvg('<div>not svg</div>', '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

View file

@ -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('<svg') || !iconContent.includes('</svg>')) {
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

View file

@ -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;
});
}

View file

@ -0,0 +1,46 @@
import { sanitizeSvgIconContent } from './sanitize-svg-icon.util';
describe('sanitizeSvgIconContent', () => {
it('removes scriptable event handlers', () => {
const result = sanitizeSvgIconContent(
'<svg onload="alert(1)"><circle cx="5" cy="5" r="4"></circle></svg>',
);
expect(result).toContain('<svg');
expect(result).not.toContain('onload');
});
it('removes dangerous script and foreignObject nodes', () => {
const result = sanitizeSvgIconContent(
'<svg><script>alert(1)</script><foreignObject><div>x</div></foreignObject><circle /></svg>',
);
expect(result).toContain('<svg');
expect(result).toContain('<circle');
expect(result).not.toContain('<script');
expect(result).not.toContain('foreignObject');
});
it('removes javascript URLs from links', () => {
const result = sanitizeSvgIconContent(
'<svg><a href="javascript:alert(1)"><circle /></a></svg>',
);
expect(result).toContain('<svg');
expect(result).not.toContain('javascript:');
});
it('removes non-fragment href values', () => {
const result = sanitizeSvgIconContent(
'<svg><use href="https://example.com/icon.svg#x"></use><use href="#local"></use></svg>',
);
expect(result).toContain('<svg');
expect(result).not.toContain('https://example.com');
expect(result).toContain('href="#local"');
});
it('returns null for non-svg input', () => {
expect(sanitizeSvgIconContent('<div>not svg</div>')).toBeNull();
});
});

View file

@ -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 (!/<svg[\s>]/i.test(sanitized) || !/<\/svg>/i.test(sanitized)) {
return null;
}
return sanitized;
};