diff --git a/web/src/compositions/usePipeline.ts b/web/src/compositions/usePipeline.ts index 61142149d3..a566a97424 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 0000000000..707b2a009b --- /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 5340d52a20..dfe7b6c1a3 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, '''); +}