前端项目安全加固实践总结几个容易忽略的关键点
这次安全加固差点翻车,记录一下踩坑过程
上周接到一个紧急任务,要给公司的一个老项目做安全加固,客户那边做了渗透测试发现了几个安全隐患。说实话,这种临危受命的活儿压力挺大的,特别是老项目,代码历史遗留问题一堆,改起来小心翼翼生怕出问题。
主要的安全问题集中在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, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// 在模板渲染前使用
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);
});
以上是我踩坑后的总结,这次安全加固总体来说还算成功,虽然还有一些小问题待优化。如果你有更好的方案欢迎评论区交流。

暂无评论