Node.js接口的安全问题,绝大多数不是语言本身造成的,而是开发习惯和默认配置导致的。很多人写接口时只关注功能能不能跑通,忽略了请求入口的校验、数据出口的过滤、以及运行环境的隔离。攻击者根本不需要找到框架漏洞,只要利用未校验的参数、未限制的频率、未脱敏的报错信息,就能轻易穿透一个看似正常的业务系统。下面直接拆解Node.js后端接口安全防护中最关键的几个层面,给出可落地的方案。
请求体大小与格式强制限制
Express或Koa默认不会限制请求体大小,攻击者可以发送超大JSON或文件流直接把服务内存打满。必须在中间件层显式设置body-parser的限制。以Express为例,json和urlencoded都应设置limit,并且拒绝非预期Content-Type。
const express = require('express');
const app = express();
app.use(express.json({ limit: '1mb', strict: true }));
app.use(express.urlencoded({ limit: '1mb', extended: false }));
app.use((req, res, next) => {
const contentType = req.headers['content-type'] || '';
if (req.method === 'POST' && !contentType.includes('application/json')) {
return res.status(415).json({ code: 415, msg: '不支持的媒体类型' });
}
next();
});
strict模式开启后,只有数组和对象会被解析,字符串、数字等裸数据会被拒绝。这个细节能挡掉一批畸形请求。
参数校验必须白名单化
不要只校验参数是否存在,还要校验类型、长度、枚举范围。推荐使用Joi或zod做schema校验,在进入业务逻辑前就完成过滤。下面用zod演示一个用户注册接口的校验。
const { z } = require('zod');
const registerSchema = z.object({
username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
password: z.string().min(8).max(64),
email: z.string().email(),
age: z.number().int().min(18).max(120).optional()
});
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ code: 400, msg: '参数校验失败', details: result.error.issues });
}
req.validated = result.data;
next();
};
}
app.post('/api/register', validate(registerSchema), (req, res) => {
// 使用req.validated,而不是req.body
res.json({ code: 0, msg: '注册成功' });
});
这样做的好处是,业务代码里不再出现散落的if判断,所有非法输入在进入控制器之前就被拦截。同时注意,校验后的数据要单独存放,不要继续使用原始req.body,避免原型污染或额外字段混入。
SQL注入与NoSQL注入防护
Node.js后端常用MySQL、PostgreSQL或MongoDB。关系型数据库必须使用参数化查询或ORM的预编译语句,禁止字符串拼接SQL。对于MongoDB,要警惕用户传入的查询操作符,比如$gt、$ne、$where。即使使用mongoose,也要对用户输入做净化。
function sanitizeMongoQuery(input) {
if (typeof input !== 'object' || input === null) return input;
const clean = {};
for (const key of Object.keys(input)) {
if (key.startsWith('$')) continue; // 丢弃操作符
if (typeof input[key] === 'object' && input[key] !== null) {
clean[key] = sanitizeMongoQuery(input[key]);
} else {
clean[key] = input[key];
}
}
return clean;
}
// 在查询用户列表时使用
const query = sanitizeMongoQuery(req.query);
const users = await User.find(query).limit(50);
另外,所有数据库账号应遵循最小权限原则,应用连接账号不要赋予DDL权限,更不能使用root账号跑业务。
接口鉴权与权限分离
很多系统只做了登录鉴权,没有做接口级别的权限控制。用户登录后拿到token,就能访问所有接口,甚至通过遍历ID获取他人数据。必须实现基于角色的访问控制(RBAC)或基于资源的访问控制(ABAC)。JWT的payload里不要放敏感信息,权限校验要在服务端做,不能只靠前端隐藏按钮。
function requireRole(...roles) {
return (req, res, next) => {
const user = req.user; // 从JWT解析后挂载
if (!user || !roles.includes(user.role)) {
return res.status(403).json({ code: 403, msg: '无权限访问' });
}
next();
};
}
app.delete('/api/users/:id', requireRole('admin'), async (req, res) => {
// 仅管理员可删除用户
});
对于资源归属问题,比如用户只能修改自己的资料,需要在业务层校验资源所有者ID与当前用户ID是否一致,不能只依赖前端传的ID。
频率限制与防暴力破解
登录、注册、短信验证码、密码重置等接口必须做频率限制。可以使用express-rate-limit或自研Redis计数器。针对不同接口设置不同阈值,登录接口一般每分钟不超过5次,验证码接口每小时不超过10次。同时要识别IP和账号两个维度,防止攻击者更换IP但固定账号爆破。
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { code: 429, msg: '尝试次数过多,请稍后再试' }
});
app.post('/api/login', loginLimiter, loginHandler);
生产环境建议用Redis存储计数,因为单机内存计数在集群部署下会失效。同时,登录失败要返回统一的模糊提示,比如“用户名或密码错误”,不要区分“用户不存在”和“密码错误”。
安全响应头与CORS策略
使用helmet中间件一键设置安全响应头,包括X-Content-Type-Options、X-Frame-Options、Strict-Transport-Security等。CORS不要配置成*,要明确指定允许的域名、方法和请求头。如果业务需要跨域携带Cookie,Access-Control-Allow-Origin必须与请求来源精确匹配,且设置Access-Control-Allow-Credentials为true。
const helmet = require('helmet');
const cors = require('cors');
app.use(helmet());
const allowedOrigins = ['https://admin.example.com', 'https://app.example.com'];
app.use(cors({
origin: function (origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('不允许的跨域来源'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
注意,如果接口只给服务端调用,不需要浏览器跨域,干脆不启用CORS,从源头减少攻击面。
错误处理与日志脱敏
生产环境绝对不能把堆栈信息返回给客户端。Express默认的错误处理会暴露文件路径、依赖版本甚至SQL语句。要自定义全局错误处理中间件,统一返回结构,并记录完整错误日志到服务端。
app.use((err, req, res, next) => {
console.error(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`, err);
res.status(err.status || 500).json({
code: err.status || 500,
msg: err.expose ? err.message : '服务器内部错误'
});
});
日志中要记录请求ID、用户ID、IP、请求路径、错误堆栈,但不要记录密码、token、身份证号等敏感字段。可以在日志库中配置redact规则,自动替换敏感字段。
依赖安全与运行时隔离
Node.js项目依赖庞大,一个间接依赖的漏洞就可能被利用。必须定期运行npm audit检查已知漏洞,并在CI流程中设置阻断。同时,锁定依赖版本,使用package-lock.json或yarn.lock,避免安装时引入意外更新。运行时使用非root用户启动Node进程,并配置内存上限,防止内存泄漏拖垮整个服务器。
# Dockerfile示例片段 FROM node:20-alpine RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 WORKDIR /app COPY --chown=nodejs:nodejs . . USER nodejs CMD ["node", "--max-old-space-size=512", "server.js"]
另外,敏感配置如数据库密码、JWT密钥、第三方API密钥必须使用环境变量或密钥管理服务,禁止硬编码在代码仓库中。.env文件要加入.gitignore,生产环境用容器编排的secret机制注入。
文件上传与静态资源安全
如果接口涉及文件上传,必须校验文件类型、大小、数量,并且重命名文件,禁止使用用户原始文件名。存储路径要放在非Web可执行目录,或者通过专门的下载接口读取,避免用户上传HTML、SVG等文件导致存储型XSS。图片文件可以用sharp等库重新编码,彻底去除可能嵌入的脚本。
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const storage = multer.diskStorage({
destination: '/data/uploads',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const name = crypto.randomBytes(16).toString('hex') + ext;
cb(null, name);
}
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
fileFilter: (req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.webp'];
const ext = path.extname(file.originalname).toLowerCase();
if (!allowed.includes(ext)) {
return cb(new Error('仅支持图片文件'));
}
cb(null, true);
}
});
下载文件时,要设置Content-Disposition为attachment,并指定正确的Content-Type,防止浏览器直接渲染用户上传的HTML或SVG。
CSRF与Cookie安全
如果接口使用Cookie做会话保持,必须防范CSRF攻击。对于状态变更请求(POST、PUT、DELETE),应校验CSRF token或自定义请求头。使用SameSite=Lax或Strict可以缓解大部分CSRF场景,但SameSite不是银弹,关键操作仍要二次验证。Cookie要设置HttpOnly和Secure标志,防止XSS窃取和明文传输。
res.cookie('session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000
});
对于前后端分离架构,如果使用Authorization头传递token,天然不受CSRF影响,但要确保token存储在内存或安全的存储中,不要放在localStorage里被XSS读取。
输入输出编码与XSS防护
Node.js后端如果直接返回用户输入的内容,且前端没有做转义,就会产生XSS。后端在输出JSON时,要对字符串中的特殊字符做编码处理。虽然现代前端框架默认转义,但接口返回的数据可能被用于innerHTML、eval或跳转链接。建议在API层统一做输出编码,尤其是富文本场景,必须使用DOMPurify等服务端净化库过滤危险标签和事件属性。
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
function sanitizeHtml(dirty) {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'p', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title']
});
}
同时,响应头要设置Content-Type为application/json,不要使用text/html,避免浏览器将JSON内容当作HTML解析。
接口幂等与并发控制
支付、扣款、库存扣减等接口必须实现幂等性。客户端每次请求携带唯一的幂等键,服务端用Redis或数据库唯一索引记录处理状态。重复请求直接返回第一次的结果,防止网络重试或攻击者重放造成资金损失。并发场景下要使用乐观锁或悲观锁,避免超卖。
async function deductStock(productId, quantity, idempotencyKey) {
const existing = await redis.get(`idem:${idempotencyKey}`);
if (existing) return JSON.parse(existing);
const result = await db.transaction(async (trx) => {
const product = await trx('products').where({ id: productId }).forUpdate().first();
if (product.stock < quantity) throw new Error('库存不足');
await trx('products').where({ id: productId }).decrement('stock', quantity);
return { success: true };
});
await redis.set(`idem:${idempotencyKey}`, JSON.stringify(result), 'EX', 3600);
return result;
}
幂等键要设置过期时间,防止Redis无限膨胀。数据库层面也可以对幂等键建唯一索引,双保险。
监控与告警
安全防护不能只靠开发阶段的配置,运行时监控同样重要。要记录接口响应时间、错误率、异常流量来源,并设置告警阈值。当某个IP短时间内请求量突增、某个接口4xx比例异常升高、或者出现大量非业务错误码时,及时通知运维。可以使用prom-client采集指标,配合Grafana展示,告警规则里重点监控登录失败次数、权限拒绝次数、请求体超限次数。
接口安全是一个持续对抗的过程,没有一劳永逸的方案。每一个新增接口都要过一遍上面的检查清单,每一次依赖升级都要评估安全影响。把安全措施嵌入开发流程,比事后补救成本低得多。
