공개 페이지 전환 경고 정리
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
<script setup>
|
||||
import { sanitizeHtmlBlock } from '../../lib/markdown-html-block.js'
|
||||
|
||||
const props = defineProps({
|
||||
html: {
|
||||
type: String,
|
||||
@@ -8,6 +6,203 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const allowedHtmlBlockTags = new Set([
|
||||
'a',
|
||||
'abbr',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'caption',
|
||||
'code',
|
||||
'col',
|
||||
'colgroup',
|
||||
'dd',
|
||||
'details',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'em',
|
||||
'figcaption',
|
||||
'figure',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'i',
|
||||
'img',
|
||||
'li',
|
||||
'mark',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
's',
|
||||
'small',
|
||||
'span',
|
||||
'strong',
|
||||
'sub',
|
||||
'summary',
|
||||
'sup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'time',
|
||||
'tr',
|
||||
'u',
|
||||
'ul'
|
||||
])
|
||||
const globalAllowedAttributes = new Set(['class', 'title', 'aria-label', 'aria-hidden'])
|
||||
const allowedAttributesByTag = {
|
||||
a: new Set(['href', 'target', 'rel']),
|
||||
col: new Set(['span', 'width']),
|
||||
img: new Set(['src', 'alt', 'width', 'height', 'loading']),
|
||||
li: new Set(['value']),
|
||||
ol: new Set(['start', 'type']),
|
||||
td: new Set(['colspan', 'rowspan', 'headers']),
|
||||
th: new Set(['colspan', 'rowspan', 'headers', 'scope']),
|
||||
time: new Set(['datetime'])
|
||||
}
|
||||
const urlAttributeNames = new Set(['href', 'src'])
|
||||
const allowedUrlSchemes = new Set(['http:', 'https:', 'mailto:', 'tel:'])
|
||||
|
||||
/**
|
||||
* HTML 텍스트를 escape한다.
|
||||
* @param {unknown} value - 원본 값
|
||||
* @returns {string} HTML escape 문자열
|
||||
*/
|
||||
const escapeHtmlText = (value) => String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
/**
|
||||
* 브라우저에서 URL 속성 값을 허용할지 확인한다.
|
||||
* @param {string} value - URL 값
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
const isAllowedBrowserUrl = (value) => {
|
||||
const trimmedValue = String(value || '').trim()
|
||||
|
||||
if (!trimmedValue || trimmedValue.startsWith('//')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (trimmedValue.startsWith('/') || trimmedValue.startsWith('#')) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
return allowedUrlSchemes.has(new URL(trimmedValue, window.location.origin).protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저에서 HTML 노드를 허용 목록 기준으로 정리한다.
|
||||
* @param {Node} node - 정리할 노드
|
||||
* @returns {Node|null} 정리된 노드
|
||||
*/
|
||||
const sanitizeBrowserNode = (node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return document.createTextNode(node.textContent || '')
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return null
|
||||
}
|
||||
|
||||
const element = /** @type {Element} */ (node)
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
|
||||
if (!allowedHtmlBlockTags.has(tagName)) {
|
||||
const fragment = document.createDocumentFragment()
|
||||
Array.from(element.childNodes).forEach((childNode) => {
|
||||
const sanitizedChild = sanitizeBrowserNode(childNode)
|
||||
if (sanitizedChild) {
|
||||
fragment.appendChild(sanitizedChild)
|
||||
}
|
||||
})
|
||||
return fragment
|
||||
}
|
||||
|
||||
const sanitizedElement = document.createElement(tagName)
|
||||
const tagAllowedAttributes = allowedAttributesByTag[tagName] || new Set()
|
||||
|
||||
Array.from(element.attributes).forEach((attribute) => {
|
||||
const attributeName = attribute.name.toLowerCase()
|
||||
|
||||
if (!globalAllowedAttributes.has(attributeName) && !tagAllowedAttributes.has(attributeName)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (urlAttributeNames.has(attributeName) && !isAllowedBrowserUrl(attribute.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
sanitizedElement.setAttribute(attributeName, attribute.value)
|
||||
})
|
||||
|
||||
if (tagName === 'a' && sanitizedElement.getAttribute('target') === '_blank') {
|
||||
const relValues = new Set(String(sanitizedElement.getAttribute('rel') || '').split(/\s+/).filter(Boolean))
|
||||
relValues.add('noopener')
|
||||
relValues.add('noreferrer')
|
||||
sanitizedElement.setAttribute('rel', [...relValues].join(' '))
|
||||
}
|
||||
|
||||
Array.from(element.childNodes).forEach((childNode) => {
|
||||
const sanitizedChild = sanitizeBrowserNode(childNode)
|
||||
if (sanitizedChild) {
|
||||
sanitizedElement.appendChild(sanitizedChild)
|
||||
}
|
||||
})
|
||||
|
||||
return sanitizedElement
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저에서 HTML 블록을 정리한다.
|
||||
* @param {unknown} value - 원본 HTML
|
||||
* @returns {string} 정리된 HTML
|
||||
*/
|
||||
const sanitizeHtmlBlockInBrowser = (value) => {
|
||||
const html = String(value || '').trim()
|
||||
|
||||
if (!html) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!import.meta.client || typeof DOMParser === 'undefined') {
|
||||
return escapeHtmlText(html)
|
||||
}
|
||||
|
||||
const parser = new DOMParser()
|
||||
const documentFragment = document.createDocumentFragment()
|
||||
const parsedDocument = parser.parseFromString(`<template>${html}</template>`, 'text/html')
|
||||
const template = parsedDocument.querySelector('template')
|
||||
|
||||
Array.from(template?.content.childNodes || []).forEach((node) => {
|
||||
const sanitizedNode = sanitizeBrowserNode(node)
|
||||
if (sanitizedNode) {
|
||||
documentFragment.appendChild(sanitizedNode)
|
||||
}
|
||||
})
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.appendChild(documentFragment)
|
||||
return container.innerHTML.trim()
|
||||
}
|
||||
|
||||
const sanitizeHtmlBlock = import.meta.server
|
||||
? (await import('../../lib/markdown-html-block.js')).sanitizeHtmlBlock
|
||||
: sanitizeHtmlBlockInBrowser
|
||||
|
||||
const sanitizedHtml = computed(() => sanitizeHtmlBlock(props.html))
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user