前面几节讨论了 Web 常见攻击与防御,以及接口层面的安全机制。本节将聚焦于另一个核心安全领域:如何保护数据在传输和存储过程中的机密性。在 Node.js 应用中,这主要涉及三个环节:强制启用 HTTPS、对关键数据实施加密存储、以及在日志和接口输出中对敏感信息进行脱敏处理。
21.4.1 HTTPS 配置:从开发到生产的完整方案
HTTPS 是网络通信安全的基石。它通过 TLS 协议在客户端与服务端之间建立加密通道,防止数据被窃听、篡改或冒充。对于任何面向用户的 Web 应用,强制使用 HTTPS 已经不再是一个可选项,而是一个必选项。
方案一:Node.js 原生实现(开发/测试或简单场景)
Node.js 内置的 https 模块可以直接创建 TLS 服务器,只需提供证书和私钥即可。
const https = require('https');
const fs = require('fs');
const express = require('express');
const app = express();
app.get('/', (req, res) => res.send('Hello HTTPS'));
const options = {
key: fs.readFileSync('/path/to/private-key.pem'),
cert: fs.readFileSync('/path/to/certificate.pem'),
};
https.createServer(options, app).listen(443, () => {
console.log('HTTPS server running on port 443');
});
开发时可以使用自签名证书,但浏览器会显示警告。更常用的做法是在本地使用 mkcert 工具生成受信任的本地证书,从而在开发环境中获得与生产一致的 HTTPS 体验。
注意:直接将证书文件放在项目目录并硬编码路径会增加泄露风险,生产环境中应通过环境变量或配置文件管理证书路径。
方案二:Nginx 反向代理终结 TLS(生产环境推荐)
在实际生产部署中,绝大多数团队会选择在 Node.js 服务前面放置一个反向代理(如 Nginx、HAProxy 或云服务商的负载均衡器),由反向代理负责处理 HTTPS 握手和 TLS 终止,后端 Node.js 只处理 HTTP。这样做的好处包括:
- 充分利用 Nginx 的高性能 TLS 处理能力,以及成熟的会话复用、OCSP Stapling 等特性。
- Node.js 无需加载私钥,降低了密钥泄露风险。
- 方便做集中式证书管理、日志和访问控制。
一个典型的 Nginx 反向代理配置片段:
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# 强制 HTTP 跳转 HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
在这种架构下,Node.js 应用只监听本机 HTTP 端口(如 127.0.0.1:3000),由 Nginx 统一对外暴露 443 端口,并通过 X-Forwarded-Proto 头告知后端请求协议。如果需要在应用中识别用户是否从 HTTPS 访问,可使用 req.headers['x-forwarded-proto'] 或依赖 Express 的 trust proxy 设置。
证书获取:Let's Encrypt 自动化
生产环境中强烈建议使用机构签发的证书而非自签名证书。Let's Encrypt 提供了免费的 DV 证书,配合 certbot 工具可以实现全自动签发和续期。
以 Nginx + Ubuntu 为例,证书获取与自动续期流程:
# 安装 certbot 及 Nginx 插件
sudo apt install certbot python3-certbot-nginx
# 自动配置 HTTPS
sudo certbot --nginx -d example.com -d www.example.com
# 测试续期(ceerbot 会每天检查并自动续期)
sudo certbot renew --dry-run
这样可以在几分钟内完成 HTTPS 配置,并确保证书在 90 天内自动续期,避免因证书过期导致的服务中断。
21.4.2 数据加密:保护数据静态安全
HTTPS 解决了数据传输过程中的加密,但存储在服务器上的敏感数据(如密码、身份证号、手机号)同样需要被保护。一旦服务器或数据库被非法访问,明文存储的数据将直接暴露。
用户密码:单向哈希加盐
密码永远不应被明文存储,也不应使用可逆加密。标准的做法是使用 bcrypt 或 argon2 这类专门的密码哈希算法。它们内置了盐值生成和计算耗时(成本因子)控制,能有效抵御彩虹表攻击和暴力破解。
安装 bcrypt:
npm install bcrypt
注册时生成哈希:
const bcrypt = require('bcrypt');
const saltRounds = 12; // 成本因子,适当增大可增加破解难度
async function registerUser(password) {
const hash = await bcrypt.hash(password, saltRounds);
// 将 hash 存入数据库,不要存密码原文
await db.save({ username, passwordHash: hash });
}
登录时验证:
async function loginUser(username, inputPassword) {
const user = await db.findUser(username);
if (!user) throw new Error('用户不存在');
const match = await bcrypt.compare(inputPassword, user.passwordHash);
if (!match) throw new Error('密码错误');
// 登录成功,签发 Token
}
bcrypt.compare 是时间安全的比较函数,能防止时序攻击。
敏感字段加密:可逆加密的必要场景
对于身份证号、银行卡号、手机号等需要后续查询或展示的数据,不能简单地做单向哈希,而需要可逆加密。这时可使用 Node.js 内置的 crypto 模块配合对称加密算法(如 AES-256-GCM)。
一个安全的对称加密实现需要关注:
- 使用 AES-256-GCM 或 ChaCha20-Poly1305 等 AEAD 加密模式,同时提供机密性和完整性校验。
- 每个字段使用随机生成的 初始化向量(IV) 或 Nonce,即使相同明文加密后的密文也不同。
- 加密密钥(Key)必须妥善保管,不要硬编码在代码中,应通过环境变量或密钥管理服务(如 AWS KMS、HashiCorp Vault)加载。
下面是一个封装好的加密/解密工具模块:
const crypto = require('crypto');
// 密钥应为 32 字节(256 位),从环境变量中读取
const ENCRYPTION_KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const IV_LENGTH = 12; // GCM 推荐使用 12 字节的 nonce
function encrypt(text) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
// 将 IV 和 authTag 与密文一起存储
return iv.toString('hex') + ':' + authTag + ':' + encrypted;
}
function decrypt(ciphertext) {
const parts = ciphertext.split(':');
const iv = Buffer.from(parts.shift(), 'hex');
const authTag = Buffer.from(parts.shift(), 'hex');
const encryptedText = parts.join(':');
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
module.exports = { encrypt, decrypt };
使用示例:
const encryptedPhone = encrypt('13812345678');
// 存储 encryptedPhone,格式如 'abc123:def456:...'
const originalPhone = decrypt(encryptedPhone);
// 恢复为 '13812345678'
由于使用了随机 IV,相同明文每次加密结果不同,可以防止统计分析。解密时如果密文被篡改,GCM 模式会因认证标签校验失败而抛出异常,从而抵御篡改攻击。
加密密钥的管理
密钥是整个加密体系的最薄弱环节。在生产环境中应遵循以下原则:
- 代码仓库中禁止存储密钥,使用环境变量或
.env文件(且.env需加入.gitignore)。 - 定期轮换密钥,并保留历史密钥用于解密旧数据。
- 使用云平台密钥管理服务(KMS)管理主密钥,应用启动时通过授权调用 KMS 解密数据密钥,避免直接面对原始密钥。
21.4.3 敏感信息脱敏:最小化信息暴露面
即使数据被加密存储,在日志记录、接口返回、错误消息以及数据库查询结果中,仍然可能出现敏感信息的明文。脱敏就是在不影响业务逻辑的前提下,对身份证号、手机号、电子邮箱、家庭住址等信息进行部分遮盖,确保即使日志或接口被非授权查看,也无法获取完整的敏感内容。
常见脱敏规则
| 数据类型 | 原始示例 | 脱敏后示例 | 说明 |
|----------|----------|------------|------|
| 手机号 | 13812345678 | 138****5678 | 保留前 3 后 4 |
| 身份证号 | 320102199001010012 | 320102********0012 | 保留前 6 后 4 |
| 电子邮箱 | user@example.com | u\\\*@example.com | 用户名保留首字符 |
| 银行卡号 | 6222021234567890 | 6222 * * 890 | 保留前 4 后 3 |
| 姓名(2-3 字)| 张三 / 张三丰 | 张 / 张丰 | 仅姓为明文 |
实现一个可复用的脱敏工具函数
function maskPhone(phone) {
if (!phone || phone.length !== 11) return phone;
return phone.slice(0, 3) + '****' + phone.slice(7);
}
function maskIdCard(idCard) {
if (!idCard || idCard.length !== 18) return idCard;
return idCard.slice(0, 6) + '********' + idCard.slice(14);
}
function maskEmail(email) {
if (!email) return email;
const [prefix, domain] = email.split('@');
if (!domain) return email;
const maskedPrefix = prefix.charAt(0) + '***' + prefix.charAt(prefix.length - 1);
return maskedPrefix + '@' + domain;
}
日志脱敏的最佳实践
日志系统(如 winston、pino)通常提供序列化器(serializer)或转换器(transformer)功能,可以在写入日志前自动脱敏。例如在 pino 配置中:
const pino = require('pino');
const logger = pino({
serializers: {
req(req) {
// 脱敏请求体中的手机号
if (req.body?.phone) {
req.body.phone = maskPhone(req.body.phone);
}
return req;
},
res(res) {
return res;
}
}
});
或者使用拦截器在中间件层统一处理请求日志,避免在多个地方重复添加脱敏代码。另一个常见陷阱是 错误对象的堆栈信息 可能会泄露文件路径或参数值,需要在全局错误处理中对 error.message 和 error.stack 做过滤。
接口返回脱敏:统一封装
后端接口在返回用户信息时,应明确区分“脱敏字段”和“完整字段”。例如,对普通查询用户列表的接口只返回脱敏后的手机号;而对用户本人查看自己详情的接口可以返回完整数据,前提是做好鉴权和权限校验。
可以通过一个简单的中间件或响应包装函数,在数据序列化之前遍历对象并按照规则脱敏。但更推荐的方式是在数据层(DAO/Repository)就区分好返回字段,采用 DTO(数据传输对象)明确需要赋值的字段,避免将原始数据库对象直接暴露。
class UserDTO {
constructor(user, isSelf = false) {
this.id = user.id;
this.name = user.name;
this.phone = isSelf ? user.phone : maskPhone(user.phone);
this.email = isSelf ? user.email : maskEmail(user.email);
}
}
// Controller 中使用
const userDto = new UserDTO(userFromDB, req.user.id === userFromDB.id);
res.json(userDto);
数据库查询中的脱敏
即使在日志和接口层面做了脱敏,也应尽量遵循“最小权限原则”,在数据库查询时就不查出不必要的敏感字段。可以使用 Sequelize 的 attributes 排除字段,或在 GraphQL 中精确控制查询字段。同时,对数据库查询的慢查询日志也要防止记录参数中的敏感数据。
21.4.4 组合防御:纵深保护
HTTPS 加密、数据加密和敏感信息脱敏三者并非各自为战,而应形成一个纵深防御体系:
- 传输层:全站 HTTPS,禁用明文 HTTP,启用 HSTS。
- 存储层:密码不可逆哈希,高敏感字段可逆加密,加密密钥与密文分离存储。
- 应用层:接口最小返回,日志自动脱敏,异常信息不泄露内部细节。
- 访问控制层:只有经过鉴权的用户才能获取完整敏感信息,普通用户只能看到脱敏版本。
通过在 Node.js 应用中落实这四层防护,即使某个层面出现疏忽,其他层面仍能起到拦截作用,大幅降低数据泄露的风险和影响范围。安全是一个持续关注和迭代的过程,这些措施应作为项目的基础配置而不是事后补丁。