feat(server): 安全基线(Helmet+限流+脱敏)(E10)

- 注册 @fastify/helmet(CSP 关闭)
- 引入 @fastify/rate-limit,并对 login/forgot/reset 按开关启用
- Fastify logger 增加敏感字段 redaction(authorization/cookie/password 等)
这个提交包含在:
2026-02-08 23:58:55 +08:00
父节点 7597756fad
当前提交 479565f737
共修改 7 个文件,包含 124 行新增和 8 行删除
+12
查看文件
@@ -223,6 +223,9 @@ npm run job:ocr-once
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
- `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB)
- `RATE_LIMIT_ENABLED`: 是否启用关键接口限流(默认:production=true,其它环境=false)
- `RATE_LIMIT_LOGIN_MAX`: `/api/auth/login` 等接口限流次数(默认 `10`)
- `RATE_LIMIT_LOGIN_WINDOW`: 限流时间窗(默认 `1 minute`,支持 ms 格式如 `30s`/`1m`)
- `DELEGATION_REVOKE_CRON`: 委派自动回收定时表达式(默认 `0 * * * *`)
- `OCR_PROVIDER`: `off` / `mock` / `tencent`(默认 `off`)
- `OCR_BATCH_SIZE`: 每轮 OCR 认领数量(默认 `10`)
@@ -232,6 +235,15 @@ npm run job:ocr-once
- `OCR_TIMEOUT_MS`: 腾讯 OCR SDK 超时(默认 `5000`)
- `TENCENT_SECRET_ID` / `TENCENT_SECRET_KEY` / `TENCENT_OCR_REGION`: 腾讯 OCR 配置
安全基线(P4):
- 安全头:默认启用 `@fastify/helmet`(CSP 默认关闭,避免误伤 SPA 本地调试)。
- 限流:`/api/auth/login`、`/api/auth/forgot-password`、`/api/auth/reset-password` 在 `RATE_LIMIT_ENABLED=true` 时启用限流。
- 日志脱敏:`authorization/cookie/password/code/newPassword` 等敏感字段会被 redaction。
回滚策略:
- 若限流误伤,可先设置 `RATE_LIMIT_ENABLED=false`(无需改代码)。
- 若安全头造成兼容性问题,可回退 `server/src/app.js` 中 helmet 注册逻辑。
> 生产提示:大表加索引需评估 `CREATE INDEX CONCURRENTLY`,避免锁表影响。
---
+52
查看文件
@@ -11,8 +11,10 @@
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
@@ -239,6 +241,26 @@
],
"license": "MIT"
},
"node_modules/@fastify/helmet": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.0.2.tgz",
"integrity": "sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fastify-plugin": "^5.0.0",
"helmet": "^8.0.0"
}
},
"node_modules/@fastify/jwt": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
@@ -324,6 +346,27 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/rate-limit": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
@@ -1489,6 +1532,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/hono": {
"version": "4.11.4",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
+5 -3
查看文件
@@ -24,19 +24,21 @@
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
"fastify": "^5.7.4",
"fastify-plugin": "^5.1.0",
"node-cron": "^4.2.1",
"pg": "^8.18.0",
"prisma": "^7.3.0",
"zod": "^4.3.6",
"node-cron": "^4.2.1",
"tencentcloud-sdk-nodejs": "^4.1.14"
"tencentcloud-sdk-nodejs": "^4.1.14",
"zod": "^4.3.6"
},
"devDependencies": {
"dotenv": "^16.4.5"
+36 -1
查看文件
@@ -7,6 +7,8 @@ const fastify = require("fastify");
const cors = require("@fastify/cors");
const cookie = require("@fastify/cookie");
const multipart = require("@fastify/multipart");
const helmet = require("@fastify/helmet");
const rateLimit = require("@fastify/rate-limit");
const { getConfig } = require("./config");
const authPlugin = require("./plugins/auth");
@@ -26,7 +28,20 @@ async function buildApp() {
const config = getConfig();
const app = fastify({
logger: true,
logger: {
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"req.headers['set-cookie']",
"res.headers['set-cookie']",
"req.body.password",
"req.body.newPassword",
"req.body.code",
],
censor: "[REDACTED]",
},
},
trustProxy: true,
genReqId: () => crypto.randomUUID(),
});
@@ -60,6 +75,26 @@ async function buildApp() {
},
});
await app.register(helmet, {
contentSecurityPolicy: false,
});
await app.register(rateLimit, {
global: false,
errorResponseBuilder: (request, context) => {
return new AppError({
code: "SYS_429",
message: "请求过于频繁,请稍后重试",
statusCode: context.statusCode,
details: {
retryAfter: context.after,
max: context.max,
ban: context.ban,
},
});
},
});
app.addHook("onRequest", async (request, reply) => {
reply.header("x-request-id", request.id);
});
+7
查看文件
@@ -43,6 +43,13 @@ function getConfig() {
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
rateLimitEnabled:
process.env.RATE_LIMIT_ENABLED != null
? process.env.RATE_LIMIT_ENABLED === "true"
: nodeEnv === "production",
rateLimitLoginMax: Number(process.env.RATE_LIMIT_LOGIN_MAX ?? 10),
rateLimitLoginWindow: process.env.RATE_LIMIT_LOGIN_WINDOW ?? "1 minute",
delegationRevokeCron: process.env.DELEGATION_REVOKE_CRON ?? "0 * * * *",
ocrProvider: process.env.OCR_PROVIDER ?? "off",
+11 -3
查看文件
@@ -39,7 +39,15 @@ async function authRoutes(fastify) {
password: z.string().min(1),
});
fastify.post("/login", async (request, reply) => {
const authRateLimit =
fastify.config.rateLimitEnabled && typeof fastify.rateLimit === "function"
? fastify.rateLimit({
max: fastify.config.rateLimitLoginMax,
timeWindow: fastify.config.rateLimitLoginWindow,
})
: undefined;
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = loginBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
@@ -374,7 +382,7 @@ async function authRoutes(fastify) {
email: z.string().email(),
});
fastify.post("/forgot-password", async (request, reply) => {
fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = forgotPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
@@ -436,7 +444,7 @@ async function authRoutes(fastify) {
newPassword: z.string().min(12),
});
fastify.post("/reset-password", async (request, reply) => {
fastify.post("/reset-password", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = resetPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({