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 = `
@@ -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();