feat(app): 应用矢量图标 icon_key/icon_color 落库 + icon-map 出参 + svg 上传消毒
build / build-push (push) Successful in 1m25s

This commit is contained in:
chenxing
2026-10-20 16:24:51 +08:00
parent 336b50675c
commit 2b754c08ca
10 changed files with 498 additions and 5 deletions
@@ -5,6 +5,7 @@ import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
@@ -146,4 +147,15 @@ public class AppController {
: IEnum.getByKey(ClientEnum.class, clientType);
return appCategoryService.listBoaAppTypeBySchool(schoolId, clientEnum);
}
/**
* 全量应用矢量图标映射(AppIcon 资产库通道):[{id, iconKey, iconColor}],仅含已配置行。<br>
* 背景:BoaApp 属 BasePlatform SDK 本期不动,/oapi/app/list 出参无矢量字段;
* UserHome(PC GetUserApp)与 MobilePortal(移动端桌面)拉本端点后在各自出参上合并注入。<br>
* 只读,无敏感字段;消费方拉取失败时按空映射降级,端侧回退链不受影响。
*/
@GetMapping("icon-map")
public List<AppIconMapVo> iconMap() {
return applicationInfoService.listIconMap();
}
}
@@ -104,6 +104,19 @@ public class ApplicationInfo extends BaseEntity {
@Column(name = "app_icon")
private String appIcon;
/**
* 矢量图标键(AppIcon 资产库图标名,如 tongzhi)。空 = 未配置,端侧走 关键词映射 → URL 位图 → 首字 原回退链。<br>
* 不复用 pc_icon/app_icon:那两列存完整 URL,被 oapi BoaApp 出参与 appIconPath() 按 URL 语义消费(方案 §5.2)。
*/
@Column(name = "icon_key", length = 64)
private String iconKey;
/**
* 矢量图标着色(CSS 色值,如 #366EF4)。空 = 端侧默认(场景 token / 分组色)。人工配置值非派生值,落库安全(方案 §5.3)。
*/
@Column(name = "icon_color", length = 16)
private String iconColor;
/**
* 公众号应用地址
*/
@@ -0,0 +1,29 @@
package com.yunhai.userbase.model.application;
import lombok.Data;
/**
* 应用矢量图标映射条目({@code /oapi/app/icon-map} 出参)。<br>
* 消费方:UserHome(PC GetUserApp 合并注入)与 MobilePortal(移动端桌面 AppItemVo 合并)。<br>
* 只透出 id/iconKey/iconColor 三字段,不含名称/地址等其余应用信息。
*
* @author chenx
*/
@Data
public class AppIconMapVo {
/**
* 应用主键 ID(与 BoaApp.id 同源,消费方按此合并)。
*/
private Integer id;
/**
* 矢量图标键(AppIcon 资产库图标名);null 表示该行不携带,消费方跳过。
*/
private String iconKey;
/**
* 矢量图标着色(CSS 色值);null 表示端侧默认。
*/
private String iconColor;
}
@@ -81,6 +81,16 @@ public class ApplicationInfoRequest {
*/
private String appIcon;
/**
* 矢量图标键(AppIcon 资产库图标名)。空 = 不指定(编辑时空值不回写,沿用原值)。<br>
*/
private String iconKey;
/**
* 矢量图标着色(CSS 色值)。空 = 端侧默认。编辑时空值不回写。<br>
*/
private String iconColor;
/**
* 公众号应用地址。<br>
*/
@@ -82,6 +82,16 @@ public class ApplicationInfoVo {
*/
private String appIcon;
/**
* 矢量图标键(AppIcon 资产库图标名);空 = 未配置。<br>
*/
private String iconKey;
/**
* 矢量图标着色(CSS 色值);空 = 端侧默认。<br>
*/
private String iconColor;
/**
* 公众号应用地址。<br>
*/
@@ -1,6 +1,7 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
@@ -133,4 +134,11 @@ public interface IApplicationInfoService {
* @return 推送设置 VO(仅含推送相关字段)
*/
PushSettingVo getPushSetting(Integer id);
/**
* 全量应用的矢量图标映射(id → iconKey/iconColor),{@code /oapi/app/icon-map} 数据面。<br>
* 仅返回 iconKey 非空的行;供 UserHome / MobilePortal 在既有 BoaApp 列表之上合并注入
* (BoaApp 属 BasePlatform SDK 本期不动,矢量字段走本独立通道补拉)。
*/
List<AppIconMapVo> listIconMap();
}
@@ -18,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
@@ -202,15 +203,24 @@ public class ApplicationInfoService implements IApplicationInfoService {
&& applicationInfoRepository.existsByAppCodeAndIdNot(request.getAppCode(), id)) {
throw new DException("技术标识已存在");
}
// appCode/appNature 空值不回写:部分提交场景(学校覆盖线只传 4 字段)copyProperties
// 会把 null 覆盖进主表;app_code 是引用键,清空即引用失效,仅显式提供时才更新
BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret", "appCode", "appNature");
// appCode/appNature/iconKey/iconColor 空值不回写:部分提交场景(学校覆盖线只传 4 字段)copyProperties
// 会把 null 覆盖进主表;app_code 是引用键,清空即引用失效,仅显式提供时才更新。
// iconKey/iconColor 采用「未携带(null)=不改,携带空串=清除,携带值=更新」三分语义:
// 未启用矢量选择器的提交方不送该键即零影响;管理端全量表单显式清空即解除矢量渲染回到原回退链
BeanUtils.copyProperties(request, entity,
"id", "appKey", "appSecret", "appCode", "appNature", "iconKey", "iconColor");
if (StringUtils.isNotBlank(request.getAppCode())) {
entity.setAppCode(request.getAppCode());
}
if (StringUtils.isNotBlank(request.getAppNature())) {
entity.setAppNature(request.getAppNature());
}
if (request.getIconKey() != null) {
entity.setIconKey(StringUtils.trimToNull(request.getIconKey()));
}
if (request.getIconColor() != null) {
entity.setIconColor(StringUtils.trimToNull(request.getIconColor()));
}
ApplicationInfo saved = applicationInfoRepository.save(entity);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
}
@@ -407,4 +417,20 @@ public class ApplicationInfoService implements IApplicationInfoService {
vo.setPushSecret(entity.getPushSecret());
return vo;
}
@Override
public List<AppIconMapVo> listIconMap() {
List<AppIconMapVo> result = new ArrayList<>();
for (ApplicationInfo app : applicationInfoRepository.findAll()) {
if (StringUtils.isBlank(app.getIconKey())) {
continue; // 未配置矢量键的行不下发,消费方零合并成本
}
AppIconMapVo vo = new AppIconMapVo();
vo.setId(app.getId());
vo.setIconKey(app.getIconKey());
vo.setIconColor(app.getIconColor());
result.add(vo);
}
return result;
}
}
@@ -379,6 +379,8 @@ public class FileUtil {
/**
* MultipartFile通用上传(流式写入,避免大文件OOM,返回完整 Web URL)。<br>
* svg 后缀走白名单消毒流程(AppIcon 方案 P4):≤64KB + viewBox ≤1024 + 元素/属性白名单消毒,
* 落盘的是消毒后内容——不把 svg 加进危险后缀黑名单(正常图标功能保留)。<br>
*
* @param file 上传文件
* @param subDir 子目录
@@ -394,14 +396,30 @@ public class FileUtil {
ext = (ext == null || ext.trim().length() == 0) ? "" : "." + ext.toLowerCase();
// 后缀黑名单校验
validateExt(ext);
// svg 消毒(含 64KB/viewBox 校验):落盘消毒后字节,而非原始上传内容
byte[] sanitized = null;
if (".svg".equals(ext)) {
try {
String raw = new String(file.getBytes(), java.nio.charset.StandardCharsets.UTF_8);
sanitized = SvgSanitizer.sanitize(raw).getBytes(java.nio.charset.StandardCharsets.UTF_8);
} catch (DException e) {
throw e;
} catch (Exception e) {
throw new DException("SVG 文件处理失败");
}
}
ensureStoreBase();
try {
String dirPath = BASE_STORE_PATH + File.separator + subDir;
cn.hutool.core.io.FileUtil.mkdir(dirPath);
String fileName = IdUtil.fastSimpleUUID() + ext;
File targetFile = new File(dirPath, fileName);
// 流式写入,不加载全文件到内存
cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile);
if (sanitized != null) {
cn.hutool.core.io.FileUtil.writeBytes(sanitized, targetFile);
} else {
// 流式写入,不加载全文件到内存
cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile);
}
return buildUrl(subDir, fileName);
} catch (DException e) {
throw e;
@@ -0,0 +1,243 @@
package com.yunhai.userbase.utils;
import com.yunhai.platform.utils.exception.DException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* 上传 SVG 白名单式消毒器(AppIcon 方案 P4 安全收口)。<br>
* 背景:上传框早已 accept=".svg" 且 /upload/icon/** 同源可访问,存储型 XSS(如
* {@code <svg onload=alert(1)>})此前无任何拦截——本类在落盘前消毒,而非把 svg 加进
* 危险后缀黑名单(那会砍掉正常功能)。<br>
* 策略(白名单优先,不依赖 jsoup/dom4j 等额外依赖):
* <ul>
* <li>元素白名单:svg 结构/图形/渐变/文本/滤镜元素之外(script、foreignObject、animate、
* image、a、switch 等)整棵子树删除;</li>
* <li>属性白名单 + 三条硬规则:on* 事件属性一律删、style(可携带 url(javascript:))一律删、
* href/xlink:href 仅保留 "#" 开头的文档内引用(外部引用一律删);</li>
* <li>注释/PI/CDATA 节点删除;DOCTYPE/外部实体在解析层直接禁止(XXE);</li>
* <li>消毒产物重新解析校验——仍是合法 XML 且根元素为 svg 才放行;</li>
* <li>尺寸校验:字节数 ≤64KB;viewBox 宽高 ≤1024(缺失 viewBox 不阻断,仅校验存在的值)。</li>
* </ul>
*/
public final class SvgSanitizer {
/** 图标上传字节数上限(与前端 before-upload 同口径) */
public static final int MAX_BYTES = 64 * 1024;
/** viewBox 宽/高上限(AppIcon 资产库 1024 网格口径) */
public static final double MAX_VIEWBOX = 1024.0;
/** 元素白名单(统一小写比较;SVG 局部名大小写敏感,比较前折叠) */
private static final Set<String> ALLOWED_ELEMENTS = new HashSet<>(Arrays.asList(
"svg", "g", "defs", "symbol", "use", "path", "rect", "circle", "ellipse", "line",
"polyline", "polygon", "text", "tspan", "textpath",
"lineargradient", "radialgradient", "stop", "clippath", "mask", "pattern", "marker",
"title", "desc",
"filter", "fegaussianblur", "feoffset", "feblend", "fecolormatrix", "fecomposite",
"feflood", "femerge", "femergenode", "fecomponenttransfer",
"fefuncr", "fefuncg", "fefuncb", "fefuna"));
/** 属性白名单(统一小写比较;href 走专用规则,不在此集合;不收带前缀属性——序列化层对非 ns-aware DOM 的前缀属性不稳定,内部引用统一规范为无前缀 href) */
private static final Set<String> ALLOWED_ATTRS = new HashSet<>(Arrays.asList(
"id", "class", "version", "baseprofile", "xmlns", "preserveaspectratio",
"viewbox", "width", "height", "x", "y", "dx", "dy",
"d", "points", "transform", "clip-path", "mask", "filter",
"fill", "fill-opacity", "fill-rule", "color", "opacity",
"stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit",
"stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "clip-rule",
"cx", "cy", "r", "rx", "ry", "x1", "y1", "x2", "y2",
"gradientunits", "gradienttransform", "spreadmethod", "offset", "stop-color", "stop-opacity",
"patternunits", "patterncontentunits", "patterntransform",
"markerwidth", "markerheight", "refx", "refy", "orient", "markerunits",
"text-anchor", "font-family", "font-size", "font-weight", "font-style",
"letter-spacing", "word-spacing", "dominant-baseline", "alignment-baseline",
"rotate", "textlength", "lengthadjust", "display", "visibility"));
private SvgSanitizer() {
}
/**
* 消毒上传 SVG 并执行尺寸校验,返回可安全落盘的内容(UTF-8 文本)。<br>
* 任何解析失败/结构异常/超限均抛 {@link DException}(提示词面相用户,不透出内部栈)。
*
* @param raw 原始 SVG 文本
* @return 消毒后的 SVG 文本(合法 XML,根元素 svg)
*/
public static String sanitize(String raw) {
if (raw == null || raw.trim().isEmpty()) {
throw new DException("SVG 内容为空");
}
byte[] bytes = raw.getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) {
throw new DException("SVG 文件超过 64KB 上限");
}
Document doc = parse(raw);
Element root = doc.getDocumentElement();
if (root == null || !"svg".equals(localName(root.getNodeName()))) {
throw new DException("SVG 根元素必须是 <svg>");
}
checkViewBox(root);
stripDisallowed(doc);
String out = serialize(doc);
// 消毒产物复解析:保证落盘的仍是合法 svg(白名单误删导致结构破坏时在此拦截)
Element reroot = parse(out).getDocumentElement();
if (reroot == null || !"svg".equals(localName(reroot.getNodeName()))) {
throw new DException("SVG 消毒后校验失败");
}
return out;
}
/** 深度遍历:删白名单外元素整棵子树 + 清洗保留元素属性 + 删注释/PI/CDATA。 */
private static void stripDisallowed(Node node) {
NodeList children = node.getChildNodes();
for (int i = children.getLength() - 1; i >= 0; i--) {
Node c = children.item(i);
short type = c.getNodeType();
if (type == Node.ELEMENT_NODE) {
if (!ALLOWED_ELEMENTS.contains(localName(c.getNodeName()))) {
node.removeChild(c);
} else {
stripDisallowed(c);
}
} else if (type == Node.COMMENT_NODE || type == Node.PROCESSING_INSTRUCTION_NODE
|| type == Node.CDATA_SECTION_NODE) {
node.removeChild(c);
}
}
if (node.getNodeType() == Node.ELEMENT_NODE) {
cleanAttrs((Element) node);
}
}
/** 属性清洗:on 开头的事件属性与 style 一律删、href 仅留内部锚点、其余按白名单。 */
private static void cleanAttrs(Element el) {
NamedNodeMap attrs = el.getAttributes();
for (int i = attrs.getLength() - 1; i >= 0; i--) {
Node a = attrs.item(i);
String name = a.getNodeName(); // 可能带前缀(xlink:href)
String local = localName(name);
if (local.startsWith("on") || "style".equals(local)) {
el.removeAttribute(name);
continue;
}
if ("href".equals(local)) {
String value = a.getNodeValue().trim();
if (!value.startsWith("#")) {
el.removeAttribute(name); // 外部引用(http/data/javascript: 等)一律删
} else if (!"href".equals(name)) {
// 内部引用:规范为无前缀 href(SVG2 形式,浏览器全兼容;
// 非 ns-aware DOM 序列化带前缀属性不稳定,且 xlink 声明可能已被裁掉)
el.removeAttribute(name);
el.setAttribute("href", value);
}
continue;
}
if (!ALLOWED_ATTRS.contains(local)) {
el.removeAttribute(name);
}
}
}
/** viewBox 校验:宽高均须为正数且 ≤1024;缺失不阻断(存量无 viewBox 图标功能不砍)。 */
private static void checkViewBox(Element root) {
String vb = attrIgnoreCase(root, "viewBox");
if (vb == null || vb.trim().isEmpty()) {
return;
}
String[] parts = vb.trim().split("[\\s,]+");
if (parts.length != 4) {
throw new DException("SVG viewBox 格式不合法");
}
try {
double w = Double.parseDouble(parts[2]);
double h = Double.parseDouble(parts[3]);
if (w <= 0 || h <= 0) {
throw new DException("SVG viewBox 宽高必须为正数");
}
if (w > MAX_VIEWBOX || h > MAX_VIEWBOX) {
throw new DException("SVG viewBox 宽高不能超过 1024");
}
} catch (NumberFormatException e) {
throw new DException("SVG viewBox 格式不合法");
}
}
private static String attrIgnoreCase(Element el, String name) {
NamedNodeMap attrs = el.getAttributes();
for (int i = 0; i < attrs.getLength(); i++) {
Node a = attrs.item(i);
if (localName(a.getNodeName()).equals(localName(name))) {
return a.getNodeValue();
}
}
return null;
}
/** 解析(禁 DOCTYPE/外部实体/XXE)。解析失败 = 文件不是合法 XML,直接拒绝。 */
private static Document parse(String xml) {
try {
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setNamespaceAware(false);
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setFeature("http://xml.org/sax/features/external-general-entities", false);
f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
f.setXIncludeAware(false);
f.setExpandEntityReferences(false);
f.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
f.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
return f.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
} catch (DException e) {
throw e;
} catch (Exception e) {
throw new DException("SVG 文件解析失败(不是合法的 SVG)");
}
}
/** 序列化(无 XML 声明头、UTF-8;浏览器可直接渲染)。 */
private static String serialize(Document doc) {
try {
TransformerFactory tf = TransformerFactory.newInstance();
tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
Transformer t = tf.newTransformer();
t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
t.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
t.setOutputProperty(OutputKeys.METHOD, "xml");
StringWriter sw = new StringWriter();
t.transform(new DOMSource(doc), new StreamResult(sw));
return sw.toString();
} catch (Exception e) {
throw new DException("SVG 消毒序列化失败");
}
}
/** 名字折叠:取冒号后局部名并转小写(namespaceAware=false 时 nodeName 可能带前缀)。 */
private static String localName(String name) {
if (name == null) {
return "";
}
int idx = name.indexOf(':');
String local = idx >= 0 ? name.substring(idx + 1) : name;
return local.toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,124 @@
package com.yunhai.userbase.utils;
import com.yunhai.platform.utils.exception.DException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* SVG 白名单消毒器单测(AppIcon 方案 P4 收口用例)。<br>
* 核心门禁:<code>&lt;svg onload=alert(1)&gt;</code> 经消毒后落盘内容不执行脚本;
* 正常图标(单 path + fill/viewBox)消毒后保持可渲染结构。
*/
class SvgSanitizerTest {
private static final String NORMAL_ICON =
"<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" viewBox=\"0 0 1024 1024\">"
+ "<path d=\"M512 85.33a426.67 426.67 0 1 0 0 853.34 426.67 426.67 0 0 0 0-853.34Z\"/></svg>";
@Test
void onload脚本属性必须被中和() {
String evil = "<svg xmlns=\"http://www.w3.org/2000/svg\" onload=\"alert(1)\" viewBox=\"0 0 24 24\">"
+ "<path d=\"M1 1h10v10z\"/></svg>";
String clean = SvgSanitizer.sanitize(evil);
assertFalse(clean.toLowerCase().contains("onload"), "onload 属性必须被移除:" + clean);
assertFalse(clean.contains("alert"), "事件代码不得残留在产物里:" + clean);
assertTrue(clean.contains("<svg"), "消毒后仍是 svg 文档");
assertTrue(clean.contains("<path"), "图形元素保留");
}
@Test
void script元素整棵删除() {
String evil = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">"
+ "<script>alert('xss')</script><path d=\"M1 1h2v2z\"/></svg>";
String clean = SvgSanitizer.sanitize(evil);
assertFalse(clean.toLowerCase().contains("script"), "script 元素必须被移除:" + clean);
assertFalse(clean.contains("xss"), "脚本内容不得残留:" + clean);
}
@Test
void foreignObject删除() {
String evil = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">"
+ "<foreignObject><div xmlns=\"http://www.w3.org/1999/xhtml\">html 注入</div></foreignObject>"
+ "<path d=\"M1 1h2v2z\"/></svg>";
String clean = SvgSanitizer.sanitize(evil);
assertFalse(clean.toLowerCase().contains("foreignobject"), "foreignObject 必须被移除:" + clean);
assertFalse(clean.contains("html 注入"), "嵌入 HTML 内容不得残留:" + clean);
}
@Test
void 外部引用href删除_内部锚点保留() {
String svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 24 24\">"
+ "<defs><linearGradient id=\"g\"><stop offset=\"0\" stop-color=\"#fff\"/></linearGradient></defs>"
+ "<path d=\"M1 1h2v2z\" fill=\"url(#g)\"/>"
+ "<use xlink:href=\"#g\"/>"
+ "<image href=\"https://evil.com/x.png\" x=\"0\" y=\"0\" width=\"1\" height=\"1\"/>"
+ "<a href=\"javascript:alert(1)\"><path d=\"M0 0z\"/></a></svg>";
String clean = SvgSanitizer.sanitize(svg);
assertFalse(clean.contains("evil.com"), "外部 URL 引用必须删除:" + clean);
assertFalse(clean.contains("javascript:"), "javascript: 协议必须删除:" + clean);
assertFalse(clean.toLowerCase().contains("<image"), "image 元素不在白名单:" + clean);
assertFalse(clean.toLowerCase().contains("<a "), "a 元素不在白名单:" + clean);
assertTrue(clean.contains("url(#g)"), "文档内渐变引用保留");
assertTrue(clean.contains("xlink:href=\"#g\"") || clean.contains("href=\"#g\""), "use 的内部锚点引用保留");
}
@Test
void style属性与SMIL动画删除() {
String svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">"
+ "<path d=\"M1 1h2v2z\" style=\"fill:url(javascript:alert(1))\"/>"
+ "<animate attributeName=\"href\" values=\"javascript:alert(1)\"/>"
+ "<rect x=\"0\" y=\"0\" width=\"4\" height=\"4\"/><!-- 注释 --></svg>";
String clean = SvgSanitizer.sanitize(svg);
assertFalse(clean.contains("style"), "style 属性必须删除:" + clean);
assertFalse(clean.toLowerCase().contains("animate"), "SMIL 动画元素不在白名单:" + clean);
assertFalse(clean.contains("注释"), "注释节点删除:" + clean);
assertTrue(clean.contains("<rect"), "白名单图形元素保留");
}
@Test
void 正常图标内容保留() {
String clean = SvgSanitizer.sanitize(NORMAL_ICON);
assertNotNull(clean);
assertTrue(clean.contains("viewBox=\"0 0 1024 1024\""), "viewBox 保留");
assertTrue(clean.contains("currentColor"), "currentColor 着色保留");
assertTrue(clean.contains("<path"), "path 保留");
assertTrue(clean.contains("d=\"M512 85.33"), "path 数据保留");
}
@Test
void doctype与实体注入拒绝() {
String xx = "<!DOCTYPE svg [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>"
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><text>&xxe;</text></svg>";
assertThrows(DException.class, () -> SvgSanitizer.sanitize(xx), "DOCTYPE 必须被拒绝(XXE)");
}
@Test
void 超过64KB拒绝() {
StringBuilder big = new StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path d=\"");
while (big.length() < 64 * 1024 + 100) {
big.append("M1 1L2 2 ");
}
big.append("\"/></svg>");
assertThrows(DException.class, () -> SvgSanitizer.sanitize(big.toString()), "超过 64KB 必须拒绝");
}
@Test
void viewBox超1024拒绝_合法尺寸放行() {
String bigVb = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 2048 2048\"><path d=\"M0 0z\"/></svg>";
assertThrows(DException.class, () -> SvgSanitizer.sanitize(bigVb), "viewBox 超过 1024 必须拒绝");
String ok = SvgSanitizer.sanitize(NORMAL_ICON); // 0 0 1024 1024 = 边界值放行
assertTrue(ok.contains("1024"));
}
@Test
void 非法XML拒绝() {
assertThrows(DException.class, () -> SvgSanitizer.sanitize("<svg><path"));
assertThrows(DException.class, () -> SvgSanitizer.sanitize(""));
assertThrows(DException.class, () -> SvgSanitizer.sanitize("<div>不是svg</div>"), "根元素非 svg 拒绝");
}
}