Files
Gnuboard7/app/Seo/HtmlSanitizer.php
HeuJung 0dfb45951b fix(core,basic): 검색 봇 화면 렌더러 패리티 결손 일괄 수정
일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇
화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다
(공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다.

- 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속
 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구
- 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화.
 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다
- 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 /
 children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태
- 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로,
 비교식이 raw 경로에서 null 이 되던 문제
- 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강
- 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝

지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과
레이아웃 전수 순회 테스트로 잠갔다.
2026-07-27 10:03:06 +09:00

301 lines
9.5 KiB
PHP

<?php
namespace App\Seo;
use Illuminate\Support\Facades\Log;
/**
* 봇 화면에 삽입되는 사용자 작성 HTML 을 정화합니다.
*
* 일반 화면(React)은 `HtmlContent` 컴포지트가 DOMPurify 로 정화한 결과를 그리므로,
* 봇 화면도 **같은 강도**로 정화해야 두 화면의 출력이 일치합니다. 정화하지 않으면
* 봇 화면에서만 `<script>` / `onerror` 등이 살아 있어, 주소에 봇 파라미터를 붙이는
* 것만으로 저장된 스크립트가 실행됩니다.
*
* 차단 목록은 아래 파일의 DOMPurify 설정을 옮긴 것입니다 — 한쪽만 고치면 두 화면의
* 정화 강도가 어긋나므로 **함께 갱신**해야 합니다.
*
* @see templates/_bundled/sirsoft-basic/src/components/composite/HtmlContent.tsx
* @see docs/backend/seo-system.md "봇 화면의 HTML 정화"
*/
class HtmlSanitizer
{
/**
* 정화 대상 HTML 을 감쌀 래퍼 요소 id.
*
* DOM 파서가 요구하는 문서 골격을 씌운 뒤 이 요소의 자식만 되돌려 받습니다.
*/
private const ROOT_ID = 'g7-seo-sanitize-root';
/**
* 요소와 내용을 통째로 제거할 태그 목록.
*
* 스크립트 실행 · 외부 콘텐츠 삽입 · 문서 구조 조작처럼 내부 텍스트를 남기는 것조차
* 위험하거나 무의미한 태그입니다. (DOMPurify 의 `FORBID_CONTENTS` 계열 동작)
*/
private const DROP_WITH_CONTENT = [
'script', 'noscript', 'style', 'template',
'iframe', 'frame', 'frameset',
'title', 'head',
'svg', 'math',
'plaintext', 'xmp', 'listing',
'noframes', 'noembed',
'audio', 'video',
];
/**
* 요소만 벗기고 내부 텍스트는 남길 태그 목록.
*
* 본문 텍스트를 품고 있을 수 있어 통째로 버리면 콘텐츠가 사라지는 태그입니다.
* (DOMPurify 의 `KEEP_CONTENT: true` 기본 동작)
*/
private const UNWRAP_KEEP_CONTENT = [
'object', 'embed', 'applet', 'portal',
'link', 'meta', 'base',
'form', 'input', 'textarea', 'select', 'option', 'button',
'body', 'html',
'source', 'track', 'canvas',
'details', 'dialog',
'marquee', 'slot',
];
/**
* 제거할 속성 목록 (`on*` 이벤트 핸들러는 전부 별도로 제거).
*/
private const FORBID_ATTRS = [
'formaction', 'xlink:href', 'action', 'srcdoc', 'ping',
];
/**
* URL 값을 담는 속성 목록 (스킴 검사 대상).
*/
private const URL_ATTRS = ['href', 'src', 'srcset', 'poster', 'background', 'cite', 'longdesc'];
/**
* URL 속성에 허용하는 스킴.
*/
private const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel', 'sms', 'ftp', 'cid', 'xmpp', 'callto'];
/**
* `data:` URL 중 허용하는 형태 (이미지 한정 — svg 는 스크립트 실행 벡터라 제외).
*/
private const ALLOWED_DATA_URL = '/^data:image\/(png|jpe?g|gif|webp|avif|bmp);/i';
/**
* 사용자 작성 HTML 을 정화해 반환합니다.
*
* 파싱에 실패하면 정화되지 않은 HTML 을 내보내지 않고 전체를 이스케이프합니다
* (실패 시 안전한 쪽으로 기웁니다).
*
* @param string $html 정화할 HTML
* @return string 정화된 HTML
*/
public function sanitize(string $html): string
{
if (trim($html) === '') {
return '';
}
if (! class_exists(\DOMDocument::class)) {
return e($html);
}
$document = new \DOMDocument('1.0', 'UTF-8');
$previous = libxml_use_internal_errors(true);
try {
$loaded = $document->loadHTML(
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div id="'
.self::ROOT_ID.'">'.$html.'</div></body></html>',
LIBXML_NOERROR | LIBXML_NOWARNING
);
if (! $loaded) {
return e($html);
}
$root = $document->getElementById(self::ROOT_ID);
if (! $root instanceof \DOMElement) {
return e($html);
}
$this->cleanNode($root);
return $this->innerHtml($document, $root);
} catch (\Throwable $e) {
Log::warning('봇 화면 HTML 정화 실패 — 전체 이스케이프로 대체', [
'error' => $e->getMessage(),
]);
return e($html);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
}
/**
* 노드와 그 자손을 재귀적으로 정화합니다.
*
* @param \DOMNode $node 대상 노드
*/
private function cleanNode(\DOMNode $node): void
{
// 자식 목록을 먼저 복사한다 — 순회 중 DOM 을 변경하므로 라이브 NodeList 를 쓰면 항목을 건너뛴다.
$children = [];
foreach ($node->childNodes as $child) {
$children[] = $child;
}
foreach ($children as $child) {
if ($child instanceof \DOMElement) {
$this->cleanElement($child);
continue;
}
// 주석은 봇 화면에 남길 이유가 없고 조건부 주석 등 파서 혼란 요인이 된다.
if ($child instanceof \DOMComment) {
$child->parentNode?->removeChild($child);
}
}
}
/**
* 단일 요소를 정화합니다 (제거 / 언랩 / 속성 정리).
*
* @param \DOMElement $element 대상 요소
*/
private function cleanElement(\DOMElement $element): void
{
$tag = strtolower($element->tagName);
if (in_array($tag, self::DROP_WITH_CONTENT, true)) {
$element->parentNode?->removeChild($element);
return;
}
if (in_array($tag, self::UNWRAP_KEEP_CONTENT, true)) {
$this->cleanNode($element);
$this->unwrap($element);
return;
}
$this->cleanAttributes($element);
$this->cleanNode($element);
}
/**
* 요소를 제거하고 자식 노드를 부모의 같은 자리에 올립니다.
*
* @param \DOMElement $element 대상 요소
*/
private function unwrap(\DOMElement $element): void
{
$parent = $element->parentNode;
if ($parent === null) {
return;
}
while ($element->firstChild !== null) {
$parent->insertBefore($element->firstChild, $element);
}
$parent->removeChild($element);
}
/**
* 요소의 속성을 정리합니다.
*
* @param \DOMElement $element 대상 요소
*/
private function cleanAttributes(\DOMElement $element): void
{
$attributes = [];
foreach ($element->attributes as $attribute) {
$attributes[] = $attribute->nodeName;
}
foreach ($attributes as $name) {
$lower = strtolower($name);
// 이벤트 핸들러 전량 제거 (onerror, onload, onanimationend, ...)
if (str_starts_with($lower, 'on')) {
$element->removeAttribute($name);
continue;
}
if (in_array($lower, self::FORBID_ATTRS, true)) {
$element->removeAttribute($name);
continue;
}
if (in_array($lower, self::URL_ATTRS, true)
&& ! $this->isAllowedUrl($element->getAttribute($name))) {
$element->removeAttribute($name);
}
}
// 외부 링크에 rel 보강 (일반 화면의 HtmlContent 후처리와 동일)
if (strtolower($element->tagName) === 'a' && ! $element->hasAttribute('rel')) {
$href = $element->getAttribute('href');
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
$element->setAttribute('rel', 'noopener noreferrer');
}
}
}
/**
* URL 속성 값이 허용된 스킴인지 판정합니다.
*
* 상대 경로 · 앵커 · 프로토콜 상대 경로는 허용하고, `javascript:` 처럼 스크립트를
* 실행할 수 있는 스킴은 차단합니다.
*
* @param string $url 검사할 URL
* @return bool 허용 여부
*/
private function isAllowedUrl(string $url): bool
{
$trimmed = trim($url);
if ($trimmed === '') {
return true;
}
// 제어문자를 끼워 넣어 스킴 검사를 우회하는 시도를 막는다 (`java\tscript:` 등)
$normalized = strtolower(preg_replace('/[\x00-\x20]+/', '', $trimmed) ?? '');
if (preg_match(self::ALLOWED_DATA_URL, $normalized) === 1) {
return true;
}
// 스킴이 없으면 상대 경로 / 앵커 / 쿼리 → 허용
if (preg_match('/^([a-z][a-z0-9+.-]*):/', $normalized, $matches) !== 1) {
return true;
}
return in_array($matches[1], self::ALLOWED_SCHEMES, true);
}
/**
* 요소의 내부 HTML 을 문자열로 반환합니다.
*
* @param \DOMDocument $document 소속 문서
* @param \DOMElement $element 대상 요소
* @return string 내부 HTML
*/
private function innerHtml(\DOMDocument $document, \DOMElement $element): string
{
$html = '';
foreach ($element->childNodes as $child) {
$html .= $document->saveHTML($child);
}
return $html;
}
}