Files
YwxAppThink/addon/blog/library/HtmlSanitizer.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

227 lines
7.4 KiB
PHP

<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare (strict_types = 1);
namespace addon\blog\library;
/**
* 文章正文 HTML 白名单净化器
*
* 用途:文章正文在前台是 {$article.content|raw} 直出(富文本必须原样输出),
* 因此必须在**入库前**做白名单净化,否则一旦内容来自不可信来源
* (前台投稿、Word/网页导入、编辑器被绕过)就是存储型 XSS。
*
* 实现说明:基于 PHP DOM 扩展做「标签白名单 + 属性白名单 + URL 协议白名单」,
* 不依赖外部组件(HTMLPurifier)。libxml 不可用时降级为 strip_tags 粗过滤。
*/
class HtmlSanitizer
{
/** 允许的标签(小写) */
private const ALLOW_TAGS = [
'p', 'br', 'div', 'span', 'section', 'article', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code',
'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'sub', 'sup', 'mark', 'small',
'a', 'img', 'figure', 'figcaption', 'hr',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'caption', 'colgroup', 'col',
];
/** 整块丢弃的标签(连同内部内容一起删除) */
private const DROP_TAGS = [
'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet',
'form', 'input', 'button', 'select', 'option', 'textarea', 'label',
'link', 'meta', 'base', 'noscript', 'svg', 'math', 'audio', 'video', 'source', 'track',
];
/** 允许保留的属性(小写) */
private const ALLOW_ATTR = [
'class', 'id', 'style', 'title', 'alt', 'src', 'href', 'target', 'rel',
'width', 'height', 'align', 'colspan', 'rowspan',
];
/**
* 净化 HTML
*
* @param string $html 原始富文本
* @return string 净化后的 HTML(可直接入库与直出)
*/
public static function clean(string $html): string
{
$html = trim($html);
if ($html === '') {
return '';
}
if (!class_exists('DOMDocument')) {
return self::fallback($html);
}
$prev = libxml_use_internal_errors(true);
$doc = new \DOMDocument('1.0', 'UTF-8');
// 加 XML 编码声明,避免中文被按 ISO-8859-1 解析
$loaded = $doc->loadHTML('<?xml encoding="UTF-8">' . $html);
libxml_clear_errors();
libxml_use_internal_errors($prev);
if (!$loaded) {
return self::fallback($html);
}
$body = $doc->getElementsByTagName('body')->item(0);
if (!$body) {
return self::fallback($html);
}
self::walk($body);
$out = '';
foreach ($body->childNodes as $child) {
$out .= $doc->saveHTML($child);
}
return trim($out);
}
/**
* 递归净化节点
*/
private static function walk(\DOMNode $node): void
{
$children = [];
foreach ($node->childNodes as $child) {
$children[] = $child;
}
foreach ($children as $child) {
// 注释节点一律删除(IE 条件注释可携带脚本)
if ($child->nodeType === XML_COMMENT_NODE) {
$node->removeChild($child);
continue;
}
if ($child->nodeType !== XML_ELEMENT_NODE) {
continue;
}
/** @var \DOMElement $child */
$tag = strtolower($child->nodeName);
// 危险标签:连同内容一起丢掉
if (in_array($tag, self::DROP_TAGS, true)) {
$node->removeChild($child);
continue;
}
// 非白名单标签:只拆掉外壳,保留内容(如 font/center 等富文本残留)
if (!in_array($tag, self::ALLOW_TAGS, true)) {
self::walk($child);
self::unwrap($child);
continue;
}
self::cleanAttrs($child);
self::walk($child);
}
}
/**
* 拆掉元素外壳,把子节点提到父级
*/
private static function unwrap(\DOMElement $el): void
{
$parent = $el->parentNode;
if (!$parent) {
return;
}
$children = [];
foreach ($el->childNodes as $c) {
$children[] = $c;
}
foreach ($children as $c) {
$parent->insertBefore($c, $el);
}
$parent->removeChild($el);
}
/**
* 属性白名单 + URL 协议校验
*/
private static function cleanAttrs(\DOMElement $el): void
{
$names = [];
foreach ($el->attributes as $attr) {
$names[] = $attr->nodeName;
}
foreach ($names as $name) {
$lname = strtolower($name);
$value = (string) $el->getAttribute($name);
// on* 事件属性一律删除(白名单里本来也没有,这里是双保险)
if (strpos($lname, 'on') === 0) {
$el->removeAttribute($name);
continue;
}
if (!in_array($lname, self::ALLOW_ATTR, true)) {
$el->removeAttribute($name);
continue;
}
// src / href 必须走安全协议
if (($lname === 'src' || $lname === 'href') && self::isUnsafeUrl($value)) {
$el->removeAttribute($name);
continue;
}
// style 里禁止 expression / javascript / url() 等可执行写法
if ($lname === 'style' && preg_match('/(expression|javascript|vbscript|url\s*\()/i', $value)) {
$el->removeAttribute($name);
continue;
}
}
// 链接统一加 rel:防 window.opener 劫持 + 不向站外传递权重
if (strtolower($el->nodeName) === 'a' && $el->getAttribute('href') !== '') {
$el->setAttribute('rel', 'nofollow noopener noreferrer');
}
}
/**
* URL 是否不安全
*/
private static function isUnsafeUrl(string $value): bool
{
$v = strtolower((string) preg_replace('/[\s\x00-\x1f]/', '', $value));
if ($v === '') {
return true;
}
// 可执行协议
if (preg_match('/^(javascript|vbscript|file|about|blob):/', $v)) {
return true;
}
// data: 仅允许图片
if (preg_match('/^data:/', $v) && !preg_match('/^data:image\//', $v)) {
return true;
}
return false;
}
/**
* 降级方案(DOM 不可用时):粗过滤危险标签
*/
private static function fallback(string $html): string
{
$html = (string) preg_replace('#<\s*(script|style|iframe|object|embed|form)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $html);
$html = (string) preg_replace('/\son[a-z]+\s*=\s*(".*?"|\'.*?\'|[^\s>]+)/is', '', $html);
return strip_tags($html, '<p><br><div><span><h1><h2><h3><h4><h5><h6><ul><ol><li><blockquote><pre><code><strong><b><em><i><u><s><a><img><table><thead><tbody><tr><td><th><hr><figure><figcaption>');
}
}