一次真实的 XSS 事故
去年底,我们负责的一个内容管理后台收到了一条用户反馈:某篇文章的评论区里,鼠标划过某段文字时弹出了一个空白框。乍看像是浏览器的渲染 bug,但排查后发现,攻击者在一段看似普通的评论中嵌入了经过编码的 <img src=x onerror=...> 标签,利用评论区的富文本渲染逻辑绕过了过滤规则,成功在管理员查看评论时执行了恶意脚本。
这次事故的直接后果是:攻击者通过盗取管理员的会话 Cookie 短暂获取了后台操作权限,所幸发现及时,没有造成数据泄露。但这次复盘让我们意识到,XSS 从来不是“前端加个转义就完事”的问题,它贯穿了数据的输入、存储、输出三个环节。
XSS 的常见方式
XSS(跨站脚本攻击)的本质是攻击者让浏览器把恶意内容当作脚本执行。根据恶意代码的注入位置和触发方式,通常分为三类:
存储型 XSS 是最危险的一种。恶意脚本被提交到服务器并持久化存储(比如评论区、用户昵称、文章内容),任何访问该页面的用户都会触发执行。我们遇到的正是这一类。
反射型 XSS 出现在服务端将用户输入直接拼接到响应页面中。典型场景是搜索框:用户搜索的关键词被原样回显到页面上。攻击者构造一个带有恶意参数的链接诱导用户点击,脚本随即在用户浏览器中执行。
DOM 型 XSS 不经过服务端渲染,完全由前端 JavaScript 处理不当引发。比如代码中使用了 innerHTML、document.write、eval 等 API,将 URL 参数或用户输入直接插入 DOM,导致脚本执行。
三种类型的共同点是:用户可控的数据未经充分处理就进入了 HTML 上下文。
前端如何防御
前端防御的核心原则只有一条:永远不信任用户输入,永远对输出进行上下文相关的编码。
具体来说,可以从以下几个层面着手:
输出编码是第一道防线。 根据数据所处的上下文(HTML 标签体、HTML 属性、JavaScript 字符串、URL 参数),采用对应的编码方式。比如在 HTML 标签体内,将 < 转义为 <,> 转义为 >,& 转义为 &。在属性值中还需要转义引号。现代前端框架(React、Vue)在模板中默认做了这些处理,但一旦使用 dangerouslySetInnerHTML 或 v-html,保护就失效了。
避免危险的 DOM API。 优先使用 textContent 而非 innerHTML,使用 setAttribute 而非直接拼接属性字符串。如果确实需要渲染富文本,必须引入成熟的白名单过滤库,比如 DOMPurify,而不是自己写正则。
启用 CSP(内容安全策略)。 CSP 是浏览器层面的最后一道防线。通过设置 Content-Security-Policy 响应头,限制页面只能加载指定来源的脚本,禁止内联脚本执行(script-src 'self'),可以大幅降低 XSS 的成功率。即使攻击者成功注入了脚本标签,浏览器也会拒绝执行。
Cookie 设置 HttpOnly 和 SameSite。 将敏感 Cookie 标记为 HttpOnly,JavaScript 就无法通过 document.cookie 读取,会话劫持的难度会显著增加。SameSite 属性则可以缓解 CSRF 与部分 XSS 的组合攻击。
MySQL 防止 SQL 注入的几种写法
XSS 和 SQL 注入经常在同一套业务代码中并存。防止 SQL 注入的关键在于:让 SQL 语句的结构与数据分离。
使用参数化查询(Prepared Statements) 是最可靠的方式。以 PHP PDO 为例:
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
无论 $email 中包含什么内容,数据库都只会将其视为数据,而非 SQL 代码。Java 的 PreparedStatement、Python 的 cursor.execute(sql, params)、Node.js 的 mysql2 占位符查询,原理相同。
使用 ORM 框架 是更上层的选择。Sequelize、TypeORM、Hibernate、Django ORM 等工具在大多数情况下会自动生成参数化查询,减少手写 SQL 的机会。但要注意,ORM 中的原生查询方法(如 sequelize.query)如果拼接字符串,同样会引入注入风险。
存储过程 也可以作为防御手段,但前提是存储过程内部不使用动态 SQL 拼接。如果存储过程里写了 EXEC('SELECT * FROM ' + @table),注入风险依然存在。
需要特别警惕的是字符串拼接和手动转义。"SELECT * FROM users WHERE id = " + userId 这种写法在任何情况下都不应该出现。而 mysql_real_escape_string 这类转义函数,虽然能应对部分场景,但受字符集等因素影响,并非万无一失,不应作为主要防御手段。
Linux 服务器安全加固清单
应用层的防御再完善,如果服务器本身被攻破,一切努力都归零。以下是一份精简的 Linux 服务器加固清单:
账户与认证方面,禁用 root 远程 SSH 登录(PermitRootLogin no),改用密钥认证并禁用密码登录(PasswordAuthentication no),为所有服务账户设置强密码并定期轮换。使用 fail2ban 自动封禁暴力破解来源 IP。
网络层面,通过防火墙(iptables/nftables 或云安全组)只开放必要的端口,默认拒绝所有入站流量。数据库(MySQL 3306)、Redis(6379)等内部服务绝不暴露在公网。
系统更新方面,开启自动安全更新(unattended-upgrades),定期检查内核和关键组件的安全公告。删除不必要的软件包和服务,减少攻击面。
日志与监控方面,集中收集 auth.log、Nginx 访问日志、应用日志,配置异常告警。使用 auditd 监控关键文件的变更。
文件权限方面,确保 Web 目录不可写(除非业务确实需要上传),敏感配置文件权限设为 600,定期检查 SUID/SGID 文件。
总结
这次 XSS 事故给我们的最大教训是:安全不是一个环节的事,而是一条链。前端做了转义,但服务端存储时没有二次校验;服务端用了参数化查询,但富文本渲染环节又引入了 innerHTML。任何一个环节的疏忽,都可能让整条防线崩溃。输入验证、输出编码、参数化查询、服务器加固,每一层都做好自己的事,才能真正把风险降到可控范围。
未经允许不得转载:任鹏个人博客 » 从一次 XSS 漏洞复盘看前端输入输出安全


朋友圈点赞图在线生成源码