diff --git a/src/scripts/components/button-component.ts b/src/scripts/components/button-component.ts index 3dc55f2..f9614d2 100644 --- a/src/scripts/components/button-component.ts +++ b/src/scripts/components/button-component.ts @@ -1,3 +1,5 @@ +import { escapeHtml, escapeHtmlAttribute, sanitizeUrl } from '../utils/html'; + export default class ButtonComponent extends HTMLElement { static get observedAttributes() { return ['href', 'title']; @@ -16,16 +18,19 @@ export default class ButtonComponent extends HTMLElement { } render() { - const href = this.getAttribute('href') || '#'; - const title = this.getAttribute('title') || ''; + const href = escapeHtmlAttribute( + sanitizeUrl(this.getAttribute('href') || '#') + ); + const title = escapeHtml(this.getAttribute('title') || ''); this.innerHTML = ` - - `; + + ${title} + + `; } } diff --git a/src/scripts/components/card-component.ts b/src/scripts/components/card-component.ts index 97ab3c8..572673f 100644 --- a/src/scripts/components/card-component.ts +++ b/src/scripts/components/card-component.ts @@ -1,3 +1,10 @@ +import { + escapeHtml, + escapeHtmlAttribute, + sanitizeAssetUrl, + sanitizeUrl, +} from '../utils/html'; + class CardComponent extends HTMLElement { static get observedAttributes() { return ['imgsrc', 'href', 'title', 'description']; @@ -16,18 +23,25 @@ class CardComponent extends HTMLElement { } render() { - const imgSrc = this.getAttribute('imgsrc'); // 없으면 null - const href = this.getAttribute('href') || '#'; - const title = this.getAttribute('title') || ''; - const description = this.getAttribute('description') || ''; + const imgSrc = this.getAttribute('imgsrc'); + const safeImgSrc = imgSrc + ? escapeHtmlAttribute(sanitizeAssetUrl(imgSrc, '')) + : null; + const href = escapeHtmlAttribute( + sanitizeUrl(this.getAttribute('href') || '#') + ); + const rawTitle = this.getAttribute('title') || ''; + const title = escapeHtml(rawTitle); + const titleAttribute = escapeHtmlAttribute(rawTitle); + const description = escapeHtml(this.getAttribute('description') || ''); this.innerHTML = `
${ - imgSrc + safeImgSrc ? `
- ${title} + ${titleAttribute}
` : '' } diff --git a/src/scripts/components/contributor-component.ts b/src/scripts/components/contributor-component.ts index 457258a..14ebbd6 100644 --- a/src/scripts/components/contributor-component.ts +++ b/src/scripts/components/contributor-component.ts @@ -1,3 +1,10 @@ +import { + escapeHtml, + escapeHtmlAttribute, + sanitizeAssetUrl, + sanitizeUrl, +} from '../utils/html'; + /** * ContributorComponent * 기여자 프로필을 카드 형식으로 표시하는 Web Component @@ -20,20 +27,14 @@ export default class ContributorComponent extends HTMLElement { this.render(); } - /** - * HTML 특수 문자를 이스케이프 처리 - * XSS 공격 방지를 위한 보안 함수 - */ - private escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + private isValidGitHubUsername(username: string): boolean { + return /^(?!-)(?!.*--)[a-zA-Z0-9-]{1,39}(? `; } else { - const escapedUsername = this.escapeHtml(username); - const githubUrl = `https://github.com/${username}`; - const avatarUrl = - avatar || 'https://avatars.githubusercontent.com/u/0?v=4'; + const escapedUsername = escapeHtml(username); + const usernameAttribute = escapeHtmlAttribute(username); + const githubProfileUrl = this.isValidGitHubUsername(username) + ? `https://github.com/${username}` + : 'https://github.com/docker-ko/docker-ko.github.io/graphs/contributors'; + const githubUrl = escapeHtmlAttribute(sanitizeUrl(githubProfileUrl)); + const avatarUrl = escapeHtmlAttribute( + sanitizeAssetUrl( + avatar || 'https://avatars.githubusercontent.com/u/0?v=4', + 'https://avatars.githubusercontent.com/u/0?v=4' + ) + ); this.innerHTML = `
${username}의 프로필 사진 diff --git a/src/scripts/components/home-link-card-component.ts b/src/scripts/components/home-link-card-component.ts index 2bd77cc..a94d38a 100644 --- a/src/scripts/components/home-link-card-component.ts +++ b/src/scripts/components/home-link-card-component.ts @@ -1,3 +1,5 @@ +import { escapeHtml, escapeHtmlAttribute, sanitizeUrl } from '../utils/html'; + /** * HomeLinkCardComponent * 홈 화면에서 사용되는 링크 카드 Web Component @@ -58,29 +60,21 @@ export default class HomeLinkCardComponent extends HTMLElement { return href.startsWith('http://') || href.startsWith('https://'); } - /** - * HTML 특수 문자를 이스케이프 처리 - * XSS 공격 방지를 위한 보안 함수 - */ - private escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - render() { - const href = this.getAttribute('href') || '#'; + const safeHref = sanitizeUrl(this.getAttribute('href') || '#'); + const href = escapeHtmlAttribute(safeHref); const icon = this.getAttribute('icon') || 'rocket'; - const title = this.escapeHtml(this.getAttribute('title') || '제목'); - const description = this.escapeHtml( - this.getAttribute('description') || '설명' - ); + const rawTitle = this.getAttribute('title') || '제목'; + const title = escapeHtml(rawTitle); + const description = escapeHtml(this.getAttribute('description') || '설명'); - const isExternal = this.isExternalLink(href); + const isExternal = this.isExternalLink(safeHref); const externalAttrs = isExternal ? 'target="_blank" rel="noopener noreferrer"' : ''; - const ariaLabel = isExternal ? `${title} (새 창에서 열림)` : title; + const ariaLabel = escapeHtmlAttribute( + isExternal ? `${rawTitle} (새 창에서 열림)` : rawTitle + ); this.innerHTML = ` { + const itemName = escapeHtml(item.name); + const hrefPath = escapeHtmlAttribute(sanitizeUrl(item.href_path)); + if (item.children) { return this.generateSectionWithChildren(item); } else { return `
  • - - ${item.name} + + ${itemName}
  • `; @@ -79,9 +88,19 @@ export default class NavComponent extends HTMLElement { } private generateSectionWithChildren(item: NavItem): string { + const itemName = escapeHtml(item.name); + const hrefPath = item.href_path + ? escapeHtmlAttribute(sanitizeUrl(item.href_path)) + : ''; const childrenHtml = item.children ? Object.entries(item.children) .map(([, childItem]) => { + const childName = escapeHtml(childItem.name); + const childTitle = escapeHtmlAttribute(childItem.name); + const childHref = escapeHtmlAttribute( + sanitizeUrl(childItem.href_path) + ); + if (childItem.children) { // 3단계 중첩 (예: docker-concepts/the-basics/what-is-a-container) return this.generateNestedSection(childItem); @@ -89,9 +108,9 @@ export default class NavComponent extends HTMLElement { return `
  • - ${childItem.name} + href="${childHref}" + title="${childTitle}"> + ${childName}
  • `; @@ -107,11 +126,11 @@ export default class NavComponent extends HTMLElement { ${ item.href_path ? ` - ${item.name} + href="${hrefPath}"> + ${itemName} ` : ` - ${item.name} + ${itemName} ` }
    @@ -136,15 +155,19 @@ export default class NavComponent extends HTMLElement { } private generateNestedSection(item: NavItem): string { + const itemName = escapeHtml(item.name); + const hrefPath = item.href_path + ? escapeHtmlAttribute(sanitizeUrl(item.href_path)) + : ''; const childrenHtml = item.children ? Object.entries(item.children) .map( ([, childItem]) => `
  • - ${childItem.name} + href="${escapeHtmlAttribute(sanitizeUrl(childItem.href_path))}" + title="${escapeHtmlAttribute(childItem.name)}"> + ${escapeHtml(childItem.name)}
  • ` @@ -159,11 +182,11 @@ export default class NavComponent extends HTMLElement { ${ item.href_path ? ` - ${item.name} + href="${hrefPath}"> + ${itemName} ` : ` - ${item.name} + ${itemName} ` }
    @@ -191,31 +214,37 @@ export default class NavComponent extends HTMLElement { private generateGuidesNav(data: GuidesData): string { return Object.entries(data) - .map( - ([category, items]) => ` + .map(([category, items]) => { + const safeCategory = escapeHtml(category); + const categoryId = this.normalizeCheckboxIdPart(category); + + return `
  • - ${category} + ${safeCategory}

  • - ` - ) + `; + }) .join(''); } diff --git a/src/scripts/table-contents.ts b/src/scripts/table-contents.ts index 254dd0b..6ed59fd 100644 --- a/src/scripts/table-contents.ts +++ b/src/scripts/table-contents.ts @@ -1,5 +1,54 @@ import { getElement } from './utils/dom'; +const createHeadingSlug = ( + headingText: string, + fallbackIndex: number +): string => { + const slug = headingText + .trim() + .toLowerCase() + .replace(/[^\p{Letter}\p{Number}]+/gu, '-') + .replace(/^-+|-+$/g, ''); + + return slug || `toc-heading-${fallbackIndex}`; +}; + +const createUniqueHeadingId = ( + headingText: string, + fallbackIndex: number, + usedIds: Set +): string => { + const baseId = createHeadingSlug(headingText, fallbackIndex); + let nextId = baseId; + let suffix = 1; + + while (usedIds.has(nextId)) { + nextId = `${baseId}-${suffix}`; + suffix += 1; + } + + usedIds.add(nextId); + return nextId; +}; + +const removeLegacyAnchors = (content: HTMLElement): void => { + content + .querySelectorAll('[data-toc-legacy-anchor="true"]') + .forEach((anchor) => anchor.remove()); +}; + +const insertLegacyAnchor = (heading: Element, index: number): void => { + if (document.getElementById(index.toString())) { + return; + } + + const legacyAnchor = document.createElement('span'); + legacyAnchor.id = index.toString(); + legacyAnchor.setAttribute('data-toc-legacy-anchor', 'true'); + legacyAnchor.setAttribute('aria-hidden', 'true'); + heading.parentNode?.insertBefore(legacyAnchor, heading); +}; + const createObserver = (headingMap: Record) => { return new IntersectionObserver( (entries) => { @@ -43,6 +92,7 @@ export const initializeTableContents = (): void => { } toc.innerHTML = ''; + removeLegacyAnchors(content); const headings = content.querySelectorAll('h2, h3'); @@ -58,6 +108,13 @@ export const initializeTableContents = (): void => { tocTitle.textContent = 'Table of contents'; const headingMap: Record = {}; + const usedIds = new Set(); + + headings.forEach((heading) => { + if (heading.id) { + usedIds.add(heading.id); + } + }); headings.forEach((heading, index) => { const listItem = document.createElement('li'); @@ -106,8 +163,15 @@ export const initializeTableContents = (): void => { listItem.classList.add('pl-3'); } - heading.id = `${index}`; - headingMap[heading.id] = listItem; + const headingId = + heading.id || createUniqueHeadingId(headingText, index, usedIds); + + if (!heading.id) { + insertLegacyAnchor(heading, index); + } + + heading.id = headingId; + headingMap[headingId] = listItem; }); toc.appendChild(tocTitle); diff --git a/src/scripts/utils/html.ts b/src/scripts/utils/html.ts new file mode 100644 index 0000000..16d75ee --- /dev/null +++ b/src/scripts/utils/html.ts @@ -0,0 +1,83 @@ +export function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +export function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); +} + +function hasUnsafeProtocol(value: string): boolean { + const normalizedValue = value.trim().replace(/\s+/g, '').toLowerCase(); + const protocolCandidate = normalizedValue + .replace(/^[#/]+/, '') + .replace(/^(\.\/|\.\.\/)+/, ''); + + return ( + protocolCandidate.startsWith('javascript:') || + protocolCandidate.startsWith('data:') || + protocolCandidate.startsWith('vbscript:') + ); +} + +export function sanitizeUrl(value: string, fallback: string = '#'): string { + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return fallback; + } + + if (hasUnsafeProtocol(trimmedValue)) { + return fallback; + } + + if (trimmedValue.startsWith('//')) { + return fallback; + } + + if ( + trimmedValue === '/' || + trimmedValue.startsWith('#/') || + trimmedValue.startsWith('/#/') + ) { + return trimmedValue; + } + + const protocolMatch = trimmedValue.match(/^([a-z][a-z\d+.-]*):/i); + if (!protocolMatch) { + return fallback; + } + + const protocol = protocolMatch[1].toLowerCase(); + return ['http', 'https', 'mailto', 'tel'].includes(protocol) + ? trimmedValue + : fallback; +} + +export function sanitizeAssetUrl(value: string, fallback: string = ''): string { + const trimmedValue = value.trim(); + + if (!trimmedValue || hasUnsafeProtocol(trimmedValue)) { + return fallback; + } + + if ( + trimmedValue.startsWith('/') || + trimmedValue.startsWith('./') || + trimmedValue.startsWith('../') || + !trimmedValue.match(/^([a-z][a-z\d+.-]*):/i) + ) { + return trimmedValue.startsWith('//') ? fallback : trimmedValue; + } + + const protocol = trimmedValue + .match(/^([a-z][a-z\d+.-]*):/i)?.[1] + .toLowerCase(); + return ['http', 'https'].includes(protocol || '') ? trimmedValue : fallback; +} diff --git a/tests/html-utils.test.ts b/tests/html-utils.test.ts new file mode 100644 index 0000000..97bfdb8 --- /dev/null +++ b/tests/html-utils.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + escapeHtml, + escapeHtmlAttribute, + sanitizeAssetUrl, + sanitizeUrl, +} from '../src/scripts/utils/html'; +import { + setupTestEnvironment, + type TestEnvironment, +} from './helpers/test-environment'; + +let testEnv: TestEnvironment; + +beforeAll(() => { + testEnv = setupTestEnvironment(); +}); + +afterAll(() => { + testEnv.cleanup(); +}); + +describe('html utils', () => { + it('escapeHtml이 HTML 태그를 이스케이프함', () => { + expect(escapeHtml('')).toBe( + '<script>alert(1)</script>' + ); + }); + + it('escapeHtmlAttribute가 따옴표와 특수 문자를 이스케이프함', () => { + expect(escapeHtmlAttribute('"test" & ')).toBe( + '"test" & <tag>' + ); + }); + + it('sanitizeUrl이 안전한 내부 경로를 유지함', () => { + expect(sanitizeUrl('/')).toBe('/'); + expect(sanitizeUrl('/#/get-started')).toBe('/#/get-started'); + expect(sanitizeUrl('#/guides')).toBe('#/guides'); + }); + + it('sanitizeUrl이 javascript 스킴을 차단함', () => { + expect(sanitizeUrl('javascript:alert(1)')).toBe('#'); + expect(sanitizeUrl('/#/javascript:alert(1)')).toBe('#'); + expect(sanitizeUrl('./javascript:alert(1)')).toBe('#'); + expect(sanitizeUrl('data:text/html,')).toBe('#'); + }); + + it('sanitizeUrl이 http/https URL은 허용함', () => { + expect(sanitizeUrl('https://example.com/docs')).toBe( + 'https://example.com/docs' + ); + expect(sanitizeUrl('http://example.com/docs')).toBe( + 'http://example.com/docs' + ); + }); + + it('sanitizeUrl이 mailto/tel 스킴을 허용함', () => { + expect(sanitizeUrl('mailto:test@example.com')).toBe( + 'mailto:test@example.com' + ); + expect(sanitizeUrl('tel:+821012345678')).toBe('tel:+821012345678'); + }); + + it('sanitizeUrl이 비해시 상대 경로를 차단함', () => { + expect(sanitizeUrl('/docs/page')).toBe('#'); + expect(sanitizeUrl('./page')).toBe('#'); + expect(sanitizeUrl('foo/bar')).toBe('#'); + }); + + it('sanitizeAssetUrl이 안전한 에셋 경로를 허용함', () => { + expect(sanitizeAssetUrl('images/foo.png')).toBe('images/foo.png'); + expect(sanitizeAssetUrl('./images/foo.png')).toBe('./images/foo.png'); + expect(sanitizeAssetUrl('https://example.com/image.png')).toBe( + 'https://example.com/image.png' + ); + }); +}); diff --git a/tests/table-contents.test.ts b/tests/table-contents.test.ts index d3d6a76..e450265 100644 --- a/tests/table-contents.test.ts +++ b/tests/table-contents.test.ts @@ -142,9 +142,26 @@ describe('initializeTableContents', () => { const h2Elements = contentElement.querySelectorAll('h2'); // Assert - h2Elements.forEach((heading, index) => { - expect(heading.id).toBe(index.toString()); - }); + expect(h2Elements[0].id).toBe('the-basics'); + expect(h2Elements[1].id).toBe('building-images'); + expect(h2Elements[2].id).toBe('running-containers'); + }); + + it('기존 헤딩 id가 있으면 유지됨', async () => { + const firstHeading = contentElement.querySelector('h2'); + firstHeading!.id = 'existing-heading-id'; + + initializeTableContents(); + + expect(firstHeading!.id).toBe('existing-heading-id'); + }); + + it('기존 숫자 앵커 호환용 legacy anchor를 생성함', async () => { + initializeTableContents(); + + expect(document.getElementById('0')).toBeTruthy(); + expect(document.getElementById('1')).toBeTruthy(); + expect(document.getElementById('2')).toBeTruthy(); }); it('button 클릭 시 해당 섹션으로 스크롤 이동이 정확히 실행되는지 확인', async () => {