feat(server): 安全基线(Helmet+限流+脱敏)(E10)
- 注册 @fastify/helmet(CSP 关闭) - 引入 @fastify/rate-limit,并对 login/forgot/reset 按开关启用 - Fastify logger 增加敏感字段 redaction(authorization/cookie/password 等)
这个提交包含在:
+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);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
在新工单中引用
屏蔽一个用户