Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions src/scripts/components/button-component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { escapeHtml, escapeHtmlAttribute, sanitizeUrl } from '../utils/html';

export default class ButtonComponent extends HTMLElement {
static get observedAttributes() {
return ['href', 'title'];
Expand All @@ -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 = `
<button type="button" class="not-prose my-4">
<a href="${href}" class="cursor-pointer py-2 px-4 rounded bg-[#086dd7] hover:bg-[#2560ff] text-white!">
${title}
</a>
</button>
`;
<a
href="${href}"
class="not-prose my-4 inline-block cursor-pointer rounded bg-[#086dd7] px-4 py-2 text-white! hover:bg-[#2560ff]"
>
${title}
</a>
`;
}
}

Expand Down
26 changes: 20 additions & 6 deletions src/scripts/components/card-component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
escapeHtml,
escapeHtmlAttribute,
sanitizeAssetUrl,
sanitizeUrl,
} from '../utils/html';

class CardComponent extends HTMLElement {
static get observedAttributes() {
return ['imgsrc', 'href', 'title', 'description'];
Expand All @@ -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 = `
<div class="card">
<a href="${href}" class="card-link">
${
imgSrc
safeImgSrc
? `<div class="card-icon">
<img class="card-img" src="${imgSrc}" alt="${title}" />
<img class="card-img" src="${safeImgSrc}" alt="${titleAttribute}" />
</div>`
: ''
}
Expand Down
39 changes: 24 additions & 15 deletions src/scripts/components/contributor-component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
escapeHtml,
escapeHtmlAttribute,
sanitizeAssetUrl,
sanitizeUrl,
} from '../utils/html';

/**
* ContributorComponent
* 기여자 프로필을 카드 형식으로 표시하는 Web Component
Expand All @@ -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}(?<!-)$/.test(username);
}

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;
Expand Down Expand Up @@ -139,18 +140,26 @@ export default class ContributorComponent extends HTMLElement {
</a>
`;
} 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 = `
<a
href="${githubUrl}"
target="_blank"
rel="noopener noreferrer"
class="group block w-full"
aria-label="${username}의 GitHub 프로필 보기"
aria-label="${usernameAttribute}의 GitHub 프로필 보기"
>
<div class="
relative
Expand Down Expand Up @@ -200,7 +209,7 @@ export default class ContributorComponent extends HTMLElement {
">
<img
src="${avatarUrl}"
alt="${username}의 프로필 사진"
alt="${usernameAttribute}의 프로필 사진"
class="h-20 w-20 object-cover"
loading="lazy"
/>
Expand Down
28 changes: 11 additions & 17 deletions src/scripts/components/home-link-card-component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { escapeHtml, escapeHtmlAttribute, sanitizeUrl } from '../utils/html';

/**
* HomeLinkCardComponent
* 홈 화면에서 사용되는 링크 카드 Web Component
Expand Down Expand Up @@ -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 = `
<a
Expand Down
81 changes: 55 additions & 26 deletions src/scripts/components/nav-component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { escapeHtml, escapeHtmlAttribute, sanitizeUrl } from '../utils/html';

interface NavItem {
name: string;
docs_path?: string;
Expand Down Expand Up @@ -60,16 +62,23 @@ 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]) => {
const itemName = escapeHtml(item.name);
const hrefPath = escapeHtmlAttribute(sanitizeUrl(item.href_path));

if (item.children) {
return this.generateSectionWithChildren(item);
} else {
return `
<li class="rounded px-2 hover:text-blue-500 hover:dark:text-blue-500">
<a class="block w-full truncate py-2" href="${item.href_path}">
${item.name}
<a class="block w-full truncate py-2" href="${hrefPath}">
${itemName}
</a>
</li>
`;
Expand All @@ -79,19 +88,29 @@ 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);
} else {
return `
<li class="rounded px-2 hover:text-blue-500 hover:dark:text-blue-500">
<a class="block w-full truncate py-2"
href="${childItem.href_path}"
title="${childItem.name}">
${childItem.name}
href="${childHref}"
title="${childTitle}">
${childName}
</a>
</li>
`;
Expand All @@ -107,11 +126,11 @@ export default class NavComponent extends HTMLElement {
${
item.href_path
? `<a class="block select-none hover:text-blue-500 hover:dark:text-blue-500"
href="${item.href_path}">
${item.name}
href="${hrefPath}">
${itemName}
</a>`
: `<span class="block select-none">
${item.name}
${itemName}
</span>`
}
</div>
Expand All @@ -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]) => `
<li class="rounded px-2 hover:text-blue-500 hover:dark:text-blue-500">
<a class="block w-full truncate py-2"
href="${childItem.href_path}"
title="${childItem.name}">
${childItem.name}
href="${escapeHtmlAttribute(sanitizeUrl(childItem.href_path))}"
title="${escapeHtmlAttribute(childItem.name)}">
${escapeHtml(childItem.name)}
</a>
</li>
`
Expand All @@ -159,11 +182,11 @@ export default class NavComponent extends HTMLElement {
${
item.href_path
? `<a class="block select-none hover:text-blue-500 hover:dark:text-blue-500"
href="${item.href_path}">
${item.name}
href="${hrefPath}">
${itemName}
</a>`
: `<span class="block select-none">
${item.name}
${itemName}
</span>`
}
</div>
Expand Down Expand Up @@ -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 `
<li class="mb-2">
<h3 class="mb-2 text-lg font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
${category}
${safeCategory}
</h3>
<ul class="ml-0">
${items
.map(
(item) => `
.map((item) => {
const safeItem = escapeHtml(item);
const itemId = `${categoryId}-${this.normalizeCheckboxIdPart(item)}`;

return `
<li class="rounded px-2 hover:text-blue-500 hover:dark:text-blue-500">
<div class="flex items-center py-2">
<input type="checkbox" class="mr-2 rounded cursor-pointer" id="${category.toLowerCase()}-${item.toLowerCase().replace(/\s+/g, '-').replace(/[#+]/g, '')}">
<label class="block w-full truncate cursor-pointer" for="${category.toLowerCase()}-${item.toLowerCase().replace(/\s+/g, '-').replace(/[#+]/g, '')}">
${item}
<input type="checkbox" class="mr-2 rounded cursor-pointer" id="${escapeHtmlAttribute(itemId)}">
<label class="block w-full truncate cursor-pointer" for="${escapeHtmlAttribute(itemId)}">
${safeItem}
</label>
</div>
</li>
`
)
`;
})
.join('')}
</ul>
</li>
`
)
`;
})
.join('');
}

Expand Down
Loading