From 5cab8ae8a536c17215a7172f43bfbc2b60b50a71 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 1 Mar 2026 23:17:37 +0800 Subject: [PATCH] fix: CSRF token accessibility and hook installation status - Remove HttpOnly from XSRF-TOKEN cookie for JavaScript readability - Add hook installation status detection in system settings API - Update InjectionControlTab to show installed hooks status - Add brace expansion support in globToRegex utility --- .../components/specs/InjectionControlTab.tsx | 17 ++++++++-- ccw/frontend/src/lib/api.ts | 1 + ccw/frontend/src/locales/en/common.json | 2 ++ ccw/frontend/src/locales/en/specs.json | 10 +++--- ccw/frontend/src/locales/zh/common.json | 2 ++ ccw/frontend/src/locales/zh/specs.json | 10 +++--- ccw/src/core/auth/csrf-middleware.ts | 3 +- ccw/src/core/routes/auth-routes.ts | 3 +- ccw/src/core/routes/system-routes.ts | 33 +++++++++++++++++-- ccw/src/core/server.ts | 3 +- ccw/src/utils/file-reader.ts | 17 ++++++++++ 11 files changed, 80 insertions(+), 21 deletions(-) diff --git a/ccw/frontend/src/components/specs/InjectionControlTab.tsx b/ccw/frontend/src/components/specs/InjectionControlTab.tsx index a9148fea..2efd8725 100644 --- a/ccw/frontend/src/components/specs/InjectionControlTab.tsx +++ b/ccw/frontend/src/components/specs/InjectionControlTab.tsx @@ -46,7 +46,7 @@ import { Layers, Filter, } from 'lucide-react'; -import { useInstallRecommendedHooks } from '@/hooks/useSystemSettings'; +import { useInstallRecommendedHooks, useSystemSettings } from '@/hooks/useSystemSettings'; import type { InjectionPreviewFile, InjectionPreviewResponse } from '@/lib/api'; import { getInjectionPreview, COMMAND_PREVIEWS, type CommandPreviewConfig } from '@/lib/api'; @@ -197,6 +197,9 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) { // State for hooks installation const [installingHookIds, setInstallingHookIds] = useState([]); + // Fetch system settings (for hooks installation status) + const systemSettingsQuery = useSystemSettings(); + // State for injection preview const [previewMode, setPreviewMode] = useState<'required' | 'all'>('required'); const [categoryFilter, setCategoryFilter] = useState('all'); @@ -349,10 +352,18 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) { const installedHookIds = useMemo(() => { const installed = new Set(); + const hooks = systemSettingsQuery.data?.recommendedHooks; + if (hooks) { + hooks.forEach(hook => { + if (hook.installed) { + installed.add(hook.id); + } + }); + } return installed; - }, []); + }, [systemSettingsQuery.data?.recommendedHooks]); - const installedCount = 0; + const installedCount = installedHookIds.size; const allHooksInstalled = installedCount === RECOMMENDED_HOOKS.length; const handleInstallHook = useCallback(async (hookId: string) => { diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 86c81ee8..58abdfbc 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -7555,6 +7555,7 @@ export interface SystemSettings { description: string; scope: 'global' | 'project'; autoInstall: boolean; + installed: boolean; }>; } diff --git a/ccw/frontend/src/locales/en/common.json b/ccw/frontend/src/locales/en/common.json index 4fe2b106..478b0b03 100644 --- a/ccw/frontend/src/locales/en/common.json +++ b/ccw/frontend/src/locales/en/common.json @@ -1,4 +1,6 @@ { + "cancel": "Cancel", + "save": "Save", "aria": { "toggleNavigation": "Toggle navigation menu", "refreshWorkspace": "Refresh workspace", diff --git a/ccw/frontend/src/locales/en/specs.json b/ccw/frontend/src/locales/en/specs.json index 2e11bae8..31a1eee5 100644 --- a/ccw/frontend/src/locales/en/specs.json +++ b/ccw/frontend/src/locales/en/specs.json @@ -303,13 +303,16 @@ "hookCommand": "Command", "hookScope": "Scope", "hookTimeout": "Timeout (ms)", - "hookFailMode": "Fail Mode" + "hookFailMode": "Fail Mode", + "editTitle": "Edit Spec: {title}", + "editDescription": "Modify spec metadata and settings." }, "form": { "readMode": "Read Mode", "priority": "Priority", "keywords": "Keywords", + "keywordsPlaceholder": "Enter keywords, press Enter or comma to add", "title": "Title", "titlePlaceholder": "Enter spec title", "addKeyword": "Add Keyword", @@ -322,11 +325,6 @@ "titleRequired": "Title is required" }, - "dialog": { - "editTitle": "Edit Spec: {title}", - "editDescription": "Modify spec metadata and settings." - }, - "hooks": { "installSuccess": "Hook installed successfully", "installError": "Failed to install hook", diff --git a/ccw/frontend/src/locales/zh/common.json b/ccw/frontend/src/locales/zh/common.json index dffe0a75..acf8bb07 100644 --- a/ccw/frontend/src/locales/zh/common.json +++ b/ccw/frontend/src/locales/zh/common.json @@ -1,4 +1,6 @@ { + "cancel": "取消", + "save": "保存", "aria": { "toggleNavigation": "切换导航菜单", "refreshWorkspace": "刷新工作区", diff --git a/ccw/frontend/src/locales/zh/specs.json b/ccw/frontend/src/locales/zh/specs.json index f33fd9a7..882bb867 100644 --- a/ccw/frontend/src/locales/zh/specs.json +++ b/ccw/frontend/src/locales/zh/specs.json @@ -310,13 +310,16 @@ "hookCommand": "执行命令", "hookScope": "作用域", "hookTimeout": "超时时间(ms)", - "hookFailMode": "失败模式" + "hookFailMode": "失败模式", + "editTitle": "编辑规范:{title}", + "editDescription": "修改规范元数据和设置。" }, "form": { "readMode": "读取模式", "priority": "优先级", "keywords": "关键词", + "keywordsPlaceholder": "输入关键词,按回车或逗号添加", "title": "标题", "titlePlaceholder": "输入规范标题", "addKeyword": "添加关键词", @@ -329,11 +332,6 @@ "titleRequired": "标题为必填项" }, - "dialog": { - "editTitle": "编辑规范:{title}", - "editDescription": "修改规范元数据和设置。" - }, - "hooks": { "installSuccess": "钩子安装成功", "installError": "钩子安装失败", diff --git a/ccw/src/core/auth/csrf-middleware.ts b/ccw/src/core/auth/csrf-middleware.ts index 01c32f43..b53b1418 100644 --- a/ccw/src/core/auth/csrf-middleware.ts +++ b/ccw/src/core/auth/csrf-middleware.ts @@ -51,7 +51,8 @@ function setCsrfCookie(res: ServerResponse, token: string, maxAgeSeconds: number const attributes = [ `XSRF-TOKEN=${encodeURIComponent(token)}`, 'Path=/', - 'HttpOnly', + // Note: XSRF-TOKEN must be readable by JavaScript for CSRF protection to work + // The token is also sent via X-CSRF-Token header, so not having HttpOnly is safe 'SameSite=Strict', `Max-Age=${maxAgeSeconds}`, ]; diff --git a/ccw/src/core/routes/auth-routes.ts b/ccw/src/core/routes/auth-routes.ts index 409fd445..b5b1613b 100644 --- a/ccw/src/core/routes/auth-routes.ts +++ b/ccw/src/core/routes/auth-routes.ts @@ -71,7 +71,8 @@ function setCsrfCookie(res: ServerResponse, token: string, maxAgeSeconds: number const attributes = [ `XSRF-TOKEN=${encodeURIComponent(token)}`, 'Path=/', - 'HttpOnly', + // Note: XSRF-TOKEN must be readable by JavaScript for CSRF protection to work + // The token is also sent via X-CSRF-Token header, so not having HttpOnly is safe 'SameSite=Strict', `Max-Age=${maxAgeSeconds}`, ]; diff --git a/ccw/src/core/routes/system-routes.ts b/ccw/src/core/routes/system-routes.ts index a0b6ebd5..ce61ca91 100644 --- a/ccw/src/core/routes/system-routes.ts +++ b/ccw/src/core/routes/system-routes.ts @@ -90,6 +90,27 @@ function readSettingsFile(filePath: string): Record { } } +/** + * Check if a recommended hook is installed in settings + */ +function isHookInstalled( + settings: Record & { hooks?: Record }, + hook: typeof RECOMMENDED_HOOKS[number] +): boolean { + const hooks = settings.hooks; + if (!hooks) return false; + + const eventHooks = hooks[hook.event]; + if (!eventHooks || !Array.isArray(eventHooks)) return false; + + // Check if hook exists in nested hooks array (by command) + return eventHooks.some((entry) => { + const entryHooks = (entry as Record).hooks as Array> | undefined; + if (!entryHooks || !Array.isArray(entryHooks)) return false; + return entryHooks.some((h) => (h as Record).command === hook.command); + }); +} + /** * Get system settings from global settings file */ @@ -97,12 +118,18 @@ function getSystemSettings(): { injectionControl: typeof DEFAULT_INJECTION_CONTROL; personalSpecDefaults: typeof DEFAULT_PERSONAL_SPEC_DEFAULTS; devProgressInjection: typeof DEFAULT_DEV_PROGRESS_INJECTION; - recommendedHooks: typeof RECOMMENDED_HOOKS; + recommendedHooks: Array; } { - const settings = readSettingsFile(GLOBAL_SETTINGS_PATH) as Record; + const settings = readSettingsFile(GLOBAL_SETTINGS_PATH) as Record & { hooks?: Record }; const system = (settings.system || {}) as Record; const user = (settings.user || {}) as Record; + // Check installation status for each recommended hook + const recommendedHooksWithStatus = RECOMMENDED_HOOKS.map(hook => ({ + ...hook, + installed: isHookInstalled(settings, hook) + })); + return { injectionControl: { ...DEFAULT_INJECTION_CONTROL, @@ -116,7 +143,7 @@ function getSystemSettings(): { ...DEFAULT_DEV_PROGRESS_INJECTION, ...((system.devProgressInjection || {}) as Record) } as typeof DEFAULT_DEV_PROGRESS_INJECTION, - recommendedHooks: RECOMMENDED_HOOKS + recommendedHooks: recommendedHooksWithStatus }; } diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index 5841a4d7..2ad463a1 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -257,7 +257,8 @@ function setCsrfCookie(res: http.ServerResponse, token: string, maxAgeSeconds: n const attributes = [ `XSRF-TOKEN=${encodeURIComponent(token)}`, 'Path=/', - 'HttpOnly', + // Note: XSRF-TOKEN must be readable by JavaScript for CSRF protection to work + // The token is also sent via X-CSRF-Token header, so not having HttpOnly is safe 'SameSite=Strict', `Max-Age=${maxAgeSeconds}`, ]; diff --git a/ccw/src/utils/file-reader.ts b/ccw/src/utils/file-reader.ts index 31c952ae..0aa84d4f 100644 --- a/ccw/src/utils/file-reader.ts +++ b/ccw/src/utils/file-reader.ts @@ -64,8 +64,25 @@ export function isBinaryFile(filePath: string): boolean { /** * Convert glob pattern to regex + * Supports: *, ?, and brace expansion {a,b,c} */ export function globToRegex(pattern: string): RegExp { + // Handle brace expansion: *.{md,json,ts} -> (?:.*\.md|.*\.json|.*\.ts) + const braceMatch = pattern.match(/^(.*)\{([^}]+)\}(.*)$/); + if (braceMatch) { + const [, prefix, options, suffix] = braceMatch; + const optionList = options.split(',').map(opt => `${prefix}${opt}${suffix}`); + // Create a regex that matches any of the expanded patterns + const expandedPatterns = optionList.map(opt => { + return opt + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + }); + return new RegExp(`^(?:${expandedPatterns.join('|')})$`, 'i'); + } + + // Standard glob conversion const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*')