把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, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
}

这个方案能用,但有个坑:如果后面新加一个字段又忘了过滤,漏洞就又回来了。特别是在项目有几十个开发人员、历史代码又乱的情况下,靠人肉记住每个输出点不现实。

后来改成用模板引擎自带的安全渲染:

// 使用模板引擎的自动转义
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-inlineunsafe-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%,代码审查中安全问题也明显少了。但安全不是一次性的工作,后面还做了几件事:

  1. 定期更新依赖:每个月跑一次 npm audit fix,但修复前先确认漏洞的攻击路径
  2. 安全培训:给团队做了一次分享,讲清楚 XSS、CSRF 的原理和防护方法
  3. 代码审查:PR 模版里加了个 checklist,要求检查是否有新的安全风险点
  4. 应急预案:准备了一个安全事件响应流程,万一出问题能快速处理

还有一个坑是日志。一开始没怎么关心安全相关的日志,出问题时想排查都找不到信息。后来专门加了个日志中间件,记录可疑的请求:

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/) 转载或引用必须申明原指尖魔法屋来源及源地址!