前端项目安全加固实践总结几个容易忽略的关键点

程序员树行 工具 阅读 2,500
赞 16 收藏
二维码
手机扫码查看
反馈

这次安全加固差点翻车,记录一下踩坑过程

上周接到一个紧急任务,要给公司的一个老项目做安全加固,客户那边做了渗透测试发现了几个安全隐患。说实话,这种临危受命的活儿压力挺大的,特别是老项目,代码历史遗留问题一堆,改起来小心翼翼生怕出问题。

前端项目安全加固实践总结几个容易忽略的关键点

主要的安全问题集中在XSS防护、CSRF防护、HTTP头部安全设置这几个方面。开始我以为就是常规操作,结果在实施过程中踩了不少坑,有些细节问题比想象中复杂。

Content Security Policy配置真是一言难尽

首先搞的是CSP策略,这个东西听起来简单,实际配置起来各种兼容性问题和脚本加载失败。最开始我在服务器端设置了很严格的策略:

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src * data:; font-src 'self' fonts.gstatic.com;

结果页面一加载,各种脚本报错,连jQuery都执行不了。折腾了半天才发现,项目里用了大量的内联样式和脚本,还有几个依赖库用到了eval()函数,这下傻眼了。

后来改成这样稍微宽松一点的策略:

// CSP头部设置
app.use((req, res, next) => {
  const cspHeader = [
    "default-src 'self'",
    "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com https://code.jquery.com",
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
    "img-src 'self' data: blob: https:",
    "font-src 'self' https://fonts.gstatic.com",
    "connect-src 'self' https://jztheme.com/api/",
    "frame-ancestors 'none'"
  ].join('; ');
  
  res.setHeader('Content-Security-Policy', cspHeader);
  next();
});

这里我踩了个坑,一开始没考虑到CDN资源的域名,导致外部库加载失败。后来一个个添加信任域名才搞定。不过说实话,这策略还是不够完美,’unsafe-inline’和’unsafe-eval’的存在让我心里一直不踏实。

XSS过滤器的那些事儿

接下来是XSS防护,主要是输入验证和输出编码。老项目的输入验证基本没有,各种地方都有直接输出用户输入的地方。手动加过滤器工作量巨大,而且容易遗漏。

我决定用helmet这个中间件来统一处理:

const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: false, // 单独配置
  xssFilter: true,
  noSniff: true,
  frameguard: { action: 'deny' },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  },
  hidePoweredBy: true
}));

但仅仅依赖helmet还不够,对于动态内容的输出还得自己处理。我封装了一个简单的转义函数:

function escapeHtml(text) {
  if (!text) return '';
  return text.toString()
    .replace(/&/g, "&")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

// 在模板渲染前使用
app.locals.escape = escapeHtml;

这里有个坑,就是对于富文本编辑器的内容处理比较麻烦。如果全部转义,格式就乱了;如果不转义,又有安全风险。最后我采用了白名单过滤的方式,只允许部分HTML标签:

const sanitizeHtml = require('sanitize-html');

function sanitizeRichText(html) {
  return sanitizeHtml(html, {
    allowedTags: ['p', 'br', 'strong', 'em', 'u', 'ol', 'ul', 'li'],
    allowedAttributes: {}
  });
}

CSRF防护的兼容性问题

CSRF防护这块也遇到了不少麻烦。老项目很多表单都是传统的POST提交,没有AJAX调用,需要在每个表单里加入token。手动添加太麻烦,我写了个中间件自动注入:

const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

// 中间件注入CSRF token
app.use((req, res, next) => {
  if (req.csrfToken) {
    res.locals.csrfToken = req.csrfToken();
  }
  next();
});

// 在路由中使用
app.get('/form', csrfProtection, (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});

但是这里有个问题,部分页面是通过iframe嵌套的,设置了frameguard: deny后iframe就加载不了了。后来改成SAMEORIGIN:

helmet.frameguard({ 
  action: 'sameorigin' 
})

然后对应的form模板里也要加上hidden input:

<form method="post" action="/submit">
  <input type="hidden" name="_csrf" value="{{csrfToken}}">
  <!-- 其他表单字段 -->
</form>

HTTP头部设置的细节

头部设置相对简单,但也有一些需要注意的地方。比如Strict-Transport-Security这个头部,一旦设置了就无法撤销(除非过期),所以得谨慎:

// 谨慎启用HSTS
app.use(helmet.hsts({
  maxAge: 31536000, // 一年
  includeSubDomains: true,
  preload: false // 先不预加载,确认没问题后再开启
}));

还有一个Referrer-Policy,这个对SEO有一定影响,设置得过于严格可能会影响统计:

app.use(helmet.referrerPolicy({
  policy: 'strict-origin-when-cross-origin'
}));

我还遇到一个奇怪的问题,某些老版本的浏览器不支持新的头部,导致页面加载异常。最后通过User-Agent判断来降级处理:

app.use((req, res, next) => {
  const userAgent = req.headers['user-agent'];
  // 针对老浏览器降低安全级别
  if (userAgent && userAgent.includes('MSIE')) {
    // 对IE浏览器特殊处理
    res.setHeader('X-Content-Type-Options', 'nosniff');
  }
  next();
});

静态资源的安全问题

静态资源的安全配置也是重点,特别是上传目录的访问控制。之前项目允许直接访问所有静态文件,存在信息泄露风险:

// 配置静态资源访问
app.use('/static', express.static(path.join(__dirname, 'public'), {
  setHeaders: (res, filePath) => {
    // 防止MIME类型嗅探
    res.setHeader('X-Content-Type-Options', 'nosniff');
    
    // 设置缓存策略
    if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
      res.setHeader('Cache-Control', 'public, max-age=31536000');
    }
  }
}));

// 特殊目录访问限制
app.use('/uploads', (req, res, next) => {
  const fileExtension = path.extname(req.url).toLowerCase();
  // 禁止执行脚本文件
  if (['.php', '.asp', '.aspx', '.jsp', '.exe', '.bat'].includes(fileExtension)) {
    return res.status(403).send('Access Denied');
  }
  next();
});

HTTPS强制重定向的坑

HTTPS重定向看着简单,实际上线后发现了很多问题。特别是内部负载均衡器的配置,导致request.secure判断不准确:

// 代理环境下检测HTTPS
app.set('trust proxy', 1);

// HTTPS重定向中间件
const forceHttps = (req, res, next) => {
  if (!req.secure && req.get('x-forwarded-proto') !== 'https' && process.env.NODE_ENV === 'production') {
    return res.redirect(301, https://${req.headers.host}${req.url});
  }
  next();
};

// 只在生产环境启用
if (process.env.NODE_ENV === 'production') {
  app.use(forceHttps);
}

折腾了半天才发现,云服务商的负载均衡器转发时可能会丢失原始协议信息,需要检查x-forwarded-proto头部。

日志记录和监控

安全加固不能光靠防护,还要有监控。我在关键位置添加了安全事件日志:

// 安全日志中间件
const securityLogger = (req, res, next) => {
  const originalSend = res.send;
  
  res.send = function(body) {
    // 记录可疑请求
    if (body && typeof body === 'string' && 
        (body.includes('script') || body.includes('<') || body.includes('>'))) {
      console.warn('Potential XSS attempt:', {
        ip: req.ip,
        url: req.url,
        userAgent: req.headers['user-agent'],
        timestamp: new Date().toISOString()
      });
    }
    
    res.send = originalSend;
    return res.send(body);
  };
  
  next();
};

测试和验证

配置完成后,我用OWASP ZAP工具做了自动化扫描,发现还有几个漏洞没处理完。主要是JSONP回调函数的XSS漏洞和一些缓存头配置问题。

JSONP那块确实麻烦,老接口不能改,只能在输出时严格校验callback参数:

function sanitizeCallback(callback) {
  if (!callback) return null;
  // 只允许字母数字下划线
  return /^[a-zA-Z0-9_]+$/.test(callback) ? callback : null;
}

app.get('/api/jsonp', (req, res) => {
  const callback = sanitizeCallback(req.query.callback);
  if (!callback) {
    return res.status(400).send('Invalid callback');
  }
  
  const data = { message: 'hello' };
  res.jsonp(data);
});

以上是我踩坑后的总结,这次安全加固总体来说还算成功,虽然还有一些小问题待优化。如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论