From 1649e19e16e7c5a261ebb52d800853ff3126b041 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:43:22 +0000 Subject: [PATCH 1/7] =?UTF-8?q?[fix]=20=EC=9B=B9=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=9E=A0?= =?UTF-8?q?=EC=9E=AC=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: elecbug <81972855+elecbug@users.noreply.github.com> --- src/scripts/components/button-component.ts | 21 +++-- src/scripts/components/card-component.ts | 21 +++-- .../components/contributor-component.ts | 33 ++++---- .../components/home-link-card-component.ts | 28 +++---- src/scripts/components/nav-component.ts | 83 +++++++++++++------ src/scripts/table-contents.ts | 5 +- src/scripts/utils/html.ts | 45 ++++++++++ tests/html-utils.test.ts | 52 ++++++++++++ tests/table-contents.test.ts | 11 ++- 9 files changed, 222 insertions(+), 77 deletions(-) create mode 100644 src/scripts/utils/html.ts create mode 100644 tests/html-utils.test.ts 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..07927b6 100644 --- a/src/scripts/components/card-component.ts +++ b/src/scripts/components/card-component.ts @@ -1,3 +1,5 @@ +import { escapeHtml, escapeHtmlAttribute, sanitizeUrl } from '../utils/html'; + class CardComponent extends HTMLElement { static get observedAttributes() { return ['imgsrc', 'href', 'title', 'description']; @@ -16,18 +18,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(sanitizeUrl(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..2c74e8d 100644 --- a/src/scripts/components/contributor-component.ts +++ b/src/scripts/components/contributor-component.ts @@ -1,3 +1,5 @@ +import { escapeHtml, escapeHtmlAttribute, sanitizeUrl } from '../utils/html'; + /** * ContributorComponent * 기여자 프로필을 카드 형식으로 표시하는 Web Component @@ -20,20 +22,10 @@ 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; - } - render() { const username = this.getAttribute('username'); const avatar = this.getAttribute('avatar'); - const role = this.escapeHtml(this.getAttribute('role') || '기여자'); + const role = escapeHtml(this.getAttribute('role') || '기여자'); // 빈 카드 모드 (username이 없으면 기여하기 카드) const isEmptyCard = !username; @@ -139,10 +131,17 @@ export default class ContributorComponent extends HTMLElement {
`; } 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 githubUrl = escapeHtmlAttribute( + sanitizeUrl(`https://github.com/${username}`) + ); + const avatarUrl = escapeHtmlAttribute( + sanitizeUrl( + 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 +84,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 +104,9 @@ export default class NavComponent extends HTMLElement { return `
  • - ${childItem.name} + href="${childHref}" + title="${childTitle}"> + ${childName}
  • `; @@ -107,11 +122,11 @@ export default class NavComponent extends HTMLElement { ${ item.href_path ? ` - ${item.name} + href="${hrefPath}"> + ${itemName} ` : ` - ${item.name} + ${itemName} ` }
    @@ -136,15 +151,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 +178,11 @@ export default class NavComponent extends HTMLElement { ${ item.href_path ? ` - ${item.name} + href="${hrefPath}"> + ${itemName} ` : ` - ${item.name} + ${itemName} ` }
    @@ -191,31 +210,43 @@ 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 = category + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[#+]/g, ''); + + return `
  • - ${category} + ${safeCategory}

  • - ` - ) + `; + }) .join(''); } diff --git a/src/scripts/table-contents.ts b/src/scripts/table-contents.ts index 254dd0b..2c48e3a 100644 --- a/src/scripts/table-contents.ts +++ b/src/scripts/table-contents.ts @@ -106,8 +106,9 @@ export const initializeTableContents = (): void => { listItem.classList.add('pl-3'); } - heading.id = `${index}`; - headingMap[heading.id] = listItem; + const headingId = heading.id || `toc-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..7240bb1 --- /dev/null +++ b/src/scripts/utils/html.ts @@ -0,0 +1,45 @@ +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, '>'); +} + +export function sanitizeUrl(value: string, fallback: string = '#'): string { + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return fallback; + } + + if ( + trimmedValue.startsWith('#') || + trimmedValue.startsWith('/') || + trimmedValue.startsWith('./') || + trimmedValue.startsWith('../') + ) { + return trimmedValue; + } + + try { + const baseUrl = window.location.href.startsWith('http') + ? window.location.href + : 'https://docker-ko.github.io/'; + const parsedUrl = new URL(trimmedValue, baseUrl); + if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') { + return trimmedValue; + } + } catch { + // Invalid URLs fall back to a safe value + } + + return fallback; +} diff --git a/tests/html-utils.test.ts b/tests/html-utils.test.ts new file mode 100644 index 0000000..a2f4674 --- /dev/null +++ b/tests/html-utils.test.ts @@ -0,0 +1,52 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + escapeHtml, + escapeHtmlAttribute, + 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('/#/get-started')).toBe('/#/get-started'); + expect(sanitizeUrl('#/guides')).toBe('#/guides'); + }); + + it('sanitizeUrl이 javascript 스킴을 차단함', () => { + expect(sanitizeUrl('javascript:alert(1)')).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' + ); + }); +}); diff --git a/tests/table-contents.test.ts b/tests/table-contents.test.ts index d3d6a76..c0271e2 100644 --- a/tests/table-contents.test.ts +++ b/tests/table-contents.test.ts @@ -143,10 +143,19 @@ describe('initializeTableContents', () => { // Assert h2Elements.forEach((heading, index) => { - expect(heading.id).toBe(index.toString()); + expect(heading.id).toBe(`toc-heading-${index}`); }); }); + it('기존 헤딩 id가 있으면 유지됨', async () => { + const firstHeading = contentElement.querySelector('h2'); + firstHeading!.id = 'existing-heading-id'; + + initializeTableContents(); + + expect(firstHeading!.id).toBe('existing-heading-id'); + }); + it('button 클릭 시 해당 섹션으로 스크롤 이동이 정확히 실행되는지 확인', async () => { // Arrange initializeTableContents(); From 6d4147b93ea0f423f7cf84aaf24aacca5715524a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:44:56 +0000 Subject: [PATCH 2/7] =?UTF-8?q?[fix]=20=EC=9B=B9=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20URL=20=EC=A0=95=EC=A0=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: elecbug <81972855+elecbug@users.noreply.github.com> --- src/scripts/components/contributor-component.ts | 7 ++++--- src/scripts/utils/html.ts | 17 +++++++++++++++++ tests/html-utils.test.ts | 3 +++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/scripts/components/contributor-component.ts b/src/scripts/components/contributor-component.ts index 2c74e8d..c61ceba 100644 --- a/src/scripts/components/contributor-component.ts +++ b/src/scripts/components/contributor-component.ts @@ -133,9 +133,10 @@ export default class ContributorComponent extends HTMLElement { } else { const escapedUsername = escapeHtml(username); const usernameAttribute = escapeHtmlAttribute(username); - const githubUrl = escapeHtmlAttribute( - sanitizeUrl(`https://github.com/${username}`) - ); + const githubProfileUrl = `https://github.com/${encodeURIComponent( + username + )}`; + const githubUrl = escapeHtmlAttribute(sanitizeUrl(githubProfileUrl)); const avatarUrl = escapeHtmlAttribute( sanitizeUrl( avatar || 'https://avatars.githubusercontent.com/u/0?v=4', diff --git a/src/scripts/utils/html.ts b/src/scripts/utils/html.ts index 7240bb1..22bf2f0 100644 --- a/src/scripts/utils/html.ts +++ b/src/scripts/utils/html.ts @@ -13,6 +13,19 @@ export function escapeHtmlAttribute(value: string): string { .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(); @@ -20,6 +33,10 @@ export function sanitizeUrl(value: string, fallback: string = '#'): string { return fallback; } + if (hasUnsafeProtocol(trimmedValue)) { + return fallback; + } + if ( trimmedValue.startsWith('#') || trimmedValue.startsWith('/') || diff --git a/tests/html-utils.test.ts b/tests/html-utils.test.ts index a2f4674..63195b8 100644 --- a/tests/html-utils.test.ts +++ b/tests/html-utils.test.ts @@ -39,6 +39,9 @@ describe('html utils', () => { 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은 허용함', () => { From ebcef0043de79d992493aa6d192d890861ec158a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:45:53 +0000 Subject: [PATCH 3/7] =?UTF-8?q?[fix]=20URL=20=EC=A0=95=EC=A0=9C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: elecbug <81972855+elecbug@users.noreply.github.com> --- src/scripts/components/nav-component.ts | 14 ++++++-------- src/scripts/utils/html.ts | 25 +++++++------------------ 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/src/scripts/components/nav-component.ts b/src/scripts/components/nav-component.ts index edbfd92..5e7bd9f 100644 --- a/src/scripts/components/nav-component.ts +++ b/src/scripts/components/nav-component.ts @@ -62,6 +62,10 @@ export default class NavComponent extends HTMLElement { ); } + private normalizeCheckboxIdPart(value: string): string { + return value.toLowerCase().replace(/\s+/g, '-').replace(/[#+]/g, ''); + } + private generateGetStartedNav(data: GetStartedData): string { return Object.entries(data) .map(([, item]) => { @@ -212,10 +216,7 @@ export default class NavComponent extends HTMLElement { return Object.entries(data) .map(([category, items]) => { const safeCategory = escapeHtml(category); - const categoryId = category - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[#+]/g, ''); + const categoryId = this.normalizeCheckboxIdPart(category); return `
  • @@ -226,10 +227,7 @@ export default class NavComponent extends HTMLElement { ${items .map((item) => { const safeItem = escapeHtml(item); - const itemId = `${categoryId}-${item - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[#+]/g, '')}`; + const itemId = `${categoryId}-${this.normalizeCheckboxIdPart(item)}`; return `
  • diff --git a/src/scripts/utils/html.ts b/src/scripts/utils/html.ts index 22bf2f0..77a8857 100644 --- a/src/scripts/utils/html.ts +++ b/src/scripts/utils/html.ts @@ -37,26 +37,15 @@ export function sanitizeUrl(value: string, fallback: string = '#'): string { return fallback; } - if ( - trimmedValue.startsWith('#') || - trimmedValue.startsWith('/') || - trimmedValue.startsWith('./') || - trimmedValue.startsWith('../') - ) { - return trimmedValue; + if (trimmedValue.startsWith('//')) { + return fallback; } - try { - const baseUrl = window.location.href.startsWith('http') - ? window.location.href - : 'https://docker-ko.github.io/'; - const parsedUrl = new URL(trimmedValue, baseUrl); - if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') { - return trimmedValue; - } - } catch { - // Invalid URLs fall back to a safe value + const protocolMatch = trimmedValue.match(/^([a-z][a-z\d+.-]*):/i); + if (!protocolMatch) { + return trimmedValue; } - return fallback; + const protocol = protocolMatch[1].toLowerCase(); + return protocol === 'http' || protocol === 'https' ? trimmedValue : fallback; } From 6da10901b2b3c8d30aa4fedfe4f70349f845e813 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:46:41 +0000 Subject: [PATCH 4/7] =?UTF-8?q?[fix]=20URL=20=EC=8A=A4=ED=82=B4=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9=20=EB=B2=94=EC=9C=84=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: elecbug <81972855+elecbug@users.noreply.github.com> --- src/scripts/utils/html.ts | 4 +++- tests/html-utils.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/scripts/utils/html.ts b/src/scripts/utils/html.ts index 77a8857..92599b3 100644 --- a/src/scripts/utils/html.ts +++ b/src/scripts/utils/html.ts @@ -47,5 +47,7 @@ export function sanitizeUrl(value: string, fallback: string = '#'): string { } const protocol = protocolMatch[1].toLowerCase(); - return protocol === 'http' || protocol === 'https' ? trimmedValue : fallback; + return ['http', 'https', 'mailto', 'tel'].includes(protocol) + ? trimmedValue + : fallback; } diff --git a/tests/html-utils.test.ts b/tests/html-utils.test.ts index 63195b8..4fbf0b7 100644 --- a/tests/html-utils.test.ts +++ b/tests/html-utils.test.ts @@ -52,4 +52,11 @@ describe('html utils', () => { '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'); + }); }); From 767858b22c4e49b80e7e30d0adbbca8c3e981d1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:47:33 +0000 Subject: [PATCH 5/7] =?UTF-8?q?[fix]=20=EA=B8=B0=EC=97=AC=EC=9E=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20URL=20=EC=A0=95=EA=B7=9C=ED=99=94?= =?UTF-8?q?=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: elecbug <81972855+elecbug@users.noreply.github.com> --- src/scripts/components/contributor-component.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/scripts/components/contributor-component.ts b/src/scripts/components/contributor-component.ts index c61ceba..b5d42e9 100644 --- a/src/scripts/components/contributor-component.ts +++ b/src/scripts/components/contributor-component.ts @@ -22,6 +22,10 @@ export default class ContributorComponent extends HTMLElement { this.render(); } + private normalizeGitHubUsername(username: string): string { + return username.replace(/[^a-zA-Z0-9-]/g, '') || 'docker-ko'; + } + render() { const username = this.getAttribute('username'); const avatar = this.getAttribute('avatar'); @@ -133,9 +137,7 @@ export default class ContributorComponent extends HTMLElement { } else { const escapedUsername = escapeHtml(username); const usernameAttribute = escapeHtmlAttribute(username); - const githubProfileUrl = `https://github.com/${encodeURIComponent( - username - )}`; + const githubProfileUrl = `https://github.com/${this.normalizeGitHubUsername(username)}`; const githubUrl = escapeHtmlAttribute(sanitizeUrl(githubProfileUrl)); const avatarUrl = escapeHtmlAttribute( sanitizeUrl( From 0a7fa502dbffe01a05f16e5e14c0a540cebefb12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:48:54 +0000 Subject: [PATCH 6/7] =?UTF-8?q?[fix]=20URL=20=EC=A0=95=EC=A0=9C=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: elecbug <81972855+elecbug@users.noreply.github.com> --- src/scripts/components/card-component.ts | 9 ++++-- .../components/contributor-component.ts | 9 ++++-- src/scripts/utils/html.ts | 32 ++++++++++++++++++- tests/html-utils.test.ts | 16 ++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/scripts/components/card-component.ts b/src/scripts/components/card-component.ts index 07927b6..572673f 100644 --- a/src/scripts/components/card-component.ts +++ b/src/scripts/components/card-component.ts @@ -1,4 +1,9 @@ -import { escapeHtml, escapeHtmlAttribute, sanitizeUrl } from '../utils/html'; +import { + escapeHtml, + escapeHtmlAttribute, + sanitizeAssetUrl, + sanitizeUrl, +} from '../utils/html'; class CardComponent extends HTMLElement { static get observedAttributes() { @@ -20,7 +25,7 @@ class CardComponent extends HTMLElement { render() { const imgSrc = this.getAttribute('imgsrc'); const safeImgSrc = imgSrc - ? escapeHtmlAttribute(sanitizeUrl(imgSrc, '')) + ? escapeHtmlAttribute(sanitizeAssetUrl(imgSrc, '')) : null; const href = escapeHtmlAttribute( sanitizeUrl(this.getAttribute('href') || '#') diff --git a/src/scripts/components/contributor-component.ts b/src/scripts/components/contributor-component.ts index b5d42e9..3f9b01a 100644 --- a/src/scripts/components/contributor-component.ts +++ b/src/scripts/components/contributor-component.ts @@ -1,4 +1,9 @@ -import { escapeHtml, escapeHtmlAttribute, sanitizeUrl } from '../utils/html'; +import { + escapeHtml, + escapeHtmlAttribute, + sanitizeAssetUrl, + sanitizeUrl, +} from '../utils/html'; /** * ContributorComponent @@ -140,7 +145,7 @@ export default class ContributorComponent extends HTMLElement { const githubProfileUrl = `https://github.com/${this.normalizeGitHubUsername(username)}`; const githubUrl = escapeHtmlAttribute(sanitizeUrl(githubProfileUrl)); const avatarUrl = escapeHtmlAttribute( - sanitizeUrl( + sanitizeAssetUrl( avatar || 'https://avatars.githubusercontent.com/u/0?v=4', 'https://avatars.githubusercontent.com/u/0?v=4' ) diff --git a/src/scripts/utils/html.ts b/src/scripts/utils/html.ts index 92599b3..16d75ee 100644 --- a/src/scripts/utils/html.ts +++ b/src/scripts/utils/html.ts @@ -41,9 +41,17 @@ export function sanitizeUrl(value: string, fallback: string = '#'): string { return fallback; } + if ( + trimmedValue === '/' || + trimmedValue.startsWith('#/') || + trimmedValue.startsWith('/#/') + ) { + return trimmedValue; + } + const protocolMatch = trimmedValue.match(/^([a-z][a-z\d+.-]*):/i); if (!protocolMatch) { - return trimmedValue; + return fallback; } const protocol = protocolMatch[1].toLowerCase(); @@ -51,3 +59,25 @@ export function sanitizeUrl(value: string, fallback: string = '#'): string { ? 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 index 4fbf0b7..97bfdb8 100644 --- a/tests/html-utils.test.ts +++ b/tests/html-utils.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { escapeHtml, escapeHtmlAttribute, + sanitizeAssetUrl, sanitizeUrl, } from '../src/scripts/utils/html'; import { @@ -33,6 +34,7 @@ describe('html utils', () => { }); it('sanitizeUrl이 안전한 내부 경로를 유지함', () => { + expect(sanitizeUrl('/')).toBe('/'); expect(sanitizeUrl('/#/get-started')).toBe('/#/get-started'); expect(sanitizeUrl('#/guides')).toBe('#/guides'); }); @@ -59,4 +61,18 @@ describe('html utils', () => { ); 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' + ); + }); }); From 82635835430af2c56618ae92d412f6f1f871f636 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:50:12 +0000 Subject: [PATCH 7/7] =?UTF-8?q?[fix]=20TOC=20=EC=95=B5=EC=BB=A4=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=EC=84=B1=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: elecbug <81972855+elecbug@users.noreply.github.com> --- .../components/contributor-component.ts | 8 ++- src/scripts/table-contents.ts | 65 ++++++++++++++++++- tests/table-contents.test.ts | 14 +++- 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/scripts/components/contributor-component.ts b/src/scripts/components/contributor-component.ts index 3f9b01a..14ebbd6 100644 --- a/src/scripts/components/contributor-component.ts +++ b/src/scripts/components/contributor-component.ts @@ -27,8 +27,8 @@ export default class ContributorComponent extends HTMLElement { this.render(); } - private normalizeGitHubUsername(username: string): string { - return username.replace(/[^a-zA-Z0-9-]/g, '') || 'docker-ko'; + private isValidGitHubUsername(username: string): boolean { + return /^(?!-)(?!.*--)[a-zA-Z0-9-]{1,39}(? { + 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,7 +163,13 @@ export const initializeTableContents = (): void => { listItem.classList.add('pl-3'); } - const headingId = heading.id || `toc-heading-${index}`; + const headingId = + heading.id || createUniqueHeadingId(headingText, index, usedIds); + + if (!heading.id) { + insertLegacyAnchor(heading, index); + } + heading.id = headingId; headingMap[headingId] = listItem; }); diff --git a/tests/table-contents.test.ts b/tests/table-contents.test.ts index c0271e2..e450265 100644 --- a/tests/table-contents.test.ts +++ b/tests/table-contents.test.ts @@ -142,9 +142,9 @@ describe('initializeTableContents', () => { const h2Elements = contentElement.querySelectorAll('h2'); // Assert - h2Elements.forEach((heading, index) => { - expect(heading.id).toBe(`toc-heading-${index}`); - }); + expect(h2Elements[0].id).toBe('the-basics'); + expect(h2Elements[1].id).toBe('building-images'); + expect(h2Elements[2].id).toBe('running-containers'); }); it('기존 헤딩 id가 있으면 유지됨', async () => { @@ -156,6 +156,14 @@ describe('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 () => { // Arrange initializeTableContents();