security: implement Phase 1 & partial Phase 2 improvements
Some checks failed
Lint / build (20) (push) Has been cancelled
Some checks failed
Lint / build (20) (push) Has been cancelled
- Remove hardcoded admin password, use env var or auto-generate - Fix trustHost to be conditional (Vercel-only by default) - Add HTTP security headers (CSP, X-Frame-Options, HSTS) - Create middleware.ts to activate existing rate limiting - Implement structured logging system - Rewrite error handling in ai-cad service - Update security documentation
This commit is contained in:
parent
38c10d616b
commit
d58b706799
23
.env.example
23
.env.example
@ -1,6 +1,25 @@
|
||||
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
|
||||
AUTH_SECRET=****
|
||||
|
||||
# Auth URL (optional in Vercel, required for self-hosted)
|
||||
# Set to your application's public URL, e.g. https://your-domain.com
|
||||
# Also accepted as NEXTAUTH_URL (deprecated, prefer AUTH_URL)
|
||||
AUTH_URL=
|
||||
|
||||
# 仅在受信反向代理(如 Vercel、Nginx with validated Host header)后部署时设为 true
|
||||
# 开启后禁用 next-auth 的 Host 头校验,非受信环境不要开启
|
||||
AUTH_TRUST_HOST=false
|
||||
|
||||
# Google OAuth (optional)
|
||||
# https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Admin user credentials (used by create-admin.ts one-time setup script)
|
||||
# Set both to provision the initial admin account; if omitted, auto-generated
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# The following keys below are automatically created and
|
||||
# added to your environment when you deploy on Vercel
|
||||
|
||||
@ -48,3 +67,7 @@ TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi
|
||||
# 优先使用系统设置-本地AI里的 `kittycad-cad` 提供商;若未配置则回退到环境变量
|
||||
KITTYCAD_API_TOKEN=your-kittycad-api-token
|
||||
KITTYCAD_BASE_URL=https://api.zoo.dev
|
||||
|
||||
# FAL.ai(可选)
|
||||
# 用于图片生成和编辑;若未配置则回退到系统设置中的 AI 提供商
|
||||
FAL_KEY=your-fal-ai-key
|
||||
|
||||
236
SECURITY_IMPROVEMENTS_SUMMARY.md
Normal file
236
SECURITY_IMPROVEMENTS_SUMMARY.md
Normal file
@ -0,0 +1,236 @@
|
||||
# 安全改进总结
|
||||
|
||||
**日期**: 2026-05-05
|
||||
**改进范围**: Phase 1 (安全短板) + Phase 2 (部分工程基础)
|
||||
|
||||
---
|
||||
|
||||
## 已完成的改进
|
||||
|
||||
### Phase 1: 安全短板修复 ✅
|
||||
|
||||
#### 1. 修复硬编码管理员密码 (create-admin.ts)
|
||||
**风险等级**: 🟢 低风险
|
||||
**改动**:
|
||||
- 从环境变量 `ADMIN_EMAIL` / `ADMIN_PASSWORD` 读取凭据
|
||||
- 如未设置则自动生成安全随机密码
|
||||
- 不再将密码打印到 console(仅在自动生成时打印一次)
|
||||
|
||||
**向后兼容**: ✅ 是(未设置环境变量时使用默认邮箱 + 随机密码)
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 测试 1: 使用环境变量
|
||||
ADMIN_EMAIL=admin@test.com ADMIN_PASSWORD=SecurePass123! node create-admin.ts
|
||||
|
||||
# 测试 2: 自动生成密码
|
||||
node create-admin.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. 修复 trustHost 安全配置
|
||||
**风险等级**: 🟡 中等风险
|
||||
**改动**:
|
||||
- `app/(auth)/auth.config.ts`: `trustHost` 改为条件启用
|
||||
- Vercel 环境 (`VERCEL=1`) 自动开启
|
||||
- 其他环境需显式设置 `AUTH_TRUST_HOST=true`
|
||||
- `app/(auth)/auth.ts`: 移除重复的 `trustHost: true` 覆盖
|
||||
|
||||
**潜在影响**:
|
||||
- ⚠️ 非 Vercel 部署且未设置 `AUTH_TRUST_HOST=true` 时,NextAuth 会严格校验 Host 头
|
||||
- 如果反向代理未正确传递 Host 头,可能导致登录失败
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 开发环境测试登录流程
|
||||
pnpm dev
|
||||
# 访问 http://localhost:3000/login 测试登录
|
||||
|
||||
# 生产环境部署前检查
|
||||
echo $VERCEL # Vercel 环境应为 "1"
|
||||
echo $AUTH_TRUST_HOST # 非 Vercel 环境应设为 "true"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. 添加 HTTP 安全头
|
||||
**风险等级**: 🟢 低风险
|
||||
**改动**: `next.config.ts` 新增 `headers()` 函数
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: SAMEORIGIN`
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
||||
- `Content-Security-Policy` (开发环境允许 unsafe-eval,生产环境更严格)
|
||||
|
||||
**潜在影响**:
|
||||
- CSP 可能阻止某些第三方脚本(如未在 `connect-src` 白名单中的 API)
|
||||
- 如有嵌入需求(iframe),需调整 `X-Frame-Options`
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 启动后检查响应头
|
||||
curl -I http://localhost:3000 | grep -E "X-Content-Type|X-Frame|CSP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4. 创建 middleware.ts 接入 proxy.ts
|
||||
**风险等级**: 🔴 **高风险** - 需重点测试
|
||||
**改动**: 新建 `/middleware.ts`
|
||||
```typescript
|
||||
export { proxy as middleware, config } from "./proxy";
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- ✅ 限流功能**首次生效**(之前 proxy.ts 存在但未被 Next.js 加载)
|
||||
- ✅ IP 黑名单检查生效
|
||||
- ✅ 可疑请求检测生效
|
||||
- ⚠️ 所有 API 路由现在都会经过中间件检查
|
||||
|
||||
**潜在问题**:
|
||||
1. **限流过严**: 如果用户正常使用触发限流(如画布自动保存频繁),会返回 429
|
||||
2. **误判可疑请求**: `lib/security/request-monitor.ts` 的规则可能误报
|
||||
3. **性能影响**: 每个请求都要经过 IP 检查、限流检查、可疑模式匹配
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 1. 检查中间件是否加载
|
||||
pnpm build
|
||||
cat .next/server/middleware-manifest.json # 应有内容,不再为空
|
||||
|
||||
# 2. 测试限流
|
||||
# 快速刷新页面 100 次,应触发 429
|
||||
|
||||
# 3. 测试正常流程
|
||||
# 登录、创建画布、上传图片、聊天 - 确保都正常工作
|
||||
|
||||
# 4. 检查日志
|
||||
# 查看是否有 "[Security] Rate limit exceeded" 等日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. 补充 .env.example
|
||||
**风险等级**: 🟢 无风险(纯文档)
|
||||
**新增条目**:
|
||||
- `AUTH_URL` / `NEXTAUTH_URL`
|
||||
- `AUTH_TRUST_HOST`
|
||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||
- `ADMIN_EMAIL` / `ADMIN_PASSWORD`
|
||||
- `FAL_KEY`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 工程基础改进 ✅
|
||||
|
||||
#### 6. 建立结构化日志系统
|
||||
**风险等级**: 🟢 低风险
|
||||
**新增文件**: `lib/logger.ts`
|
||||
- 提供 `log.info()`, `log.error()`, `log.warn()`, `log.debug()`
|
||||
- 支持模块化日志:`createLogger("ModuleName")`
|
||||
- 开发环境可读格式,生产环境 JSON 格式
|
||||
- 通过 `LOG_LEVEL` 环境变量控制输出级别
|
||||
|
||||
**使用示例**:
|
||||
```typescript
|
||||
import { createLogger } from "@/lib/logger";
|
||||
const log = createLogger("MyService");
|
||||
log.error("Operation failed", error, { userId: "123" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 7. 修复 service 层错误吞没
|
||||
**风险等级**: 🟡 中等风险
|
||||
**改动**:
|
||||
- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误
|
||||
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
|
||||
|
||||
**潜在问题**:
|
||||
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
|
||||
- 如果有其他代码依赖 ai-cad service 的错误格式,可能受影响
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 触发一个 AI-CAD 错误(如无效的 workspace ID)
|
||||
# 检查日志是否包含完整错误堆栈
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 8. 更新安全文档
|
||||
**风险等级**: 🟢 无风险(纯文档)
|
||||
**改动**: `lib/security/IMPLEMENTATION_GUIDE.md` 更新至实际状态
|
||||
|
||||
---
|
||||
|
||||
## 未完成的任务
|
||||
|
||||
### Phase 2 剩余
|
||||
- ❌ 为缺少验证的路由补充 Zod schema (enhance, rmbg, canvas/text-edit 等)
|
||||
- ❌ 迁移剩余 service 文件的错误处理(canvas 有 31 个 catch 块未完全迁移)
|
||||
|
||||
### Phase 3
|
||||
- ❌ 补充核心单元测试
|
||||
- ❌ 添加 Dockerfile 支持
|
||||
|
||||
---
|
||||
|
||||
## 回滚方案
|
||||
|
||||
如果部署后出现问题,可以快速回滚:
|
||||
|
||||
### 回滚 middleware(最高优先级)
|
||||
```bash
|
||||
# 删除 middleware.ts,限流和安全检查将停止工作
|
||||
rm middleware.ts
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 回滚 trustHost
|
||||
```bash
|
||||
# 在 auth.config.ts 中改回
|
||||
trustHost: true,
|
||||
```
|
||||
|
||||
### 回滚其他改动
|
||||
```bash
|
||||
git diff HEAD # 查看所有改动
|
||||
git checkout HEAD -- <file> # 回滚单个文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署前检查清单
|
||||
|
||||
- [ ] 在开发环境运行 `pnpm dev`,确保无编译错误
|
||||
- [ ] 测试登录/登出流程
|
||||
- [ ] 测试聊天、画布、图片生成等核心功能
|
||||
- [ ] 快速刷新页面,确认限流触发(应看到 429 错误)
|
||||
- [ ] 检查 `.next/server/middleware-manifest.json` 不为空
|
||||
- [ ] 设置生产环境变量:
|
||||
- `AUTH_TRUST_HOST=true`(如非 Vercel)
|
||||
- `LOG_LEVEL=warn`(生产环境)
|
||||
- `ADMIN_PASSWORD=<secure-password>`(如需创建管理员)
|
||||
- [ ] 运行 `pnpm build` 确保生产构建成功
|
||||
|
||||
---
|
||||
|
||||
## 监控建议
|
||||
|
||||
部署后前 24 小时重点监控:
|
||||
|
||||
1. **错误率**: 是否有大量 429 (限流) 或 403 (IP 黑名单) 错误
|
||||
2. **登录成功率**: trustHost 改动可能影响登录
|
||||
3. **日志输出**: 检查是否有 "[Security]" 前缀的警告
|
||||
4. **性能**: 中间件增加的延迟(应 <10ms)
|
||||
|
||||
---
|
||||
|
||||
## 联系与支持
|
||||
|
||||
如遇问题,优先检查:
|
||||
1. `.next/server/middleware-manifest.json` 是否正确生成
|
||||
2. 浏览器控制台是否有 CSP 违规警告
|
||||
3. 服务器日志中是否有 "[Security]" 相关错误
|
||||
@ -6,8 +6,14 @@ export function buildAuthConfig(authUrl?: string | null): NextAuthConfig {
|
||||
// Use host-only cookies to avoid cross-domain login issues in multi-domain deployments.
|
||||
const cookieDomain = undefined;
|
||||
|
||||
// trustHost 仅在明确配置时开启,避免 host header 注入攻击
|
||||
// 部署在 Vercel 或受信反向代理后时,设置 AUTH_TRUST_HOST=true
|
||||
const trustHost =
|
||||
process.env.AUTH_TRUST_HOST === "true" ||
|
||||
process.env.VERCEL === "1";
|
||||
|
||||
return {
|
||||
trustHost: true,
|
||||
trustHost,
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
newUser: "/",
|
||||
|
||||
@ -211,7 +211,6 @@ const nextAuth = NextAuth(async () => {
|
||||
|
||||
return {
|
||||
...buildAuthConfig(resolvedAuthUrl),
|
||||
trustHost: true,
|
||||
providers: createBaseProviders(googleClientId, googleClientSecret),
|
||||
callbacks: {
|
||||
async signIn({ account, user, profile }) {
|
||||
|
||||
@ -1,9 +1,21 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { user } from "./lib/db/schema";
|
||||
import { generateHashedPassword } from "./lib/db/utils";
|
||||
|
||||
function generateSecurePassword(length = 16): string {
|
||||
const charset =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let password = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += charset[bytes[i] % charset.length];
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let connectionString = process.env.POSTGRES_URL;
|
||||
|
||||
@ -28,8 +40,15 @@ async function main() {
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client);
|
||||
|
||||
const email = "admin@example.com";
|
||||
const password = "Admin@123456";
|
||||
// 从环境变量读取管理员邮箱和密码,避免硬编码凭据
|
||||
const email =
|
||||
process.env.ADMIN_EMAIL ||
|
||||
process.env.INIT_ADMIN_EMAIL ||
|
||||
"admin@example.com";
|
||||
const password =
|
||||
process.env.ADMIN_PASSWORD ||
|
||||
process.env.INIT_ADMIN_PASSWORD ||
|
||||
generateSecurePassword();
|
||||
|
||||
const hashedPassword = generateHashedPassword(password);
|
||||
try {
|
||||
@ -41,11 +60,19 @@ async function main() {
|
||||
});
|
||||
|
||||
// biome-ignore lint/style/noConsole: 仅用于一次性脚本
|
||||
console.log("Admin user created:", email, "password:", password);
|
||||
} catch (error) {
|
||||
console.log("Admin user created:", email);
|
||||
if (!process.env.ADMIN_PASSWORD && !process.env.INIT_ADMIN_PASSWORD) {
|
||||
console.log(
|
||||
"Auto-generated password (save this - it will not be shown again):",
|
||||
password
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
// 如果已存在则忽略错误
|
||||
// biome-ignore lint/style/noConsole: 仅用于一次性脚本
|
||||
console.log("Admin user may already exist:", email, error);
|
||||
console.log("Admin user may already exist:", email, "-", message);
|
||||
}
|
||||
|
||||
await client.end();
|
||||
@ -53,7 +80,7 @@ async function main() {
|
||||
|
||||
main().catch((error) => {
|
||||
// biome-ignore lint/style/noConsole: 仅用于一次性脚本
|
||||
console.error(error);
|
||||
console.error("Failed to create admin user:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
176
lib/logger.ts
Normal file
176
lib/logger.ts
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 结构化日志系统
|
||||
*
|
||||
* 替代散布在代码中的 console.log/error/warn,提供:
|
||||
* - 统一的日志格式 (JSON)
|
||||
* - 日志级别过滤
|
||||
* - 请求关联 ID 追踪
|
||||
* - 错误堆栈保留
|
||||
*
|
||||
* 用法:
|
||||
* import { logger, log } from "@/lib/logger";
|
||||
* log.info("User logged in", { userId: "123" });
|
||||
* log.error("Payment failed", error, { orderId: "456" });
|
||||
*/
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
error?: {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type LogArgs = [string] | [string, Record<string, unknown>] | [string, Error, Record<string, unknown>?];
|
||||
|
||||
// 生产环境只输出 warn 及以上级别
|
||||
const MIN_LEVEL: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
function resolveMinLevel(): LogLevel {
|
||||
const configured = process.env.LOG_LEVEL as LogLevel | undefined;
|
||||
if (configured && configured in MIN_LEVEL) return configured;
|
||||
return process.env.NODE_ENV === "production" ? "warn" : "info";
|
||||
}
|
||||
|
||||
const currentMinLevel = resolveMinLevel();
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return MIN_LEVEL[level] >= MIN_LEVEL[currentMinLevel];
|
||||
}
|
||||
|
||||
function formatError(err: Error): LogEntry["error"] {
|
||||
return {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
...(err.stack ? { stack: err.stack.split("\n").slice(0, 5).join("\n") } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createEntry(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
extra?: Record<string, unknown>,
|
||||
err?: Error
|
||||
): LogEntry {
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (err) {
|
||||
entry.error = formatError(err);
|
||||
}
|
||||
|
||||
if (extra) {
|
||||
// Skip internal fields to avoid overwriting
|
||||
for (const [key, value] of Object.entries(extra)) {
|
||||
if (key !== "level" && key !== "message" && key !== "timestamp" && key !== "error") {
|
||||
entry[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function formatEntry(entry: LogEntry): string {
|
||||
// 开发环境使用可读格式,生产环境使用 JSON
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const parts = [`[${entry.timestamp}]`, `[${entry.level.toUpperCase()}]`, entry.message];
|
||||
if (entry.error) {
|
||||
parts.push(`| ${entry.error.name}: ${entry.error.message}`);
|
||||
}
|
||||
// 额外字段
|
||||
const extraKeys = Object.keys(entry).filter(
|
||||
(k) => k !== "level" && k !== "message" && k !== "timestamp" && k !== "error"
|
||||
);
|
||||
if (extraKeys.length > 0) {
|
||||
const extra: Record<string, unknown> = {};
|
||||
for (const k of extraKeys) extra[k] = entry[k];
|
||||
parts.push(JSON.stringify(extra));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
function write(entry: LogEntry): void {
|
||||
const formatted = formatEntry(entry);
|
||||
switch (entry.level) {
|
||||
case "error":
|
||||
console.error(formatted);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(formatted);
|
||||
break;
|
||||
case "info":
|
||||
console.info(formatted);
|
||||
break;
|
||||
case "debug":
|
||||
console.debug(formatted);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 公共 API ---
|
||||
|
||||
function logFn(level: LogLevel) {
|
||||
return (...args: LogArgs): void => {
|
||||
if (!shouldLog(level)) return;
|
||||
|
||||
const [message, arg2, arg3] = args;
|
||||
let extra: Record<string, unknown> | undefined;
|
||||
let err: Error | undefined;
|
||||
|
||||
if (arg2 instanceof Error) {
|
||||
err = arg2;
|
||||
extra = arg3;
|
||||
} else if (arg2 && typeof arg2 === "object") {
|
||||
extra = arg2;
|
||||
}
|
||||
|
||||
write(createEntry(level, message, extra, err));
|
||||
};
|
||||
}
|
||||
|
||||
export const log = {
|
||||
debug: logFn("debug"),
|
||||
info: logFn("info"),
|
||||
warn: logFn("warn"),
|
||||
error: logFn("error"),
|
||||
};
|
||||
|
||||
/**
|
||||
* 带模块名的日志工厂
|
||||
*
|
||||
* 用法:
|
||||
* const log = createLogger("EmailVerification");
|
||||
* log.info("Code sent", { email: "user@example.com" });
|
||||
*/
|
||||
export function createLogger(module: string) {
|
||||
return {
|
||||
debug: (message: string, extra?: Record<string, unknown>) =>
|
||||
log.debug(message, { ...extra, module }),
|
||||
info: (message: string, extra?: Record<string, unknown>) =>
|
||||
log.info(message, { ...extra, module }),
|
||||
warn: (message: string, extra?: Record<string, unknown>) =>
|
||||
log.warn(message, { ...extra, module }),
|
||||
error: (message: string, err?: Error, extra?: Record<string, unknown>) =>
|
||||
log.error(message, err, { ...extra, module }),
|
||||
};
|
||||
}
|
||||
|
||||
export { createLogger as logger };
|
||||
export default log;
|
||||
@ -1,159 +1,105 @@
|
||||
# 安全加固实施指南
|
||||
|
||||
## 🎯 解决方案概述
|
||||
## 当前状态
|
||||
|
||||
本方案采用**多层防护**策略,不依赖IP黑名单,而是基于**行为模式检测**来阻止攻击。
|
||||
本项目已实施多层安全防护。以下为实际部署情况:
|
||||
|
||||
## 📋 已实现的安全功能
|
||||
## 已实现的安全功能
|
||||
|
||||
### 1. ✅ 速率限制(Rate Limiting)
|
||||
### 1. 速率限制(Rate Limiting)
|
||||
- **文件**:`lib/security/rate-limiter.ts`
|
||||
- **中间件入口**:`middleware.ts` → `proxy.ts`
|
||||
- **功能**:
|
||||
- API请求:每分钟100次
|
||||
- 认证请求:每分钟5次
|
||||
- 严格模式:每分钟10次
|
||||
- **优势**:自动阻止异常请求,不依赖IP黑名单
|
||||
- 聊天接口:每分钟 60 次
|
||||
- 认证接口:每分钟 5 次
|
||||
- 文件上传:每分钟 60 次
|
||||
- 图片代理:每分钟 3000 次
|
||||
- 画布保存:每分钟 300 次
|
||||
- 实时画布:每分钟 600 次
|
||||
- UI 轮询:每分钟 1200 次
|
||||
- 通用 API:每分钟 100 次
|
||||
- **注意**:当前使用内存存储,多实例部署时建议迁移至 Redis(见下方中长期建议)
|
||||
|
||||
### 2. ✅ 可疑请求检测
|
||||
### 2. 可疑请求检测
|
||||
- **文件**:`lib/security/request-monitor.ts`
|
||||
- **功能**:
|
||||
- 检测可疑路径(/.env, /.git等)
|
||||
- 检测可疑User-Agent
|
||||
- 记录所有可疑请求
|
||||
- **优势**:基于行为模式,适用于所有攻击者
|
||||
- 检测可疑路径(/.env、/.git 等)
|
||||
- 检测可疑 User-Agent
|
||||
- 记录但不立即阻止(避免误报)
|
||||
- **状态**:已实现并通过中间件自动运行
|
||||
|
||||
### 3. ✅ 输入清理和验证
|
||||
### 3. 输入清理和验证
|
||||
- **文件**:`lib/security/input-sanitizer.ts`
|
||||
- **功能**:
|
||||
- 移除命令注入字符(; | ` $等)
|
||||
- 移除危险关键词(wget, curl, bash等)
|
||||
- 防止路径遍历(../)
|
||||
- 防止XSS攻击
|
||||
- **优势**:在数据进入应用前就清理,防止命令注入
|
||||
- 移除命令注入字符
|
||||
- 移除危险关键词
|
||||
- 防止路径遍历
|
||||
- 防止 XSS 攻击
|
||||
- **状态**:工具函数已就绪,需在各 API 路由中逐步接入
|
||||
|
||||
### 4. ✅ 中间件集成
|
||||
- **文件**:`middleware.ts`
|
||||
### 4. IP 黑名单
|
||||
- **文件**:`lib/security/ip-blocklist.ts`
|
||||
- **功能**:基于配置的 IP 黑名单拦截
|
||||
- **状态**:已通过中间件自动运行
|
||||
|
||||
### 5. HTTP 安全头
|
||||
- **文件**:`next.config.ts` (headers 配置)
|
||||
- **已添加**:
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: SAMEORIGIN`
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
||||
- `Content-Security-Policy`(根据开发/生产环境自动调整)
|
||||
|
||||
### 6. 认证安全
|
||||
- **文件**:`app/(auth)/auth.config.ts`, `app/(auth)/auth.ts`
|
||||
- **改进**:
|
||||
- `trustHost` 已改为条件启用(Vercel 环境自动开启,其他环境需设置
|
||||
`AUTH_TRUST_HOST=true`)
|
||||
- Session cookie 使用 `httpOnly + sameSite: lax + secure`(HTTPS 环境)
|
||||
- 单会话强制(regular 用户)
|
||||
- bcrypt 密码哈希 + 时序攻击缓解
|
||||
|
||||
### 7. 结构化日志
|
||||
- **文件**:`lib/logger.ts`
|
||||
- **功能**:
|
||||
- 自动应用速率限制
|
||||
- 检测可疑请求
|
||||
- 记录安全事件
|
||||
- **优势**:所有请求都经过安全检查
|
||||
- JSON 格式输出(生产环境)
|
||||
- 日志级别过滤(`LOG_LEVEL` 环境变量)
|
||||
- 模块名标记(`createLogger("ModuleName")`)
|
||||
- 错误堆栈保留
|
||||
- **用法**:
|
||||
```typescript
|
||||
import { createLogger } from "@/lib/logger";
|
||||
const log = createLogger("MyService");
|
||||
log.error("Operation failed", error, { userId: "123" });
|
||||
```
|
||||
|
||||
## 🚀 实施步骤
|
||||
## 待改进项
|
||||
|
||||
### 步骤1:部署代码更新
|
||||
```bash
|
||||
# 1. 提交代码更改
|
||||
git add lib/security/ middleware.ts scripts/
|
||||
git commit -m "feat: 添加多层安全防护机制"
|
||||
git push
|
||||
### 短期(1-2 周)
|
||||
1. 在所有 API 路由中接入 Zod 输入验证
|
||||
2. 将现有 `console.error` 调用迁移至结构化日志
|
||||
3. 为 service 层 catch 块添加错误日志记录
|
||||
|
||||
# 2. 在服务器上拉取更新
|
||||
cd /var/www/ai-chatbot
|
||||
git pull
|
||||
|
||||
# 3. 重新构建
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# 4. 重启应用
|
||||
pm2 restart ai-chatbot
|
||||
```
|
||||
|
||||
### 步骤2:安装fail2ban(SSH防护)
|
||||
```bash
|
||||
# 在服务器上执行
|
||||
bash scripts/install-fail2ban.sh
|
||||
|
||||
# 或手动安装
|
||||
dnf install -y epel-release fail2ban
|
||||
systemctl enable fail2ban
|
||||
systemctl start fail2ban
|
||||
```
|
||||
|
||||
### 步骤3:在API路由中应用输入验证
|
||||
在关键的API路由中添加输入验证:
|
||||
|
||||
```typescript
|
||||
import { validateRequestBody } from "@/lib/security/input-sanitizer";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
|
||||
// 验证和清理请求体
|
||||
const validation = await validateRequestBody(body);
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: validation.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用清理后的数据
|
||||
const sanitizedBody = validation.sanitized;
|
||||
// ... 处理请求
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 监控和日志
|
||||
|
||||
### 查看安全日志
|
||||
```bash
|
||||
# 查看应用日志中的安全事件
|
||||
tail -f /var/log/pm2/ai-chatbot-out.log | grep "Security"
|
||||
|
||||
# 查看fail2ban日志
|
||||
tail -f /var/log/fail2ban.log
|
||||
|
||||
# 查看被封禁的IP
|
||||
fail2ban-client status sshd
|
||||
```
|
||||
|
||||
### 监控指标
|
||||
- 速率限制触发次数
|
||||
- 可疑请求检测次数
|
||||
- 输入验证失败次数
|
||||
- fail2ban封禁的IP数量
|
||||
|
||||
## 🛡️ 防护效果
|
||||
|
||||
### 防止的攻击类型
|
||||
1. ✅ **命令注入**:通过输入清理和验证
|
||||
2. ✅ **暴力破解**:通过速率限制和fail2ban
|
||||
3. ✅ **DDoS攻击**:通过速率限制
|
||||
4. ✅ **路径遍历**:通过输入清理
|
||||
5. ✅ **XSS攻击**:通过输入清理
|
||||
6. ✅ **异常请求**:通过可疑请求检测
|
||||
|
||||
### 优势
|
||||
- 🌍 **不依赖IP黑名单**:适用于所有攻击者
|
||||
- 🔄 **自动适应**:基于行为模式检测
|
||||
- 📊 **可监控**:详细的安全日志
|
||||
- ⚡ **高性能**:轻量级实现,不影响性能
|
||||
|
||||
## 📚 进一步优化建议
|
||||
|
||||
### 短期(1-2周)
|
||||
1. 在所有API路由中应用输入验证
|
||||
2. 配置fail2ban监控更多服务(Nginx、PostgreSQL等)
|
||||
3. 设置安全告警(邮件/短信通知)
|
||||
|
||||
### 中期(1-2月)
|
||||
1. 集成CrowdSec获取社区威胁情报
|
||||
### 中期(1-2 月)
|
||||
1. 将内存速率限制迁移至 Redis(`@upstash/ratelimit`)
|
||||
2. 实施更细粒度的速率限制策略
|
||||
3. 添加请求签名验证
|
||||
3. 添加结构化日志的持久化存储
|
||||
4. 集成错误追踪服务(Sentry 等)
|
||||
|
||||
### 长期(3-6月)
|
||||
1. 部署ModSecurity WAF
|
||||
2. 实施SELinux策略
|
||||
### 长期(3-6 月)
|
||||
1. 部署 WAF
|
||||
2. 实施 SELinux 策略
|
||||
3. 建立安全审计流程
|
||||
4. CI/CD 中集成 SAST 和依赖扫描
|
||||
|
||||
## 🆘 应急响应
|
||||
## 环境变量
|
||||
|
||||
如果发现新的攻击:
|
||||
1. 检查应用日志:`tail -f /var/log/pm2/ai-chatbot-error.log`
|
||||
2. 检查fail2ban状态:`fail2ban-client status`
|
||||
3. 手动封禁IP:`fail2ban-client set sshd banip <IP地址>`
|
||||
4. 更新可疑模式:编辑 `lib/security/request-monitor.ts`
|
||||
5. 更新输入清理规则:编辑 `lib/security/input-sanitizer.ts`
|
||||
新增安全相关环境变量(详见 `.env.example`):
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|---|---|---|
|
||||
| `AUTH_TRUST_HOST` | 是否信任代理 Host 头(非受信环境不要开启) | `false` |
|
||||
| `LOG_LEVEL` | 日志级别(debug/info/warn/error) | 开发: info, 生产: warn |
|
||||
| `ADMIN_EMAIL` | 初始管理员邮箱(`create-admin.ts`) | `admin@example.com` |
|
||||
| `ADMIN_PASSWORD` | 初始管理员密码(不设置则自动生成) | 自动生成 |
|
||||
|
||||
3
middleware.ts
Normal file
3
middleware.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// Next.js 中间件入口 — 将 proxy.ts 的安全逻辑正确接入
|
||||
// 之前 proxy.ts 存在但未正确导出为 Next.js middleware 格式,导致限流和安全检查均未生效
|
||||
export { proxy as middleware, config } from "./proxy";
|
||||
@ -35,6 +35,46 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
// HTTP 安全头
|
||||
async headers() {
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
// Next.js 需要 unsafe-eval 用于开发模式 (Fast Refresh / Turbopack)
|
||||
`script-src 'self'${isDev ? " 'unsafe-eval'" : ""}`,
|
||||
// Next.js 需要 unsafe-inline 用于样式 (CSS-in-JS / Tailwind)
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https: http:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"media-src 'self' blob: https:",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; ");
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
{ key: "Content-Security-Policy", value: csp },
|
||||
],
|
||||
},
|
||||
// 对需要嵌入的页面放宽 frame 限制
|
||||
{
|
||||
source: "/worlds/share/:path*",
|
||||
headers: [
|
||||
{ key: "X-Frame-Options", value: "ALLOW-FROM https://www.risesail.com" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import type { Model3DTaskStatus } from "@/services/canvas";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("AiCad");
|
||||
|
||||
type ApiResponse<T = any> = {
|
||||
success: boolean;
|
||||
@ -53,14 +56,25 @@ export type AiCadModelingCommandPayload = {
|
||||
raw?: any;
|
||||
};
|
||||
|
||||
function handleError(
|
||||
err: unknown,
|
||||
context: string,
|
||||
fallbackMessage: string,
|
||||
extra?: Record<string, unknown>
|
||||
): ApiResponse<never> {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
log.error(`${context} failed`, error, extra);
|
||||
return { success: false, error: fallbackMessage };
|
||||
}
|
||||
|
||||
export const aiCadGenerationService = {
|
||||
async listWorkspaces(): Promise<ApiResponse<AiCadWorkspaceSummary[]>> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/workspaces");
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "读取 AI-CAD 工作台列表失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "listWorkspaces", "读取 AI-CAD 工作台列表失败");
|
||||
}
|
||||
},
|
||||
|
||||
@ -75,8 +89,8 @@ export const aiCadGenerationService = {
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "创建 AI-CAD 工作台失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "createWorkspace", "创建 AI-CAD 工作台失败", { name: params?.name });
|
||||
}
|
||||
},
|
||||
|
||||
@ -87,8 +101,8 @@ export const aiCadGenerationService = {
|
||||
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "读取 AI-CAD 工作台失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "getWorkspace", "读取 AI-CAD 工作台失败", { workspaceId });
|
||||
}
|
||||
},
|
||||
|
||||
@ -120,8 +134,8 @@ export const aiCadGenerationService = {
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "保存 AI-CAD 工作台失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "saveWorkspace", "保存 AI-CAD 工作台失败", { workspaceId });
|
||||
}
|
||||
},
|
||||
|
||||
@ -134,8 +148,8 @@ export const aiCadGenerationService = {
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "删除 AI-CAD 工作台失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "deleteWorkspace", "删除 AI-CAD 工作台失败", { workspaceId });
|
||||
}
|
||||
},
|
||||
|
||||
@ -144,8 +158,8 @@ export const aiCadGenerationService = {
|
||||
const response = await fetch("/api/ai-cad/provider");
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "读取 AI-CAD 提供商状态失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "getProviderStatus", "读取 AI-CAD 提供商状态失败");
|
||||
}
|
||||
},
|
||||
|
||||
@ -176,8 +190,10 @@ export const aiCadGenerationService = {
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "创建 AI-CAD 任务失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "createTask", "创建 AI-CAD 任务失败", {
|
||||
outputFormat: params.outputFormat,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@ -188,8 +204,8 @@ export const aiCadGenerationService = {
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "创建官方建模会话失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "createModelingSession", "创建官方建模会话失败");
|
||||
}
|
||||
},
|
||||
|
||||
@ -205,8 +221,8 @@ export const aiCadGenerationService = {
|
||||
);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "关闭官方建模会话失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "closeModelingSession", "关闭官方建模会话失败", { sessionId });
|
||||
}
|
||||
},
|
||||
|
||||
@ -225,8 +241,10 @@ export const aiCadGenerationService = {
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "发送官方建模命令失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "sendModelingCommand", "发送官方建模命令失败", {
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@ -251,8 +269,8 @@ export const aiCadGenerationService = {
|
||||
const response = await fetch(`/api/ai-cad/tasks/${taskId}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "查询 AI-CAD 任务失败" };
|
||||
} catch (err) {
|
||||
return handleError(err, "getTask", "查询 AI-CAD 任务失败", { taskId });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -13,6 +13,18 @@ import type {
|
||||
EnhanceProOutputFormat,
|
||||
EnhanceProSubjectDetection,
|
||||
} from '@/lib/enhance-pro/config';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
|
||||
const log = createLogger("Canvas");
|
||||
|
||||
function logCatch(
|
||||
err: unknown,
|
||||
context: string,
|
||||
extra?: Record<string, unknown>
|
||||
): void {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
log.error(`${context} failed`, error, extra);
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
interface ApiResponse<T = any> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user