把XSS换到CSRF时踩过的坑
如果只能用一句话说把XSS换到CSRF时踩过的坑:先把失败复现出来。
XSS 刚修完,安全团队又扫出来 CSRF:删除用户的
起因:一个奇怪的客服反馈
最早是客服转来一个用户反馈,说打开某个商品详情页后浏览器突然弹窗跳转,然后账号莫名其妙登出了。我没当回事,以为是浏览器插件的锅。
直到安全团队扫出来存储型 XSS:某个商品描述字段没有过滤,用户提交 <img src=x onerror=alert(1)> 这样的内容后,所有访问该商品页面的用户都会执行这段脚本。
问题出在项目早期的一个渲染函数:
// 旧代码,直接拼接 HTML
function renderProductDescription(desc) {
return `<div class="desc">${desc}</div>`;
}
这里 desc 来自用户输入,没有任何过滤。攻击者构造的恶意内容会被浏览器当作 HTML 执行。
修复 XSS:不是简单替换就能完事
第一反应是用 replace 换掉特殊字符:
function escapeHTML(str) {
return str.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
这个方案能用,但有个坑:如果后面新加一个字段又忘了过滤,漏洞就又回来了。特别是在项目有几十个开发人员、历史代码又乱的情况下,靠人肉记住每个输出点不现实。
后来改成用模板引擎自带的安全渲染:
// 使用模板引擎的自动转义
app.engine('html', consolidate.swig);
// swig 默认对变量自动转义,需要显式用 {{ var|safe }} 才能输出原始 HTML
同时加了个 eslint 规则,禁止使用 dangerouslySetInnerHTML 这类绕过转义的 API,除非有安全团队 review。
但这个方案踩了个坑:有个富文本编辑器需要保留部分 HTML 标签,直接全部转义会把编辑器的功能也干掉。查了一圈发现没银弹,只能针对富文本字段单独处理,用 DOMPurify 做白名单过滤:
import DOMPurify from 'dompurify';
function renderRichText(html) {
// 允许 p, br, strong, em, ul, ol, li 这些安全标签
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href'],
// href 属性只允许 http/https 开头
ADD_ATTR: ['target'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed']
});
return clean;
}
这里有个坑:DOMPurify 默认的 FORBID_TAGS 不够全,比如 SVG 标签里的 onload 事件也能触发脚本执行。最好显式写一遍禁止列表,或者用它的 strict 模式。
还有个容易被忽视的地方是 URL 参数。有些页面用 location.hash 做路由,攻击者可以构造 #<script>alert(1)</script>,虽然现代浏览器会对 hash 做限制,但在某些老版本浏览器或者配合其他漏洞时仍能利用。加了个中间件统一检查:
app.use((req, res, next) => {
const { query, params, body } = req;
const checkObj = (obj) => {
if (!obj || typeof obj !== 'object') return;
for (const key in obj) {
if (typeof obj[key] === 'string' && /<script|javascript:|on\w+=/i.test(obj[key])) {
return false;
}
}
return true;
};
if (!checkObj(query) || !checkObj(params) || !checkObj(body)) {
return res.status(400).json({ error: 'Invalid input' });
}
next();
});
这个方案粗暴但有效,先把明显可疑的请求拦下来,再慢慢优化。
CSRF:比想象中更难防
XSS 刚修完,安全团队又扫出来 CSRF:删除用户的接口是 DELETE /api/user/:id,没有额外的验证,攻击者可以构造一个页面:
<img src="https://your-site.com/api/user/123" style="display:none">
用户一打开这个页面,浏览器就会带着登录态发起请求,然后账号就被删了。
第一反应是给所有非 GET 请求加 CSRF token,但这个项目是前后端分离的,前端在 React,后端在 Node.js,JWT 存在 localStorage 里,传统的 CSRF token 方案不太适用。
后来改成双 token 方案:一个存 localStorage 的 access token 用于 API 调用,一个存 httpOnly cookie 的 refresh token 用于刷新 access token,同时在请求里加个自定义 header:
// 前端
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 关键:非 GET 请求必须带这个 header
if (config.method !== 'get') {
config.headers['X-CSRF-Token'] = getCSRFToken();
}
return config;
});
// 后端
app.use((req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const csrfToken = req.headers['x-csrf-token'];
const expectedToken = getCSRFTokenFromSession(req);
if (!csrfToken || csrfToken !== expectedToken) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
}
next();
});
这个方案能防住大部分 CSRF,因为攻击者的页面没法读取到 X-CSRF-Token 这个 header 的值。但有个坑:如果前端用的是 fetch API 而不是 axios,容易忘记加 header,需要写个统一的 fetch wrapper:
const safeFetch = (url, options = {}) => {
const method = (options.method || 'get').toUpperCase();
const headers = options.headers || {};
if (method !== 'GET') {
headers['X-CSRF-Token'] = getCSRFToken();
}
return fetch(url, { ...options, method, headers });
};
后来又发现一个边界情况:文件上传用的是 multipart/form-data,攻击者可以构造一个带有上传表单的页面,浏览器会自动提交表单。这个问题最后是通过检查 Content-Type header 来解决的:
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use((req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const contentType = req.headers['content-type'] || '';
// 允许 multipart/form-data 用于文件上传,但要求额外的 origin 验证
if (contentType.startsWith('multipart/form-data')) {
const origin = req.headers['origin'];
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
if (!origin || !allowedOrigins.includes(origin)) {
return res.status(403).json({ error: 'Invalid origin' });
}
} else {
// 非 multipart 请求要求 CSRF token
const csrfToken = req.headers['x-csrf-token'];
const expectedToken = getCSRFTokenFromSession(req);
if (!csrfToken || csrfToken !== expectedToken) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
}
}
next();
});
这个方案在开发环境用得挺顺手,但生产环境部署后踩了个坑:有些老版本的移动端浏览器发送的请求不带 Origin header,导致这些用户一直被拦截。最后改成不带 Origin 时回退到 referer 检查:
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
const allowedReferers = process.env.ALLOWED_REFERERS?.split(',') || [];
const isOriginAllowed = (origin, referer) => {
if (origin && allowedOrigins.includes(origin)) {
return true;
}
if (referer && allowedReferers.some(r => referer.startsWith(r))) {
return true;
}
return false;
};
CSP:最后一道防线
修完 XSS 和 CSRF 后,还加了个 CSP 策略做兜底。CSP 的作用是限制页面能加载哪些资源,即使漏掉一个 XSS 漏洞,攻击者注入的脚本也没法执行。
最开始的 CSP 配置是这样:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;
这个配置问题很大:unsafe-inline 和 unsafe-eval 几乎把 CSP 的作用废掉了。但线上项目有些老代码用了 inline script,直接删掉会报错。
后来改成分步推进:
// 先改成 report-only 模式,收集一个月的数据
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
reportOnly: true, // 只报告不拦截
}));
// 同时收集违反 CSP 的请求
app.use((req, res, next) => {
const cspReport = req.headers['content-security-policy-report'];
if (cspReport) {
logCSPLikelyViolation(cspReport);
}
next();
});
一个月后发现大部分 inline script 来自第三方统计代码和广告脚本,这些可以单独加到白名单里:
script-src 'self' 'strict-dynamic' 'nonce-abc123' https://analytics.example.com;
但这个方案有个坑:每个 inline script 的 nonce 值需要和页面里的 nonce 一致,也就是后端要在渲染时注入一个随机值。这对于前后端分离的项目来说不太方便,最后改成用 hash 白名单:
const csp = helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'sha256-abc123...'", // 计算 inline script 的 hash 值
"https://analytics.example.com"
],
},
});
// 计算 hash 的工具函数
function getCSPHash(script) {
return `sha256-${crypto.createHash('sha256').update(script).digest('base64')}`;
}
这个方案虽然麻烦,但一旦配置好就相对稳定。线上运行几个月后,CSP 报告里的违规请求已经降到了个位数。
安全扫描:不要只依赖自动化工具
加了这些安全措施后,项目接入了 Snyk 和 OWASP ZAP 做定期扫描。这两个工具能发现大部分常见漏洞,但有一些问题靠扫描是扫不出来的。
比如有个接口的逻辑漏洞:用户可以自己删除自己的评论,但接口只检查了评论 ID 没检查用户 ID。攻击者可以遍历评论 ID,把其他人的评论都删掉。这个漏洞是人工 code review 时发现的,扫描工具看不出来。
后来加了个单元测试覆盖这类逻辑:
describe('DELETE /api/comments/:id', () => {
it('should only allow deleting own comments', async () => {
const user1 = await createUser();
const user2 = await createUser();
const comment = await createComment({ userId: user1.id });
// user2 试图删除 user1 的评论
const res = await request(app)
.delete(`/api/comments/${comment.id}`)
.set('Authorization', `Bearer ${user2.token}`)
.expect(403);
expect(res.body.error).toMatch(/not authorized/i);
});
});
另一个问题是第三方依赖的安全漏洞。npm audit 经常报一堆高危漏洞,但这些漏洞大部分跟项目无关,修复反而会引入新的风险。后来定了个策略:只有影响的依赖项在项目里被实际使用,且有明确攻击路径时才升级。
环境差异:开发和生产不一样
本地开发环境为了调试方便,通常会把安全限制放宽。比如开启 CORS 允许所有来源,或者把 CSP 设置成 report-only 模式。
但这个习惯有个风险:开发环境和生产环境的配置差异大了,测试时可能发现不了问题。比如有一次开发环境里测试了一个跨域请求,本地没问题,但上线后就被 CORS 拦住了,因为生产环境的 ALLOWED_ORIGINS 配置里没有本地开发环境的域名。
后来统一了配置管理:
// config/security.js
const isDev = process.env.NODE_ENV === 'development';
module.exports = {
cors: {
origin: isDev
? ['http://localhost:3000', 'http://127.0.0.1:3000']
: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true,
},
csp: {
reportOnly: isDev,
directives: {
defaultSrc: ["'self'"],
scriptSrc: isDev
? ["'self'", "'unsafe-inline'", "'unsafe-eval'"]
: ["'self'", "'sha256-abc123...'", "https://analytics.example.com"],
},
},
};
同时在 CI 里加了一个步骤:检查生成的配置文件,确保开发环境和生产环境的差异在可控范围内。
后续维护:安全是个持续的过程
这次安全加固花了三周,上线后安全事件减少了 90%,代码审查中安全问题也明显少了。但安全不是一次性的工作,后面还做了几件事:
- 定期更新依赖:每个月跑一次
npm audit fix,但修复前先确认漏洞的攻击路径 - 安全培训:给团队做了一次分享,讲清楚 XSS、CSRF 的原理和防护方法
- 代码审查:PR 模版里加了个 checklist,要求检查是否有新的安全风险点
- 应急预案:准备了一个安全事件响应流程,万一出问题能快速处理
还有一个坑是日志。一开始没怎么关心安全相关的日志,出问题时想排查都找不到信息。后来专门加了个日志中间件,记录可疑的请求:
app.use((req, res, next) => {
const logSecurityEvent = () => {
const { method, path, headers, query, body } = req;
const userAgent = headers['user-agent'];
const referer = headers['referer'];
// 检查可疑模式
const suspiciousPatterns = [
/<script/i,
/javascript:/i,
/on\w+=/i,
/\.exe|\.bat|\.sh/i,
/union.*select|or.*1=1/i,
];
const inputStr = JSON.stringify({ path, query, body });
const isSuspicious = suspiciousPatterns.some(p => p.test(inputStr));
if (isSuspicious) {
securityLogger.warn({
type: 'suspicious_request',
method,
path,
userAgent,
referer,
ip: req.ip,
timestamp: new Date().toISOString(),
});
}
};
logSecurityEvent();
next();
});
这些日志后来帮我们定位了好几个攻击尝试,比如 SQL 注入尝试和目录遍历攻击。
写在后面
Web 安全这东西,平时好像没啥用,出一次问题就够喝一壶的。这次加固虽然花了些时间,但换来了系统的稳定性,也让我对安全的重要性有了更深的认识。
后续还有几个方向可以考虑:WebAuthn 的无密码登录、HSTS 的强制 HTTPS、Rate Limiting 的防 DDoS。但这些得看业务优先级,安全投入也要有个度。
最后提醒一句:不要盲目信任网上的"最佳实践",每个项目的环境和约束都不一样。测试、验证、循序渐进,比直接照搬安全清单管用得多。
可用性说明:本文发布于 2021 年 2 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-把XSS换到CSRF时踩过的坑(https://blog.thinkmoon.cn/post/106-web-security-xss-csrf-defense-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。