Skip to content
Open
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
94 changes: 90 additions & 4 deletions src/app/v1/_lib/proxy/model-redirector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,79 @@ import { logger } from "@/lib/logger";
import type { Provider } from "@/types/provider";
import type { ProxySession } from "./session";

type RedirectMatchType = "exact" | "wildcard" | "regex";

type ModelRedirectMatch = {
redirectedModel: string;
matchedPattern: string;
matchType: RedirectMatchType;
};

function parseRegexPattern(pattern: string): RegExp | null {
if (!pattern.startsWith("/")) {
return null;
}
const lastSlashIndex = pattern.lastIndexOf("/");
if (lastSlashIndex <= 0) {
return null;
}
const body = pattern.slice(1, lastSlashIndex);
const flags = pattern.slice(lastSlashIndex + 1);
if (!body) {
return null;
}
try {
return new RegExp(body, flags);
} catch {
return null;
}
}
Comment on lines +13 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

JavaScript ReDoS protection libraries 2025

💡 Result:

JavaScript ReDoS protection libraries (practical options in/around 2025)

1) Switch to a non-backtracking regex engine (strongest runtime protection)

  • re2 (node-re2): Native Node.js bindings to Google’s RE2; avoids catastrophic backtracking but does not support some JS regex features (notably lookarounds and backreferences). [1]
  • re2-wasm: Runs RE2 via WebAssembly (useful when native addons are undesirable); same “safe engine” idea and similar feature tradeoffs. [2][3]

2) Detect risky regexes (CI/lint/test-time controls)

  • redos-detector: Library/CLI that scores how vulnerable a pattern is (browser/Node/Deno). [4]
  • safe-regex2: Maintained fork of safe-regex; fast heuristic (“star height”) check—expects false positives/negatives (good as a cheap first pass). [5]
  • safe-regex: Original package (widely used but older; same heuristic limitations). [6]
  • vuln-regex-detector / davisjam/vuln-regex-detector: Heavier-weight detection aimed at finding real catastrophic-backtracking cases (includes project scanning workflow; testing can be expensive). [7][8]

3) Operational mitigations you should still do

Even with tooling, also apply input length limits and avoid building regexes directly from untrusted input. [9]

Notes on RE2 compatibility: if you plan to move patterns to RE2, many constructs are intentionally avoided/unsupported (e.g., lookarounds, backreferences). [1][10]

Sources:
[1] re2 (node-re2) package info on RE2 safety + unsupported features [2] re2-wasm npm [3] google/re2-wasm repo [4] redos-detector npm [5] safe-regex2 npm [6] safe-regex npm [7] vuln-regex-detector npm [8] davisjam/vuln-regex-detector GitHub [9] Bearer rule guidance on ReDoS mitigations [10] OWASP CRS note on non-backtracking/RE2-compatible regex constraints


🌐 Web query:

regular expression denial of service prevention techniques Node.js

💡 Result:

ReDoS prevention techniques in Node.js (practical checklist)

  1. Avoid “evil” patterns in the first place

    • Biggest risks: nested quantifiers and overlapping alternation (classic catastrophic backtracking). OWASP explicitly warns to “stay away from evil regexes” in Node.js. [1]
  2. Constrain what the regex has to process

    • Hard cap input length (and ideally also min/max lengths in the regex like {1,25}) as part of input validation guidance. [2]
    • Prefer allowlists (tight character classes) over broad patterns like .* / .+ when possible.
  3. Use a linear-time regex engine for untrusted regexes (or high-risk matches)

    • If users can supply patterns (or you must run complex patterns on untrusted data), use RE2 via the re2 (node-re2) package; it avoids catastrophic backtracking by using a non-backtracking engine (with feature tradeoffs like no lookaheads/backreferences). [3]
  4. Detect risky regexes in CI / code review

    • Add automated checks like safe-regex2 to flag potentially catastrophic patterns (useful, but it can have false positives/negatives). [4]
    • OWASP notes there are tools to test whether regexes are vulnerable to ReDoS. [1]
  5. Put regex work behind a “circuit breaker”

    • Node doesn’t have a built-in per-regex timeout, so for worst-case safety run high-risk matches in a Worker thread / child process and enforce a deadline (terminate the worker/process if it exceeds).
    • If you control Node/V8 startup flags, V8 has an experimental non-backtracking fallback after “excessive backtracks” (runtime hardening option; pattern/flag limitations apply). [5]

Sources: OWASP Node.js Security Cheat Sheet [1], OWASP Input Validation Cheat Sheet [2], re2/node-re2 docs [3], safe-regex2 docs [4], V8 non-backtracking RegExp engine blog [5].


🏁 Script executed:

# First, let's examine the actual file to understand the context better
cat -n src/app/v1/_lib/proxy/model-redirector.ts | head -100

Repository: dext7r/claude-code-hub

Length of output: 3456


🏁 Script executed:

# Let's check how modelRedirects is defined and where parseRegexPattern is used
rg "modelRedirects" src/app/v1/_lib/proxy/model-redirector.ts -A 2 -B 2

Repository: dext7r/claude-code-hub

Length of output: 1478


🏁 Script executed:

# Check the provider configuration to understand the source of modelRedirects data
rg "modelRedirects" src/ -l | head -20

Repository: dext7r/claude-code-hub

Length of output: 764


🏁 Script executed:

# Let's check how the modelRedirects are populated to confirm they're admin-configured
rg "provider\.modelRedirects\s*=" src/ -A 3 | head -40

Repository: dext7r/claude-code-hub

Length of output: 48


🏁 Script executed:

# Check the types to understand the Provider structure and source of modelRedirects
cat -n src/types/provider.ts | grep -A 5 -B 5 "modelRedirects"

Repository: dext7r/claude-code-hub

Length of output: 833


ReDoS 风险:管理员配置的正则模式可导致事件循环阻塞。

虽然 modelRedirects 来自供应商配置而非终端用户 HTTP 请求(降低了攻击面),但恶意或失误的正则表达式(如 /(a+)+$/)仍可在 regexPattern.test(originalModel) 执行时触发灾难性回溯,阻塞事件循环。现有的 try-catch 仅捕获语法错误,无法阻止计算复杂度攻击。

建议在 parseRegexPattern 中添加长度限制,这是业界公认的防护措施:

修复方案:添加正则 body 长度上限
 function parseRegexPattern(pattern: string): RegExp | null {
   if (!pattern.startsWith("/")) {
     return null;
   }
   const lastSlashIndex = pattern.lastIndexOf("/");
   if (lastSlashIndex <= 0) {
     return null;
   }
   const body = pattern.slice(1, lastSlashIndex);
   const flags = pattern.slice(lastSlashIndex + 1);
-  if (!body) {
+  if (!body || body.length > 200) {
     return null;
   }
   try {
     return new RegExp(body, flags);
   } catch {
     return null;
   }
 }
📝 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
function parseRegexPattern(pattern: string): RegExp | null {
if (!pattern.startsWith("/")) {
return null;
}
const lastSlashIndex = pattern.lastIndexOf("/");
if (lastSlashIndex <= 0) {
return null;
}
const body = pattern.slice(1, lastSlashIndex);
const flags = pattern.slice(lastSlashIndex + 1);
if (!body) {
return null;
}
try {
return new RegExp(body, flags);
} catch {
return null;
}
}
function parseRegexPattern(pattern: string): RegExp | null {
if (!pattern.startsWith("/")) {
return null;
}
const lastSlashIndex = pattern.lastIndexOf("/");
if (lastSlashIndex <= 0) {
return null;
}
const body = pattern.slice(1, lastSlashIndex);
const flags = pattern.slice(lastSlashIndex + 1);
if (!body || body.length > 200) {
return null;
}
try {
return new RegExp(body, flags);
} catch {
return null;
}
}
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 26-26: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(body, flags)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🤖 Prompt for AI Agents
In `@src/app/v1/_lib/proxy/model-redirector.ts` around lines 13 - 31, The
parseRegexPattern function currently accepts arbitrarily long regex bodies which
can lead to catastrophic backtracking (ReDoS) when later used (e.g.,
regexPattern.test(originalModel)); add a defensively small maximum length check
for the extracted body inside parseRegexPattern (and reject patterns where
body.length exceeds the limit) so that dangerous or overly complex patterns are
not compiled; keep the existing try/catch for syntax errors and return null on
length violation to preserve current behavior.


function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function buildWildcardRegex(pattern: string): RegExp | null {
if (!pattern.includes("*")) {
return null;
}
const escaped = escapeRegex(pattern);
const wildcard = escaped.replace(/\\\*/g, ".*");
return new RegExp(`^${wildcard}$`);
}

function findRedirectMatch(
modelRedirects: Record<string, string>,
originalModel: string
): ModelRedirectMatch | null {
const direct = modelRedirects[originalModel];
if (direct) {
return {
redirectedModel: direct,
matchedPattern: originalModel,
matchType: "exact",
};
}

for (const [pattern, redirectedModel] of Object.entries(modelRedirects)) {
if (!pattern || pattern === originalModel) {
continue;
}

const regexPattern = parseRegexPattern(pattern);
if (regexPattern && regexPattern.test(originalModel)) {
return { redirectedModel, matchedPattern: pattern, matchType: "regex" };
}

const wildcardRegex = buildWildcardRegex(pattern);
if (wildcardRegex && wildcardRegex.test(originalModel)) {
return { redirectedModel, matchedPattern: pattern, matchType: "wildcard" };
}
}

return null;
}

/**
* 模型重定向器
*
Expand Down Expand Up @@ -41,8 +114,8 @@ export class ModelRedirector {
}

// 检查是否有该模型的重定向配置
const redirectedModel = provider.modelRedirects[originalModel];
if (!redirectedModel) {
const match = findRedirectMatch(provider.modelRedirects, originalModel);
if (!match) {
// 如果新供应商对此模型没有重定向规则,且之前发生过重定向,需要重置
if (session.isModelRedirected()) {
ModelRedirector.resetToOriginal(session, originalModel, provider);
Expand All @@ -55,11 +128,14 @@ export class ModelRedirector {
}
return false;
}
const { redirectedModel, matchedPattern, matchType } = match;

// 执行重定向
logger.info("[ModelRedirector] Model redirected", {
originalModel,
redirectedModel,
matchedPattern,
matchType,
providerId: provider.id,
providerName: provider.name,
providerType: provider.providerType,
Expand Down Expand Up @@ -91,6 +167,8 @@ export class ModelRedirector {
newPath,
originalModel,
redirectedModel,
matchedPattern,
matchType,
});
}
}
Expand All @@ -117,11 +195,15 @@ export class ModelRedirector {
originalModel: originalModel,
redirectedModel: redirectedModel,
billingModel: originalModel, // 始终使用原始模型计费
matchedPattern,
matchType,
};
logger.debug("[ModelRedirector] Added modelRedirect to provider chain", {
providerId: provider.id,
originalModel,
redirectedModel,
matchedPattern,
matchType,
});
}

Expand All @@ -140,7 +222,8 @@ export class ModelRedirector {
return originalModel;
}

return provider.modelRedirects[originalModel] || originalModel;
const match = findRedirectMatch(provider.modelRedirects, originalModel);
return match?.redirectedModel || originalModel;
}

/**
Expand All @@ -151,7 +234,10 @@ export class ModelRedirector {
* @returns 是否配置了重定向
*/
static hasRedirect(model: string, provider: Provider): boolean {
return !!(provider.modelRedirects && model && provider.modelRedirects[model]);
if (!provider.modelRedirects || !model) {
return false;
}
return !!findRedirectMatch(provider.modelRedirects, model);
}

/**
Expand Down
9 changes: 7 additions & 2 deletions src/app/v1/_lib/proxy/provider-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getSystemSettings } from "@/repository/system-config";
import type { ProviderChainItem } from "@/types/message";
import type { Provider } from "@/types/provider";
import type { ClientFormat } from "./format-mapper";
import { ModelRedirector } from "./model-redirector";
import { ProxyResponses } from "./responses";
import type { ProxySession } from "./session";

Expand Down Expand Up @@ -125,7 +126,10 @@ function providerSupportsModel(provider: Provider, requestedModel: string): bool
return true;
}
// 检查白名单
return provider.allowedModels.includes(requestedModel);
return (
provider.allowedModels.includes(requestedModel) ||
ModelRedirector.hasRedirect(requestedModel, provider)
);
}

// 1b. 非 Anthropic 提供商不支持 Claude 模型调度
Expand All @@ -137,7 +141,8 @@ function providerSupportsModel(provider: Provider, requestedModel: string): bool
// 原因:允许 Claude 类型供应商通过 allowedModels/modelRedirects 声明支持非 Claude 模型
// 场景:Claude 供应商配置模型重定向,将 gemini-* 请求转发到真实的 Gemini 上游
const explicitlyDeclared = !!(
provider.allowedModels?.includes(requestedModel) || provider.modelRedirects?.[requestedModel]
provider.allowedModels?.includes(requestedModel) ||
ModelRedirector.hasRedirect(requestedModel, provider)
);

if (explicitlyDeclared) {
Expand Down
2 changes: 2 additions & 0 deletions src/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface ProviderChainItem {
originalModel: string; // 用户请求的模型(计费依据)
redirectedModel: string; // 实际转发的模型
billingModel: string; // 计费模型(通常等于 originalModel)
matchedPattern?: string; // 匹配的重定向模式
matchType?: "exact" | "wildcard" | "regex"; // 匹配类型
};

// 错误信息(记录失败时的上游报错)
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/proxy/model-redirector-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import { ModelRedirector } from "@/app/v1/_lib/proxy/model-redirector";
import type { Provider } from "@/types/provider";

function buildProvider(modelRedirects: Record<string, string>): Provider {
return {
id: 1,
name: "test-provider",
providerType: "claude",
isEnabled: true,
weight: 1,
priority: 0,
costMultiplier: 1,
groupTag: null,
modelRedirects,
} as Provider;
}

describe("ModelRedirector pattern matching", () => {
test("matches wildcard patterns", () => {
const provider = buildProvider({
"gpt-4o-*": "claude-sonnet-4",
});

expect(ModelRedirector.getRedirectedModel("gpt-4o-mini", provider)).toBe("claude-sonnet-4");
expect(ModelRedirector.hasRedirect("gpt-4o-mini", provider)).toBe(true);
});

test("matches regex patterns", () => {
const provider = buildProvider({
"/^gpt-4o-(mini|preview)$/": "claude-sonnet-4",
});

expect(ModelRedirector.getRedirectedModel("gpt-4o-mini", provider)).toBe("claude-sonnet-4");
expect(ModelRedirector.getRedirectedModel("gpt-4o-preview", provider)).toBe("claude-sonnet-4");
expect(ModelRedirector.hasRedirect("gpt-4o-preview", provider)).toBe(true);
});

test("falls back when regex is invalid", () => {
const provider = buildProvider({
"/[invalid/": "claude-sonnet-4",
"claude-haiku": "claude-sonnet-4",
});

expect(ModelRedirector.getRedirectedModel("claude-haiku", provider)).toBe("claude-sonnet-4");
expect(ModelRedirector.hasRedirect("claude-haiku", provider)).toBe(true);
expect(ModelRedirector.getRedirectedModel("gpt-4o-mini", provider)).toBe("gpt-4o-mini");
});
});
76 changes: 76 additions & 0 deletions tests/unit/proxy/provider-selector-model-redirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,80 @@ describe("ProxyProviderResolver.pickRandomProvider - model redirect", () => {
expect(context.requestedModel).toBe("claude-test");
expect(provider?.id).toBe(1);
});

test("allows providers with wildcard model redirects for non-claude models", async () => {
const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");

vi.spyOn(ProxyProviderResolver as any, "filterByLimits").mockImplementation(
async (...args: unknown[]) => args[0] as Provider[]
);

const providers: Provider[] = [
{
id: 2,
name: "p2",
isEnabled: true,
providerType: "claude",
groupTag: null,
weight: 1,
priority: 0,
costMultiplier: 1,
allowedModels: [],
modelRedirects: {
"gpt-4o-*": "claude-sonnet-4",
},
} as unknown as Provider,
];

const session = {
originalFormat: "claude",
authState: null,
getProvidersSnapshot: async () => providers,
getOriginalModel: () => "gpt-4o-mini",
getCurrentModel: () => "gpt-4o-mini",
clientRequestsContext1m: () => false,
} as any;

const { provider } = await (ProxyProviderResolver as any).pickRandomProvider(session, []);

expect(provider?.id).toBe(2);
});

test("allows claude providers with redirect patterns even when allowedModels excludes the model", async () => {
const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");

vi.spyOn(ProxyProviderResolver as any, "filterByLimits").mockImplementation(
async (...args: unknown[]) => args[0] as Provider[]
);

const providers: Provider[] = [
{
id: 3,
name: "p3",
isEnabled: true,
providerType: "claude",
groupTag: null,
weight: 1,
priority: 0,
costMultiplier: 1,
allowedModels: ["claude-legacy"],
modelRedirects: {
"claude-3-*": "claude-legacy",
},
} as unknown as Provider,
];

const session = {
originalFormat: "claude",
authState: null,
getProvidersSnapshot: async () => providers,
getOriginalModel: () => "claude-3-haiku",
getCurrentModel: () => "claude-3-haiku",
clientRequestsContext1m: () => false,
} as any;

const { provider } = await (ProxyProviderResolver as any).pickRandomProvider(session, []);

expect(provider?.id).toBe(3);
});
});