安全(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(市场 / 开发者中心 / 审核 / 分润)
227 lines
7.4 KiB
PHP
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\articles\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>');
|
|
}
|
|
}
|