From 88cf66a8fb3e18f7adc7172e4d58d0cbaafc4d0f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 30 Apr 2026 18:38:06 +0800 Subject: [PATCH 1/3] fix(web): escape HTML in commit messages to prevent XSS Commit messages containing HTML tags (e.g. ) were being rendered as actual HTML elements in the web UI because RenderMarkdown uses v-html with DOMPurify, which allows safe HTML. Add HTML escaping to the message and title computed properties in usePipeline.ts before they reach the markdown renderer, ensuring all special characters are displayed as plain text. Fixes #6522 Signed-off-by: wucm667 --- web/src/compositions/usePipeline.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/src/compositions/usePipeline.ts b/web/src/compositions/usePipeline.ts index 61142149d37..889619c453b 100644 --- a/web/src/compositions/usePipeline.ts +++ b/web/src/compositions/usePipeline.ts @@ -75,10 +75,19 @@ export default (pipeline: Ref) => { return prettyDuration(durationElapsed.value); }); - const message = computed(() => emojify(pipeline.value?.message ?? '')); + function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + const message = computed(() => emojify(escapeHtml(pipeline.value?.message ?? ''))); const shortMessage = computed(() => message.value.split('\n')[0]); - const prTitleWithDescription = computed(() => emojify(pipeline.value?.title ?? '')); + const prTitleWithDescription = computed(() => emojify(escapeHtml(pipeline.value?.title ?? ''))); const prTitle = computed(() => prTitleWithDescription.value.split('\n')[0]); const prettyRef = computed(() => { From b68de5e88dd969689c56c07d56d3cbac38ad6b10 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Fri, 1 May 2026 05:21:33 +0800 Subject: [PATCH 2/3] refactor(web): move escapeHtml helper to utils and add tests - Extract escapeHtml from usePipeline.ts to web/src/lib/utils/index.ts - Add comprehensive unit tests for escapeHtml in utils.test.ts - Addresses maintainer review comments on PR #6523 --- web/src/compositions/usePipeline.ts | 10 +----- web/src/lib/utils.test.ts | 47 +++++++++++++++++++++++++++++ web/src/lib/utils/index.ts | 9 ++++++ 3 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/utils.test.ts diff --git a/web/src/compositions/usePipeline.ts b/web/src/compositions/usePipeline.ts index 889619c453b..a566a97424c 100644 --- a/web/src/compositions/usePipeline.ts +++ b/web/src/compositions/usePipeline.ts @@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'; import { useDate } from '~/compositions/useDate'; import { useElapsedTime } from '~/compositions/useElapsedTime'; import type { Pipeline } from '~/lib/api/types'; +import { escapeHtml } from '~/lib/utils'; const { toLocaleString, timeAgo, prettyDuration } = useDate(); @@ -75,15 +76,6 @@ export default (pipeline: Ref) => { return prettyDuration(durationElapsed.value); }); - function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - const message = computed(() => emojify(escapeHtml(pipeline.value?.message ?? ''))); const shortMessage = computed(() => message.value.split('\n')[0]); diff --git a/web/src/lib/utils.test.ts b/web/src/lib/utils.test.ts new file mode 100644 index 00000000000..5a094a5495a --- /dev/null +++ b/web/src/lib/utils.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { escapeHtml } from './utils'; + +describe('escapeHtml', () => { + it('should return plain text unchanged', () => { + expect(escapeHtml('hello world')).toBe('hello world'); + }); + + it('should return empty string unchanged', () => { + expect(escapeHtml('')).toBe(''); + }); + + it('should escape HTML tags', () => { + expect(escapeHtml('bold')).toBe('<b>bold</b>'); + expect(escapeHtml('')).toBe( + '<script>alert("xss")</script>', + ); + }); + + it('should escape ampersands', () => { + expect(escapeHtml('foo & bar')).toBe('foo & bar'); + expect(escapeHtml('a&&b')).toBe('a&&b'); + }); + + it('should escape double quotes', () => { + expect(escapeHtml('say "hello"')).toBe('say "hello"'); + }); + + it('should escape single quotes', () => { + expect(escapeHtml("it's")).toBe('it's'); + }); + + it('should escape greater-than signs', () => { + expect(escapeHtml('a > b')).toBe('a > b'); + }); + + it('should escape mixed content', () => { + expect(escapeHtml(`it's & that's all`)).toBe( + '<a href="foo">it's & that's <b>all</b>', + ); + }); + + it('should escape already-escaped ampersands', () => { + expect(escapeHtml('&')).toBe('&amp;'); + }); +}); diff --git a/web/src/lib/utils/index.ts b/web/src/lib/utils/index.ts index 5340d52a20c..dfe7b6c1a3e 100644 --- a/web/src/lib/utils/index.ts +++ b/web/src/lib/utils/index.ts @@ -11,3 +11,12 @@ export function debounce(fn: (...args: T) => void, delay: n export function deepClone(value: T): T { return JSON.parse(JSON.stringify(toRaw(value))) as T; } + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} From 8938e67f40045533581b4187c61754db3c610ab3 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Fri, 1 May 2026 02:57:16 +0200 Subject: [PATCH 3/3] format --- web/src/lib/utils.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/lib/utils.test.ts b/web/src/lib/utils.test.ts index 5a094a5495a..707b2a009bf 100644 --- a/web/src/lib/utils.test.ts +++ b/web/src/lib/utils.test.ts @@ -13,9 +13,7 @@ describe('escapeHtml', () => { it('should escape HTML tags', () => { expect(escapeHtml('bold')).toBe('<b>bold</b>'); - expect(escapeHtml('')).toBe( - '<script>alert("xss")</script>', - ); + expect(escapeHtml('')).toBe('<script>alert("xss")</script>'); }); it('should escape ampersands', () => {