diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java index b5b552d..bdb497a 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java @@ -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}],仅含已配置行。
+ * 背景:BoaApp 属 BasePlatform SDK 本期不动,/oapi/app/list 出参无矢量字段; + * UserHome(PC GetUserApp)与 MobilePortal(移动端桌面)拉本端点后在各自出参上合并注入。
+ * 只读,无敏感字段;消费方拉取失败时按空映射降级,端侧回退链不受影响。 + */ + @GetMapping("icon-map") + public List iconMap() { + return applicationInfoService.listIconMap(); + } } diff --git a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java index fd5ee3b..dbad556 100644 --- a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java +++ b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java @@ -104,6 +104,19 @@ public class ApplicationInfo extends BaseEntity { @Column(name = "app_icon") private String appIcon; + /** + * 矢量图标键(AppIcon 资产库图标名,如 tongzhi)。空 = 未配置,端侧走 关键词映射 → URL 位图 → 首字 原回退链。
+ * 不复用 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; + /** * 公众号应用地址 */ diff --git a/src/main/java/com/yunhai/userbase/model/application/AppIconMapVo.java b/src/main/java/com/yunhai/userbase/model/application/AppIconMapVo.java new file mode 100644 index 0000000..dc7fe10 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/application/AppIconMapVo.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.model.application; + +import lombok.Data; + +/** + * 应用矢量图标映射条目({@code /oapi/app/icon-map} 出参)。
+ * 消费方:UserHome(PC GetUserApp 合并注入)与 MobilePortal(移动端桌面 AppItemVo 合并)。
+ * 只透出 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; +} diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java index 3e0167b..8991976 100644 --- a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java +++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java @@ -81,6 +81,16 @@ public class ApplicationInfoRequest { */ private String appIcon; + /** + * 矢量图标键(AppIcon 资产库图标名)。空 = 不指定(编辑时空值不回写,沿用原值)。
+ */ + private String iconKey; + + /** + * 矢量图标着色(CSS 色值)。空 = 端侧默认。编辑时空值不回写。
+ */ + private String iconColor; + /** * 公众号应用地址。
*/ diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java index 719c3cd..23492e6 100644 --- a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java +++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java @@ -82,6 +82,16 @@ public class ApplicationInfoVo { */ private String appIcon; + /** + * 矢量图标键(AppIcon 资产库图标名);空 = 未配置。
+ */ + private String iconKey; + + /** + * 矢量图标着色(CSS 色值);空 = 端侧默认。
+ */ + private String iconColor; + /** * 公众号应用地址。
*/ diff --git a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java index 242896d..633e3b3 100644 --- a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java @@ -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} 数据面。
+ * 仅返回 iconKey 非空的行;供 UserHome / MobilePortal 在既有 BoaApp 列表之上合并注入 + * (BoaApp 属 BasePlatform SDK 本期不动,矢量字段走本独立通道补拉)。 + */ + List listIconMap(); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index c5fab57..f3d1857 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -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 listIconMap() { + List 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; + } } \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/utils/FileUtil.java b/src/main/java/com/yunhai/userbase/utils/FileUtil.java index 6a8cf09..4332591 100644 --- a/src/main/java/com/yunhai/userbase/utils/FileUtil.java +++ b/src/main/java/com/yunhai/userbase/utils/FileUtil.java @@ -379,6 +379,8 @@ public class FileUtil { /** * MultipartFile通用上传(流式写入,避免大文件OOM,返回完整 Web URL)。
+ * svg 后缀走白名单消毒流程(AppIcon 方案 P4):≤64KB + viewBox ≤1024 + 元素/属性白名单消毒, + * 落盘的是消毒后内容——不把 svg 加进危险后缀黑名单(正常图标功能保留)。
* * @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; diff --git a/src/main/java/com/yunhai/userbase/utils/SvgSanitizer.java b/src/main/java/com/yunhai/userbase/utils/SvgSanitizer.java new file mode 100644 index 0000000..40e0d19 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/SvgSanitizer.java @@ -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 安全收口)。
+ * 背景:上传框早已 accept=".svg" 且 /upload/icon/** 同源可访问,存储型 XSS(如 + * {@code })此前无任何拦截——本类在落盘前消毒,而非把 svg 加进 + * 危险后缀黑名单(那会砍掉正常功能)。
+ * 策略(白名单优先,不依赖 jsoup/dom4j 等额外依赖): + *
    + *
  • 元素白名单:svg 结构/图形/渐变/文本/滤镜元素之外(script、foreignObject、animate、 + * image、a、switch 等)整棵子树删除;
  • + *
  • 属性白名单 + 三条硬规则:on* 事件属性一律删、style(可携带 url(javascript:))一律删、 + * href/xlink:href 仅保留 "#" 开头的文档内引用(外部引用一律删);
  • + *
  • 注释/PI/CDATA 节点删除;DOCTYPE/外部实体在解析层直接禁止(XXE);
  • + *
  • 消毒产物重新解析校验——仍是合法 XML 且根元素为 svg 才放行;
  • + *
  • 尺寸校验:字节数 ≤64KB;viewBox 宽高 ≤1024(缺失 viewBox 不阻断,仅校验存在的值)。
  • + *
+ */ +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 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 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 文本)。
+ * 任何解析失败/结构异常/超限均抛 {@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 根元素必须是 "); + } + 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); + } +} diff --git a/src/test/java/com/yunhai/userbase/utils/SvgSanitizerTest.java b/src/test/java/com/yunhai/userbase/utils/SvgSanitizerTest.java new file mode 100644 index 0000000..0001d73 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/utils/SvgSanitizerTest.java @@ -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 收口用例)。
+ * 核心门禁:<svg onload=alert(1)> 经消毒后落盘内容不执行脚本; + * 正常图标(单 path + fill/viewBox)消毒后保持可渲染结构。 + */ +class SvgSanitizerTest { + + private static final String NORMAL_ICON = + "" + + ""; + + @Test + void onload脚本属性必须被中和() { + String evil = "" + + ""; + String clean = SvgSanitizer.sanitize(evil); + assertFalse(clean.toLowerCase().contains("onload"), "onload 属性必须被移除:" + clean); + assertFalse(clean.contains("alert"), "事件代码不得残留在产物里:" + clean); + assertTrue(clean.contains("" + + ""; + String clean = SvgSanitizer.sanitize(evil); + assertFalse(clean.toLowerCase().contains("script"), "script 元素必须被移除:" + clean); + assertFalse(clean.contains("xss"), "脚本内容不得残留:" + clean); + } + + @Test + void foreignObject删除() { + String evil = "" + + "
html 注入
" + + "
"; + String clean = SvgSanitizer.sanitize(evil); + assertFalse(clean.toLowerCase().contains("foreignobject"), "foreignObject 必须被移除:" + clean); + assertFalse(clean.contains("html 注入"), "嵌入 HTML 内容不得残留:" + clean); + } + + @Test + void 外部引用href删除_内部锚点保留() { + String svg = "" + + "" + + "" + + "" + + "" + + ""; + String clean = SvgSanitizer.sanitize(svg); + assertFalse(clean.contains("evil.com"), "外部 URL 引用必须删除:" + clean); + assertFalse(clean.contains("javascript:"), "javascript: 协议必须删除:" + clean); + assertFalse(clean.toLowerCase().contains("" + + "" + + "" + + ""; + 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("]>" + + "&xxe;"; + assertThrows(DException.class, () -> SvgSanitizer.sanitize(xx), "DOCTYPE 必须被拒绝(XXE)"); + } + + @Test + void 超过64KB拒绝() { + StringBuilder big = new StringBuilder(""); + assertThrows(DException.class, () -> SvgSanitizer.sanitize(big.toString()), "超过 64KB 必须拒绝"); + } + + @Test + void viewBox超1024拒绝_合法尺寸放行() { + String bigVb = ""; + 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(" SvgSanitizer.sanitize("")); + assertThrows(DException.class, () -> SvgSanitizer.sanitize("
不是svg
"), "根元素非 svg 拒绝"); + } +}