Skip to content
Closed
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
3 changes: 3 additions & 0 deletions drizzle/0044_add_anthropic_warmup_intercept.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "system_settings"
ADD COLUMN IF NOT EXISTS "enable_anthropic_warmup_intercept" boolean NOT NULL DEFAULT false;

9 changes: 8 additions & 1 deletion drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,13 @@
"when": 1767362174718,
"tag": "0043_faithful_mother_askani",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1767461538553,
"tag": "0044_add_anthropic_warmup_intercept",
"breakpoints": true
}
]
}
}
2 changes: 2 additions & 0 deletions messages/en/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"nextPage": "Next Page",
"blocked": "Blocked",
"nonBilling": "Non-Billing",
"skipped": "Skipped",
"times": "times",
"loadedCount": "Loaded {count} records",
"loadingMore": "Loading more...",
Expand Down Expand Up @@ -156,6 +157,7 @@
"title": "Blocking Information",
"type": "Blocking Type",
"sensitiveWord": "Sensitive Word Blocking",
"warmupIntercept": "Warmup Intercept (CCH)",
"word": "Sensitive Word",
"matchType": "Match Type",
"matchTypeContains": "Contains Match",
Expand Down
2 changes: 2 additions & 0 deletions messages/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@
"verboseProviderErrorDesc": "When enabled, return detailed error messages when all providers are unavailable (including provider count, rate limit reasons, etc.); when disabled, only return a simple error code.",
"enableHttp2": "Enable HTTP/2",
"enableHttp2Desc": "When enabled, proxy requests will prefer HTTP/2 protocol. Automatically falls back to HTTP/1.1 on failure.",
"enableAnthropicWarmupIntercept": "Intercept Anthropic Warmup Requests",
"enableAnthropicWarmupInterceptDesc": "When enabled, CCH detects and short-circuits Claude/Anthropic warmup requests and returns a local short response to avoid unnecessary upstream calls.",
"cleanupSchedule": "Cleanup Schedule",
"cleanupScheduleDesc": "Select the execution schedule for automatic cleanup",
"configUpdated": "System settings updated. The page will refresh to apply currency display changes.",
Expand Down
2 changes: 2 additions & 0 deletions messages/ja/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"nextPage": "次へ",
"blocked": "ブロック済み",
"nonBilling": "非課金",
"skipped": "スキップ",
"times": "回",
"loadedCount": "{count} 件のレコードを読み込みました",
"loadingMore": "読み込み中...",
Expand Down Expand Up @@ -156,6 +157,7 @@
"title": "ブロック情報",
"type": "ブロックタイプ",
"sensitiveWord": "機密語句ブロック",
"warmupIntercept": "Warmup 先行応答(CCH)",
"word": "機密語句",
"matchType": "一致タイプ",
"matchTypeContains": "部分一致",
Expand Down
2 changes: 2 additions & 0 deletions messages/ja/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
"allowGlobalViewDesc": "無効にすると、一般ユーザーはダッシュボードで自分のキーの使用統計のみを表示できます。",
"verboseProviderError": "詳細なプロバイダーエラー",
"verboseProviderErrorDesc": "有効にすると、すべてのプロバイダーが利用不可の場合に詳細なエラーメッセージ(プロバイダー数、レート制限の理由など)を返します。無効の場合は簡潔なエラーコードのみを返します。",
"enableAnthropicWarmupIntercept": "Anthropic Warmup リクエストをインターセプト",
"enableAnthropicWarmupInterceptDesc": "有効にすると、CCH は Claude/Anthropic の Warmup リクエストを検出して上流への送信をスキップし、ローカルの短い応答を返します。",
"cleanupSchedule": "クリーンアップスケジュール",
"cleanupScheduleDesc": "自動クリーンアップの実行スケジュールを選択します",
"configUpdated": "システム設定が更新されました。ページが更新され、通貨表示の変更が適用されます。",
Expand Down
2 changes: 2 additions & 0 deletions messages/ru/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"nextPage": "Следующая",
"blocked": "Заблокировано",
"nonBilling": "Не тарифицируется",
"skipped": "Пропущено",
"times": "раз",
"loadedCount": "Загружено {count} записей",
"loadingMore": "Загрузка...",
Expand Down Expand Up @@ -156,6 +157,7 @@
"title": "Информация о блокировке",
"type": "Тип блокировки",
"sensitiveWord": "Блокировка по ключевым словам",
"warmupIntercept": "Warmup перехвачен (CCH)",
"word": "Ключевое слово",
"matchType": "Тип совпадения",
"matchTypeContains": "Содержит",
Expand Down
2 changes: 2 additions & 0 deletions messages/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
"allowGlobalViewDesc": "При отключении обычные пользователи могут видеть только статистику использования своих ключей на панели.",
"verboseProviderError": "Подробные ошибки провайдеров",
"verboseProviderErrorDesc": "При включении возвращает подробные сообщения об ошибках при недоступности всех провайдеров (количество провайдеров, причины ограничений и т.д.); при отключении возвращает только простой код ошибки.",
"enableAnthropicWarmupIntercept": "Перехватывать Anthropic Warmup-запросы",
"enableAnthropicWarmupInterceptDesc": "При включении CCH обнаруживает Warmup-запросы Claude/Anthropic, пропускает отправку вверх по цепочке и возвращает локальный короткий ответ.",
"cleanupSchedule": "График очистки",
"cleanupScheduleDesc": "Выбрать расписание автоматической очистки",
"configUpdated": "Параметры системы обновлены. Страница обновится для применения изменений валюты.",
Expand Down
2 changes: 2 additions & 0 deletions messages/zh-CN/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"nextPage": "下一页",
"blocked": "被拦截",
"nonBilling": "非计费",
"skipped": "已跳过",
"times": "次",
"loadedCount": "已加载 {count} 条记录",
"loadingMore": "加载更多中...",
Expand Down Expand Up @@ -156,6 +157,7 @@
"title": "拦截信息",
"type": "拦截类型",
"sensitiveWord": "敏感词拦截",
"warmupIntercept": "Warmup 抢答(CCH)",
"word": "敏感词",
"matchType": "匹配类型",
"matchTypeContains": "包含匹配",
Expand Down
2 changes: 2 additions & 0 deletions messages/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"verboseProviderErrorDesc": "开启后,当所有供应商不可用时返回详细错误信息(包含供应商数量、限流原因等);关闭后仅返回简洁错误码。",
"enableHttp2": "启用 HTTP/2",
"enableHttp2Desc": "启用后,代理请求将优先使用 HTTP/2 协议。如果 HTTP/2 失败,将自动降级到 HTTP/1.1。",
"enableAnthropicWarmupIntercept": "拦截 Anthropic Warmup 请求",
"enableAnthropicWarmupInterceptDesc": "开启后,CCH 将识别并拦截 Claude/Anthropic 的 Warmup 请求,直接返回简短响应,减少不必要的供应商调用。",
"saveSettings": "保存设置",
"keepDays": "保留天数",
"keepDaysDesc": "清理超过此天数的历史日志",
Expand Down
2 changes: 2 additions & 0 deletions messages/zh-TW/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"nextPage": "下一頁",
"blocked": "已攔截",
"nonBilling": "非計費",
"skipped": "已跳過",
"times": "次",
"loadedCount": "已載入 {count} 筆記錄",
"loadingMore": "載入更多中...",
Expand Down Expand Up @@ -156,6 +157,7 @@
"title": "攔截資訊",
"type": "攔截類型",
"sensitiveWord": "敏感詞攔截",
"warmupIntercept": "Warmup 搶答(CCH)",
"word": "敏感詞",
"matchType": "匹配類型",
"matchTypeContains": "包含匹配",
Expand Down
2 changes: 2 additions & 0 deletions messages/zh-TW/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
"allowGlobalViewDesc": "關閉後,普通使用者在儀表板僅能查看自己金鑰的使用統計。",
"verboseProviderError": "詳細供應商錯誤資訊",
"verboseProviderErrorDesc": "開啟後,當所有供應商不可用時返回詳細錯誤資訊(包含供應商數量、限流原因等);關閉後僅返回簡潔錯誤碼。",
"enableAnthropicWarmupIntercept": "攔截 Anthropic Warmup 請求",
"enableAnthropicWarmupInterceptDesc": "開啟後,CCH 會識別並攔截 Claude/Anthropic 的 Warmup 請求,直接回傳簡短回應,減少不必要的供應商呼叫。",
"cleanupSchedule": "清理週期",
"cleanupScheduleDesc": "選擇自動清理的執行週期",
"configUpdated": "系統設定已更新,頁面將重新整理以應用貨幣顯示變更。",
Expand Down
2 changes: 2 additions & 0 deletions src/actions/my-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export async function getMyTodayStats(): Promise<ActionResult<MyTodayStats>> {
and(
eq(messageRequest.key, session.key.key),
isNull(messageRequest.deletedAt),
sql`${messageRequest.blockedBy} IS DISTINCT FROM 'warmup'`,

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

🏁 Script executed:

# First, let's examine the code at line 306 in src/actions/my-usage.ts
head -320 src/actions/my-usage.ts | tail -30

Repository: ding113/claude-code-hub

Length of output: 1314


🏁 Script executed:

# Let's also check the broader context around line 306
sed -n '290,320p' src/actions/my-usage.ts

Repository: ding113/claude-code-hub

Length of output: 1359


🏁 Script executed:

# Check if the file exists and get its structure
wc -l src/actions/my-usage.ts

Repository: ding113/claude-code-hub

Length of output: 94


🏁 Script executed:

# Search for the messageRequest table and blockedBy field in schema
rg "messageRequest|blockedBy|blocked_by" src/drizzle/schema.ts -A 3 -B 3

Repository: ding113/claude-code-hub

Length of output: 2680


🏁 Script executed:

# Look for index definitions related to blocked_by
rg "index\(|\.index\(" src/drizzle/schema.ts -A 2 -B 2

Repository: ding113/claude-code-hub

Length of output: 4394


🏁 Script executed:

# Check for any index() or uniqueIndex() definitions on the messageRequest table
fd "schema" src/drizzle --type f

Repository: ding113/claude-code-hub

Length of output: 88


添加 blockedBy 列的索引以优化性能。

过滤逻辑正确使用了 IS DISTINCT FROM 操作符,能够正确排除 blockedBy = 'warmup' 的记录同时保留 NULL 值。但 blockedBy 列当前没有索引,在大数据量下会导致全表扫描。建议添加独立索引或将其包含在复合索引中,特别是对于频繁的统计查询。

🤖 Prompt for AI Agents
In src/actions/my-usage.ts around line 306, the query filters on blockedBy using
IS DISTINCT FROM 'warmup' but blockedBy lacks an index causing full table scans
on large datasets; add a database index for the blockedBy column (either a
standalone index or include blockedBy in the relevant composite index used by
these stats queries), implement the change via a migration script (or schema
change) so it’s applied in all environments, and ensure the migration is tested
on representative data; optionally consider a partial index that excludes the
'warmup' value if that better matches query patterns.

gte(messageRequest.createdAt, timeRange.startTime),
sql`${messageRequest.createdAt} < ${timeRange.endTime}`
)
Expand All @@ -322,6 +323,7 @@ export async function getMyTodayStats(): Promise<ActionResult<MyTodayStats>> {
and(
eq(messageRequest.key, session.key.key),
isNull(messageRequest.deletedAt),
sql`${messageRequest.blockedBy} IS DISTINCT FROM 'warmup'`,
gte(messageRequest.createdAt, timeRange.startTime),
sql`${messageRequest.createdAt} < ${timeRange.endTime}`
)
Expand Down
2 changes: 2 additions & 0 deletions src/actions/system-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export async function saveSystemSettings(formData: {
enableClientVersionCheck?: boolean;
verboseProviderError?: boolean;
enableHttp2?: boolean;
enableAnthropicWarmupIntercept?: boolean;
}): Promise<ActionResult<SystemSettings>> {
try {
const session = await getSession();
Expand All @@ -57,6 +58,7 @@ export async function saveSystemSettings(formData: {
enableClientVersionCheck: validated.enableClientVersionCheck,
verboseProviderError: validated.verboseProviderError,
enableHttp2: validated.enableHttp2,
enableAnthropicWarmupIntercept: validated.enableAnthropicWarmupIntercept,
});

// Invalidate the system settings cache so proxy requests get fresh settings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { Link } from "@/i18n/routing";
import { cn, formatTokenAmount } from "@/lib/utils";
import { formatCurrency } from "@/lib/utils/currency";
import { WARMUP_INTERCEPT_BLOCKED_BY } from "@/lib/utils/performance-formatter";
import { formatProviderTimeline } from "@/lib/utils/provider-chain-formatter";
import type { ProviderChainItem } from "@/types/message";
import type { BillingModelSource } from "@/types/system-config";
Expand Down Expand Up @@ -272,7 +273,9 @@ export function ErrorDetailsDialog({
<Badge variant="outline" className="border-orange-600 text-orange-600">
{blockedBy === "sensitive_word"
? t("logs.details.blocked.sensitiveWord")
: blockedBy}
: blockedBy === WARMUP_INTERCEPT_BLOCKED_BY
? t("logs.details.blocked.warmupIntercept")
: blockedBy}
</Badge>
</div>
{parsedBlockedReason && (
Expand Down
10 changes: 8 additions & 2 deletions src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
calculateOutputRate,
formatDuration,
NON_BILLING_ENDPOINT,
WARMUP_INTERCEPT_BLOCKED_BY,
} from "@/lib/utils/performance-formatter";
import { formatProviderSummary } from "@/lib/utils/provider-chain-formatter";
import type { UsageLogRow } from "@/repository/usage-logs";
Expand Down Expand Up @@ -89,7 +90,8 @@ export function UsageLogsTable({
</TableRow>
) : (
logs.map((log) => {
const isNonBilling = log.endpoint === NON_BILLING_ENDPOINT;
const isWarmupIntercept = log.blockedBy === WARMUP_INTERCEPT_BLOCKED_BY;
const isNonBilling = log.endpoint === NON_BILLING_ENDPOINT || isWarmupIntercept;

return (
<TableRow
Expand Down Expand Up @@ -302,7 +304,11 @@ export function UsageLogsTable({
</TooltipProvider>
</TableCell>
<TableCell className="text-right font-mono text-xs">
{isNonBilling ? (
{isWarmupIntercept ? (
<span className="text-orange-700 dark:text-orange-300">
{t("logs.table.skipped")}
</span>
) : isNonBilling ? (
"-"
) : log.costUsd ? (
<TooltipProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
calculateOutputRate,
formatDuration,
NON_BILLING_ENDPOINT,
WARMUP_INTERCEPT_BLOCKED_BY,
} from "@/lib/utils/performance-formatter";
import { formatProviderSummary } from "@/lib/utils/provider-chain-formatter";
import type { BillingModelSource } from "@/types/system-config";
Expand Down Expand Up @@ -250,7 +251,8 @@ export function VirtualizedLogsTable({
);
}

const isNonBilling = log.endpoint === NON_BILLING_ENDPOINT;
const isWarmupIntercept = log.blockedBy === WARMUP_INTERCEPT_BLOCKED_BY;
const isNonBilling = log.endpoint === NON_BILLING_ENDPOINT || isWarmupIntercept;

return (
<div
Expand Down Expand Up @@ -467,7 +469,11 @@ export function VirtualizedLogsTable({

{/* Cost */}
<div className="flex-[0.7] min-w-[60px] text-right font-mono text-xs px-1">
{isNonBilling ? (
{isWarmupIntercept ? (
<span className="text-orange-700 dark:text-orange-300">
{t("logs.table.skipped")}
</span>
) : isNonBilling ? (
"-"
) : log.costUsd ? (
<TooltipProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface SystemSettingsFormProps {
| "billingModelSource"
| "verboseProviderError"
| "enableHttp2"
| "enableAnthropicWarmupIntercept"
>;
}

Expand All @@ -50,6 +51,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
initialSettings.verboseProviderError
);
const [enableHttp2, setEnableHttp2] = useState(initialSettings.enableHttp2);
const [enableAnthropicWarmupIntercept, setEnableAnthropicWarmupIntercept] = useState(
initialSettings.enableAnthropicWarmupIntercept
);
const [isPending, startTransition] = useTransition();

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
Expand All @@ -68,6 +72,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
billingModelSource,
verboseProviderError,
enableHttp2,
enableAnthropicWarmupIntercept,
});

if (!result.ok) {
Expand All @@ -82,6 +87,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
setBillingModelSource(result.data.billingModelSource);
setVerboseProviderError(result.data.verboseProviderError);
setEnableHttp2(result.data.enableHttp2);
setEnableAnthropicWarmupIntercept(result.data.enableAnthropicWarmupIntercept);
}

toast.success(t("configUpdated"));
Expand Down Expand Up @@ -192,6 +198,23 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
/>
</div>

<div className="flex items-start justify-between gap-4 rounded-lg border border-dashed border-border px-4 py-3">
<div>
<Label htmlFor="enable-anthropic-warmup-intercept" className="text-sm font-medium">
{t("enableAnthropicWarmupIntercept")}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{t("enableAnthropicWarmupInterceptDesc")}
</p>
</div>
<Switch
id="enable-anthropic-warmup-intercept"
checked={enableAnthropicWarmupIntercept}
onCheckedChange={(checked) => setEnableAnthropicWarmupIntercept(checked)}
disabled={isPending}
/>
</div>

<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? tCommon("saving") : t("saveSettings")}
Expand Down
1 change: 1 addition & 0 deletions src/app/[locale]/settings/config/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async function SettingsConfigContent() {
billingModelSource: settings.billingModelSource,
verboseProviderError: settings.verboseProviderError,
enableHttp2: settings.enableHttp2,
enableAnthropicWarmupIntercept: settings.enableAnthropicWarmupIntercept,
}}
/>
</Section>
Expand Down
9 changes: 9 additions & 0 deletions src/app/v1/_lib/proxy/guard-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ProxySensitiveWordGuard } from "./sensitive-word-guard";
import type { ProxySession } from "./session";
import { ProxySessionGuard } from "./session-guard";
import { ProxyVersionGuard } from "./version-guard";
import { ProxyWarmupGuard } from "./warmup-guard";

// Request type classification for pipeline presets
export enum RequestType {
Expand All @@ -31,6 +32,7 @@ export type GuardStepKey =
| "version"
| "probe"
| "session"
| "warmup"
| "requestFilter"
| "sensitive"
| "rateLimit"
Expand Down Expand Up @@ -91,6 +93,12 @@ const Steps: Record<GuardStepKey, GuardStep> = {
return null;
},
},
warmup: {
name: "warmup",
async execute(session) {
return ProxyWarmupGuard.ensure(session);
},
},
requestFilter: {
name: "requestFilter",
async execute(session) {
Expand Down Expand Up @@ -170,6 +178,7 @@ export const CHAT_PIPELINE: GuardConfig = {
"version",
"probe",
"session",
"warmup",

@github-actions github-actions Bot Jan 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[HIGH] [SECURITY-VULNERABILITY] Warmup intercept bypasses rate limiting on the chat pipeline

Evidence (src/app/v1/_lib/proxy/guard-pipeline.ts:181):

steps: [
  // ...
  "session",
  "warmup", // returns early before rate limiting
  "requestFilter",
  "sensitive",
  "rateLimit",
  // ...
]

Why this is a problem: ProxyWarmupGuard.ensure() can return a 200 Response before ProxyRateLimitGuard.ensure() runs, so an authenticated client can repeatedly send a warmup-shaped Claude request to bypass concurrent-session / RPM protection and generate unbounded local work (Redis session writes + DB inserts). This creates an easy DoS / quota-bypass vector on the main chat endpoint.

Suggested fix:

// Keep `/v1/messages` requests rate-limited: run warmup after `rateLimit`
steps: [
  // ...
  "session",
  "requestFilter",
  "sensitive",
  "rateLimit",
  "warmup",
  // ...
]

"requestFilter",
"sensitive",
"rateLimit",
Expand Down
Loading
Loading