Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 15 additions & 75 deletions src/actions/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use server";

import { getSession } from "@/lib/auth";
import type { NotificationJobType } from "@/lib/constants/notification.constants";
import { logger } from "@/lib/logger";
import { WebhookNotifier } from "@/lib/webhook";
Expand All @@ -11,75 +12,14 @@ import {
updateNotificationSettings,
} 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] = ipv4Match.map(Number);
// 私有 IP 范围
if (a === 127) return true; // 127.0.0.0/8 (loopback range)
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
}

// 检查 IPv6 私有地址范围
// 移除方括号(如果存在)用于 IPv6 地址检查
const ipv6Hostname = hostname.replace(/^\[|\]$/g, "");
// IPv6-mapped IPv4 loopback (::ffff:127.x.x.x)
if (
ipv6Hostname.startsWith("::ffff:127.") ||
ipv6Hostname.startsWith("::ffff:10.") ||
ipv6Hostname.startsWith("::ffff:192.168.") ||
ipv6Hostname.startsWith("::ffff:0.")
) {
return true;
}
// IPv6-mapped IPv4 172.16-31.x.x
const ipv6MappedMatch = ipv6Hostname.match(/^::ffff:172\.(\d+)\./);
if (ipv6MappedMatch) {
const secondOctet = parseInt(ipv6MappedMatch[1], 10);
if (secondOctet >= 16 && secondOctet <= 31) return true;
}
// ULA (Unique Local Address): fc00::/7
if (ipv6Hostname.startsWith("fc") || ipv6Hostname.startsWith("fd")) {
return true;
}
// Link-local: fe80::/10
if (ipv6Hostname.startsWith("fe80:")) {
return true;
}

// 危险端口
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 视为不安全
}
}

/**
* 获取通知设置
*/
export async function getNotificationSettingsAction(): Promise<NotificationSettings> {
const session = await getSession();
if (!session || session.user.role !== "admin") {
throw new Error("无权限执行此操作");
}
return getNotificationSettings();
}
Comment on lines +19 to 24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

错误处理方式不一致可能导致问题。

getNotificationSettingsAction 在权限检查失败时抛出错误(line 21),而同文件中的其他两个函数(updateNotificationSettingsActiontestWebhookAction)返回错误对象。这种不一致可能导致:

  1. 调用方需要不同的错误处理逻辑
  2. 未捕获的异常可能导致运行时错误
  3. 前端错误展示不统一
🔎 建议统一错误处理方式
 export async function getNotificationSettingsAction(): Promise<NotificationSettings> {
   const session = await getSession();
   if (!session || session.user.role !== "admin") {
-    throw new Error("无权限执行此操作");
+    throw new Error("无权限执行此操作");  // 如果保持抛出,确保调用方有 try-catch
   }
   return getNotificationSettings();
 }

或者修改返回类型以与其他函数保持一致:

-export async function getNotificationSettingsAction(): Promise<NotificationSettings> {
+export async function getNotificationSettingsAction(): Promise<
+  { success: true; data: NotificationSettings } | { success: false; error: string }
+> {
   const session = await getSession();
   if (!session || session.user.role !== "admin") {
-    throw new Error("无权限执行此操作");
+    return { success: false, error: "无权限执行此操作" };
   }
-  return getNotificationSettings();
+  const settings = await getNotificationSettings();
+  return { success: true, data: settings };
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/actions/notifications.ts around lines 19-24, the function
getNotificationSettingsAction currently throws an Error on failed permission
check while the other actions return error objects; change this to return an
error object instead of throwing to keep behavior consistent (e.g., return {
error: "无权限执行此操作" }), update the function's return type/signature if needed to
include the error shape, and adjust any callers/types to expect and handle the
error object rather than catching exceptions.


Expand All @@ -90,6 +30,11 @@ export async function updateNotificationSettingsAction(
payload: UpdateNotificationSettingsInput
): Promise<{ success: boolean; data?: NotificationSettings; error?: string }> {
try {
const session = await getSession();
if (!session || session.user.role !== "admin") {
return { success: false, error: "无权限执行此操作" };
}

const updated = await updateNotificationSettings(payload);

// 重新调度通知任务(仅生产环境)
Expand Down Expand Up @@ -121,22 +66,17 @@ export async function testWebhookAction(
webhookUrl: string,
type: NotificationJobType
): Promise<{ success: boolean; error?: string }> {
const session = await getSession();
if (!session || session.user.role !== "admin") {
return { success: false, error: "无权限执行此操作" };
}

if (!webhookUrl || !webhookUrl.trim()) {
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 notifier = new WebhookNotifier(trimmedUrl, { maxRetries: 1 });
const testMessage = buildTestMessage(type);
Expand Down
91 changes: 3 additions & 88 deletions src/actions/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "@/lib/redis/circuit-breaker-config";
import type { Context1mPreference } from "@/lib/special-attributes";
import { maskKey } from "@/lib/utils/validation";
import { validateProviderUrlForConnectivity } from "@/lib/validation/provider-url";
import { CreateProviderSchema, UpdateProviderSchema } from "@/lib/validation/schemas";
import {
createProvider,
Expand Down Expand Up @@ -1785,92 +1786,6 @@ function mergeStreamChunks(chunks: ProviderApiResponse[]): ProviderApiResponse {
return base;
}

type ProviderUrlValidationError = {
message: string;
details: {
error: string;
errorType: "InvalidProviderUrl" | "BlockedUrl" | "BlockedPort";
};
};

function validateProviderUrlForConnectivity(
providerUrl: string
): { valid: true; normalizedUrl: string } | { valid: false; error: ProviderUrlValidationError } {
const trimmedUrl = providerUrl.trim();

try {
const parsedProviderUrl = new URL(trimmedUrl);

if (!["https:", "http:"].includes(parsedProviderUrl.protocol)) {
return {
valid: false,
error: {
message: "供应商地址格式无效",
details: {
error: "仅支持 HTTP 和 HTTPS 协议",
errorType: "InvalidProviderUrl",
},
},
};
}

const hostname = parsedProviderUrl.hostname.toLowerCase();
const blockedPatterns = [
/^localhost$/i,
/^127\.\d+\.\d+\.\d+$/,
/^10\.\d+\.\d+\.\d+$/,
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
/^192\.168\.\d+\.\d+$/,
/^169\.254\.\d+\.\d+$/,
/^::1$/,
/^fe80:/i,
/^fc00:/i,
/^fd00:/i,
];

if (blockedPatterns.some((pattern) => pattern.test(hostname))) {
return {
valid: false,
error: {
message: "供应商地址安全检查失败",
details: {
error: "不允许访问内部网络地址",
errorType: "BlockedUrl",
},
},
};
}

const port = parsedProviderUrl.port ? parseInt(parsedProviderUrl.port, 10) : null;
const dangerousPorts = [22, 23, 25, 3306, 5432, 6379, 27017, 9200];
if (port && dangerousPorts.includes(port)) {
return {
valid: false,
error: {
message: "供应商地址端口检查失败",
details: {
error: "不允许访问内部服务端口",
errorType: "BlockedPort",
},
},
};
}

return { valid: true, normalizedUrl: trimmedUrl };
} catch (error) {
return {
valid: false,
error: {
message: "供应商地址格式无效",
details: {
error: error instanceof Error ? error.message : "URL 解析失败",
errorType: "InvalidProviderUrl",
},
},
};
}
}

async function executeProviderApiTest(
data: ProviderApiTestArgs,
options: {
Expand Down Expand Up @@ -2630,8 +2545,8 @@ const SUB_STATUS_MESSAGES: Record<TestSubStatus, string> = {
};

/**
* Check if a URL is safe for API testing (SSRF prevention)
* Wraps validateProviderUrlForConnectivity with a simpler interface
* 检查 URL 是否可用于 API 测试(仅做基础格式校验)
* validateProviderUrlForConnectivity 的薄封装
*/
async function isUrlSafeForApiTest(
providerUrl: string
Expand Down
72 changes: 0 additions & 72 deletions src/actions/webhook-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,75 +20,6 @@ import {
} from "@/repository/webhook-targets";
import type { ActionResult } from "./types";

/**
* SSRF 防护:阻止访问内部/私有网络地址
*
* 说明:代理地址不做此限制(Telegram 在部分环境需要本地代理)。
*/
function isInternalUrl(urlString: string): boolean {
try {
const url = new URL(urlString);
const hostname = url.hostname.toLowerCase().replace(/\.$/, "");

if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return true;
}

// 云厂商元数据服务(SSRF 高危)
if (
hostname === "metadata.google.internal" ||
hostname === "169.254.169.254" ||
hostname === "100.100.100.200"
) {
return true;
}

const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
if (a === 127) return true;
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a === 0) return true;
}

const ipv6Hostname = hostname.replace(/^\[|\]$/g, "");
if (ipv6Hostname === "fd00:ec2::254") {
return true;
}
if (
ipv6Hostname.startsWith("::ffff:127.") ||
ipv6Hostname.startsWith("::ffff:10.") ||
ipv6Hostname.startsWith("::ffff:192.168.") ||
ipv6Hostname.startsWith("::ffff:0.")
) {
return true;
}
const ipv6MappedMatch = ipv6Hostname.match(/^::ffff:172\.(\d+)\./);
if (ipv6MappedMatch) {
const secondOctet = parseInt(ipv6MappedMatch[1], 10);
if (secondOctet >= 16 && secondOctet <= 31) return true;
}
if (ipv6Hostname.startsWith("fc") || ipv6Hostname.startsWith("fd")) {
return true;
}
if (ipv6Hostname.startsWith("fe80:")) {
return true;
}

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;
}
}

function trimToNull(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
Expand Down Expand Up @@ -124,9 +55,6 @@ function validateProviderConfig(params: {
if (!webhookUrl) {
throw new Error("Webhook URL 不能为空");
}
if (isInternalUrl(webhookUrl)) {
throw new Error("不允许访问内部网络地址");
}

if (providerType === "custom" && customTemplate !== undefined && !customTemplate) {
throw new Error("自定义 Webhook 需要配置模板");
Expand Down
48 changes: 48 additions & 0 deletions src/lib/validation/provider-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export type ProviderUrlValidationError = {
message: string;
details: {
error: string;
errorType: "InvalidProviderUrl";
};
};

/**
* 验证供应商地址是否是可用于连通性测试的 URL(仅做基础格式校验)
*
* 说明:此处不再限制内网地址/端口,统一交由管理员配置策略控制。
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] [SECURITY-VULNERABILITY] Cloud metadata SSRF endpoints are now allowed

Why this is a problem: validateProviderUrlForConnectivity() now permits any HTTP(S) URL (including link-local / cloud metadata endpoints). Admin-only endpoints still create outbound requests (provider tests / webhook sends), so a leaked admin token/session can be escalated into cloud credential theft by targeting metadata services (e.g. 169.254.169.254, metadata.google.internal, 100.100.100.200, fd00:ec2::254).

Suggested fix:

// src/lib/validation/provider-url.ts
const hostname = parsedProviderUrl.hostname.toLowerCase().replace(/\.$/, "");
const normalizedHost = hostname.replace(/^\[|\]$/g, "");

const blockedMetadataHosts = new Set([
  "169.254.169.254",
  "metadata.google.internal",
  "100.100.100.200",
  "fd00:ec2::254",
]);

if (blockedMetadataHosts.has(normalizedHost)) {
  return {
    valid: false,
    error: {
      message: "供应商地址安全检查失败",
      details: {
        error: "不允许访问云厂商元数据服务地址",
        errorType: "InvalidProviderUrl",
      },
    },
  };
}

*/
Comment on lines +9 to +13
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

文档明确了验证范围,但需考虑特殊风险地址

文档清楚说明了仅做基础格式校验,不限制内网地址。这与 PR 的"管理员责任模型"一致。

然而,即使在管理员配置场景下,某些特殊地址(如云厂商元数据服务)也应当被阻止,因为:

  • 它们不是常规的内部服务
  • 暴露云凭证可导致权限提升
  • 如果管理员会话被盗用,可被用于窃取云环境凭证

建议在文档中补充安全考量说明:

 /**
  * 验证供应商地址是否是可用于连通性测试的 URL(仅做基础格式校验)
  *
  * 说明:此处不再限制内网地址/端口,统一交由管理员配置策略控制。
+ * 
+ * 注意:即使允许内网地址,仍应阻止云厂商元数据服务地址(如 169.254.169.254)
+ * 以防止管理员会话被盗用时的权限提升攻击。
  */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 验证供应商地址是否是可用于连通性测试的 URL(仅做基础格式校验)
*
* 说明:此处不再限制内网地址/端口,统一交由管理员配置策略控制。
*/
/**
* 验证供应商地址是否是可用于连通性测试的 URL(仅做基础格式校验)
*
* 说明:此处不再限制内网地址/端口,统一交由管理员配置策略控制。
*
* 注意:即使允许内网地址,仍应阻止云厂商元数据服务地址(如 169.254.169.254
* 以防止管理员会话被盗用时的权限提升攻击。
*/

export function validateProviderUrlForConnectivity(
providerUrl: string
): { valid: true; normalizedUrl: string } | { valid: false; error: ProviderUrlValidationError } {
const trimmedUrl = providerUrl.trim();

try {
const parsedProviderUrl = new URL(trimmedUrl);

if (!["https:", "http:"].includes(parsedProviderUrl.protocol)) {
return {
valid: false,
error: {
message: "供应商地址格式无效",
details: {
error: "仅支持 HTTP 和 HTTPS 协议",
errorType: "InvalidProviderUrl",
},
},
};
}

return { valid: true, normalizedUrl: trimmedUrl };
} catch (error) {
return {
valid: false,
error: {
message: "供应商地址格式无效",
details: {
error: error instanceof Error ? error.message : "URL 解析失败",
errorType: "InvalidProviderUrl",
},
},
};
}
Comment on lines +14 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

考虑 DNS 重绑定攻击防护

当前验证仅在调用时解析一次 URL,不防止 DNS 重绑定攻击:

攻击场景:

  1. 管理员配置 http://evil.example.com/webhook
  2. 首次 DNS 查询返回公网 IP(通过验证)
  3. 后续请求时,DNS TTL 过期,查询返回内网 IP(如 192.168.1.1
  4. 应用访问内网服务

缓解建议:

  • 在实际发送请求时固定 DNS 解析结果(DNS pinning)
  • 记录首次解析的 IP,后续请求验证 IP 未变化
  • 或在网络层配置防火墙规则

由于这属于运行时防护而非配置验证范畴,可作为后续改进项。

💡 DNS pinning 实现参考

WebhookNotifier 或网络客户端层添加:

// 首次解析时记录 IP
const resolvedIp = await dns.lookup(hostname);
// 发送请求时使用固定 IP,但保留 Host header
fetch(`http://${resolvedIp}:${port}${path}`, {
  headers: { Host: hostname }
});
// 或使用支持 DNS pinning 的 HTTP 客户端库
🤖 Prompt for AI Agents
In src/lib/validation/provider-url.ts around lines 14 to 47, the validation only
parses the URL once and does not address DNS rebinding risks; update the
codebase by treating DNS-rebinding as a runtime/network concern rather than a
config validation fix: add a note in this validator (or its caller) documenting
that DNS pinning must be implemented where requests are made, and then implement
DNS pinning in the HTTP client or WebhookNotifier—on first resolve, record the
resolved IP(s) for the hostname and reuse those IPs for subsequent requests
while preserving the Host header or reject requests when the resolved IP
differs; alternatively ensure the network layer enforces firewall rules to block
private/internal IPs for configured provider URLs.

}
Loading
Loading