feat(app): 应用矢量图标 icon_key/icon_color 落库 + icon-map 出参 + svg 上传消毒
build / build-push (push) Successful in 1m25s
build / build-push (push) Successful in 1m25s
This commit is contained in:
@@ -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><svg onload=alert(1)></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 拒绝");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user