From 1ffa588f87bed0824fafc73785463b6aae50f83b Mon Sep 17 00:00:00 2001 From: wucm667 <109257021+wucm667@users.noreply.github.com> Date: Fri, 1 May 2026 09:07:20 +0800 Subject: [PATCH] fix(web): escape HTML in commit messages to prevent XSS (#6523) Signed-off-by: wucm667 --- web/src/compositions/usePipeline.ts | 5 ++-- web/src/lib/utils.test.ts | 45 +++++++++++++++++++++++++++++ web/src/lib/utils/index.ts | 9 ++++++ 3 files changed, 57 insertions(+), 2 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 61142149d..a566a9742 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,10 +76,10 @@ export default (pipeline: Ref) => { return prettyDuration(durationElapsed.value); }); - const message = computed(() => emojify(pipeline.value?.message ?? '')); + 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(() => { diff --git a/web/src/lib/utils.test.ts b/web/src/lib/utils.test.ts new file mode 100644 index 000000000..707b2a009 --- /dev/null +++ b/web/src/lib/utils.test.ts @@ -0,0 +1,45 @@ +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 5340d52a2..dfe7b6c1a 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, '''); +}