Browse Source

fix(security): enforce auth in action-adapter and add SSRF protection

- Add actual authentication enforcement in createActionRoute handler
  (previously only OpenAPI metadata, no runtime check)
- Add requiredRole: "admin" to all notification management endpoints
- Add SSRF protection to testWebhookAction blocking internal IPs,
  localhost, and dangerous ports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
ding113 3 months ago
parent
commit
85f6a0f199

+ 50 - 1
src/actions/notifications.ts

@@ -9,6 +9,43 @@ import {
   type UpdateNotificationSettingsInput,
 } from "@/repository/notifications";
 
+/**
+ * 检查 URL 是否指向内部/私有网络(SSRF 防护)
+ */
+function isInternalUrl(urlString: string): boolean {
+  try {
+    const url = new URL(urlString);
+    const hostname = url.hostname.toLowerCase();
+
+    // 阻止 localhost 和 IPv6 loopback
+    if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
+      return true;
+    }
+
+    // 解析 IPv4 地址
+    const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+    if (ipv4Match) {
+      const [, a, b, c] = ipv4Match.map(Number);
+      // 私有 IP 范围
+      if (a === 10) return true; // 10.0.0.0/8
+      if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
+      if (a === 192 && b === 168) return true; // 192.168.0.0/16
+      if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local)
+      if (a === 0) return true; // 0.0.0.0/8
+    }
+
+    // 危险端口
+    const dangerousPorts = [22, 23, 3306, 5432, 27017, 6379, 11211];
+    if (url.port && dangerousPorts.includes(parseInt(url.port, 10))) {
+      return true;
+    }
+
+    return false;
+  } catch {
+    return true; // 无效 URL 视为不安全
+  }
+}
+
 /**
  * 获取通知设置
  */
@@ -57,8 +94,20 @@ export async function testWebhookAction(
     return { success: false, error: "Webhook URL 不能为空" };
   }
 
+  const trimmedUrl = webhookUrl.trim();
+
+  // SSRF 防护: 阻止访问内部网络
+  if (isInternalUrl(trimmedUrl)) {
+    logger.warn({
+      action: "webhook_test_blocked",
+      reason: "internal_url",
+      url: trimmedUrl.replace(/key=[^&]+/, "key=***"), // 脱敏
+    });
+    return { success: false, error: "不允许访问内部网络地址" };
+  }
+
   try {
-    const bot = new WeChatBot(webhookUrl.trim());
+    const bot = new WeChatBot(trimmedUrl);
     const result = await bot.testConnection();
 
     return result;

+ 3 - 0
src/app/api/actions/[...route]/route.ts

@@ -588,6 +588,7 @@ const { route: getNotificationSettingsRoute, handler: getNotificationSettingsHan
     {
       description: "获取通知设置",
       tags: ["通知管理"],
+      requiredRole: "admin",
     }
   );
 app.openapi(getNotificationSettingsRoute, getNotificationSettingsHandler);
@@ -604,6 +605,7 @@ const { route: updateNotificationSettingsRoute, handler: updateNotificationSetti
       }),
       description: "更新通知设置",
       tags: ["通知管理"],
+      requiredRole: "admin",
     }
   );
 app.openapi(updateNotificationSettingsRoute, updateNotificationSettingsHandler);
@@ -618,6 +620,7 @@ const { route: testWebhookRoute, handler: testWebhookHandler } = createActionRou
     }),
     description: "测试 Webhook 配置",
     tags: ["通知管理"],
+    requiredRole: "admin",
   }
 );
 app.openapi(testWebhookRoute, testWebhookHandler);

+ 26 - 0
src/lib/api/action-adapter-openapi.ts

@@ -12,6 +12,7 @@ import { createRoute, z } from "@hono/zod-openapi";
 import type { Context } from "hono";
 import type { ActionResult } from "@/actions/types";
 import { logger } from "@/lib/logger";
+import { validateKey } from "@/lib/auth";
 
 // Server Action 函数签名 (支持两种格式)
 type ServerAction =
@@ -161,6 +162,7 @@ export function createActionRoute(
     summary,
     tags = [module],
     requiresAuth = true,
+    requiredRole,
   } = options;
 
   // 创建 OpenAPI 路由定义
@@ -193,6 +195,30 @@ export function createActionRoute(
     const fullPath = `${module}.${actionName}`;
 
     try {
+      // 0. 认证检查 (如果需要)
+      if (requiresAuth) {
+        const authToken = c.req.header("Cookie")?.match(/auth-token=([^;]+)/)?.[1];
+        if (!authToken) {
+          logger.warn(`[ActionAPI] ${fullPath} 认证失败: 缺少 auth-token`);
+          return c.json({ ok: false, error: "未认证" }, 401);
+        }
+
+        const session = await validateKey(authToken);
+        if (!session) {
+          logger.warn(`[ActionAPI] ${fullPath} 认证失败: 无效的 auth-token`);
+          return c.json({ ok: false, error: "认证无效或已过期" }, 401);
+        }
+
+        // 检查角色权限
+        if (requiredRole === "admin" && session.user.role !== "admin") {
+          logger.warn(`[ActionAPI] ${fullPath} 权限不足: 需要 admin 角色`, {
+            userId: session.user.id,
+            userRole: session.user.role,
+          });
+          return c.json({ ok: false, error: "权限不足" }, 403);
+        }
+      }
+
       // 1. 解析并验证请求体 (Zod 自动验证)
       const body = await c.req.json().catch(() => ({}));