diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index 2f6c53c6..9d968ee3 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -1859,3 +1859,68 @@ {"type":"task.created","taskId":"task_20260128_eDAP99","status":"todo","id":"evt_cwMs41pu6DbA","timestamp":"2026-01-28T18:09:16.729Z"} {"type":"task.created","taskId":"task_20260128_kAmcUg","project":"project-b","status":"todo","id":"evt_UUJ844HuX3xQ","timestamp":"2026-01-28T18:09:16.730Z"} {"type":"task.created","taskId":"task_20260128_Y3HGTt","status":"todo","id":"evt_7Nw9EKlZ8vu3","timestamp":"2026-01-28T18:09:16.744Z"} +{"type":"task.status_changed","taskId":"task_20260128_swwATe","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_uGCkIUQhF7J8","timestamp":"2026-01-28T18:09:20.964Z"} +{"type":"task.status_changed","taskId":"task_20260128_CN-j0V","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_SkQ6sP9cxpf7","timestamp":"2026-01-28T18:09:27.643Z"} +{"type":"task.status_changed","taskId":"task_20260128_swwATe","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_9wzI1f04u6jt","timestamp":"2026-01-28T18:09:40.614Z"} +{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_6R77c38njZqb","timestamp":"2026-01-28T18:11:48.433Z"} +{"type":"task.created","taskId":"task_20260128_TFMspo","status":"todo","id":"evt_IKUO9lfCKa_s","timestamp":"2026-01-28T18:11:48.444Z"} +{"type":"task.created","taskId":"task_20260128_wSK-Js","status":"todo","id":"evt_ZYwVB_e451oP","timestamp":"2026-01-28T18:11:48.447Z"} +{"type":"task.status_changed","taskId":"task_20260128_wSK-Js","status":"in-progress","previousStatus":"todo","id":"evt_1OgKRfD2TQXa","timestamp":"2026-01-28T18:11:48.449Z"} +{"type":"task.created","taskId":"task_20260128_p-kb2p","status":"todo","id":"evt_IPpe-K8ryjyP","timestamp":"2026-01-28T18:11:48.450Z"} +{"type":"task.status_changed","taskId":"task_20260128_p-kb2p","status":"blocked","previousStatus":"todo","id":"evt_weuunRo6MEZQ","timestamp":"2026-01-28T18:11:48.452Z"} +{"type":"task.created","taskId":"task_20260128_PpK5gN","status":"todo","id":"evt_IE7CuDq0IVmy","timestamp":"2026-01-28T18:11:48.453Z"} +{"type":"task.status_changed","taskId":"task_20260128_PpK5gN","status":"done","previousStatus":"todo","id":"evt_TGd_DHh7YppE","timestamp":"2026-01-28T18:11:48.456Z"} +{"type":"task.created","taskId":"task_20260128_9217hJ","project":"my-project","status":"todo","id":"evt_jMfxXnNGhYMM","timestamp":"2026-01-28T18:11:48.457Z"} +{"type":"task.created","taskId":"task_20260128_nXHj3b","status":"todo","id":"evt_Ef_w_vsQ7BqW","timestamp":"2026-01-28T18:11:48.461Z"} +{"type":"task.created","taskId":"task_20260128_h23-w-","status":"todo","id":"evt_tS92LAPtNo2O","timestamp":"2026-01-28T18:11:48.469Z"} +{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_3qCOeXGAglK4","timestamp":"2026-01-28T18:11:48.469Z"} +{"type":"task.created","taskId":"task_20260128_vxwNCe","status":"todo","id":"evt_LxbRvzK63HL4","timestamp":"2026-01-28T18:11:48.479Z"} +{"type":"task.created","taskId":"task_20260128_KjjFw8","status":"todo","id":"evt_2oKujAHFjlOQ","timestamp":"2026-01-28T18:11:48.497Z"} +{"type":"task.status_changed","taskId":"task_20260128_vxwNCe","status":"in-progress","previousStatus":"todo","id":"evt_UU710c6hOXyO","timestamp":"2026-01-28T18:11:48.497Z"} +{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_ZP_ucp7_o8yC","timestamp":"2026-01-28T18:11:48.498Z"} +{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_6OZyAPqC1VB-","timestamp":"2026-01-28T18:11:48.502Z"} +{"type":"task.created","taskId":"task_20260128_TcFV_X","status":"todo","id":"evt_HD10u26qScJ0","timestamp":"2026-01-28T18:11:48.513Z"} +{"type":"task.created","taskId":"task_20260128_jk8Rg5","status":"todo","id":"evt__ho_XX87BKC7","timestamp":"2026-01-28T18:11:48.518Z"} +{"type":"task.created","taskId":"task_20260128_iSVtgO","status":"todo","id":"evt_FwpktRaJKq3I","timestamp":"2026-01-28T18:11:48.524Z"} +{"type":"task.created","taskId":"task_20260128_LOC-iO","status":"todo","id":"evt_iOLxkU1-kRPp","timestamp":"2026-01-28T18:11:48.526Z"} +{"type":"task.created","taskId":"task_20260128_J29c6i","status":"todo","id":"evt_ep8byjn7kEwK","timestamp":"2026-01-28T18:11:48.526Z"} +{"type":"task.created","taskId":"task_20260128_BmmExL","status":"todo","id":"evt_w1Lsw_S05S7v","timestamp":"2026-01-28T18:11:48.542Z"} +{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_YzfrPhUBJYGK","timestamp":"2026-01-28T18:11:48.548Z"} +{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_gi8VhxGrM-n9","timestamp":"2026-01-28T18:11:48.548Z"} +{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_d4XAHBijcVtx","timestamp":"2026-01-28T18:11:48.549Z"} +{"type":"task.created","taskId":"task_20260128_ZHy5q3","status":"todo","id":"evt_gy0s376SCifF","timestamp":"2026-01-28T18:11:48.564Z"} +{"type":"task.created","taskId":"task_20260128_V0AJss","status":"todo","id":"evt_XSCapPRZz7W6","timestamp":"2026-01-28T18:11:48.569Z"} +{"type":"task.created","taskId":"task_20260128_qVNOXC","status":"todo","id":"evt_mD-sdhPwyUNN","timestamp":"2026-01-28T18:11:48.569Z"} +{"type":"task.created","taskId":"task_20260128_COGVtg","status":"todo","id":"evt_qk6NneshYlA3","timestamp":"2026-01-28T18:11:48.570Z"} +{"type":"task.archived","taskId":"task_20260128_COGVtg","status":"todo","id":"evt_p-FmAxoYIM1c","timestamp":"2026-01-28T18:11:48.575Z"} +{"type":"task.created","taskId":"task_20260128_NGxXBN","project":"test-project","status":"todo","id":"evt_6D6I3RZUvfjh","timestamp":"2026-01-28T18:11:48.583Z"} +{"type":"task.created","taskId":"task_20260128_s-QIZ5","status":"todo","id":"evt_WMOukhpwsVdR","timestamp":"2026-01-28T18:11:48.589Z"} +{"type":"task.created","taskId":"task_20260128_Jcytqu","status":"todo","id":"evt_vpgIxJxqbsAT","timestamp":"2026-01-28T18:11:48.596Z"} +{"type":"task.created","taskId":"task_20260128_Vubl15","status":"todo","id":"evt_iyLiL4aA_th7","timestamp":"2026-01-28T18:11:48.597Z"} +{"type":"task.created","taskId":"task_20260128_154G9H","status":"todo","id":"evt_dUkUy9TPa-7M","timestamp":"2026-01-28T18:11:48.604Z"} +{"type":"task.created","taskId":"task_20260128_lORZMU","status":"todo","id":"evt_mG1FPjU-e59H","timestamp":"2026-01-28T18:11:48.604Z"} +{"type":"task.created","taskId":"task_20260128_32CsLz","status":"todo","id":"evt_NVNL8obApavR","timestamp":"2026-01-28T18:11:48.610Z"} +{"type":"task.created","taskId":"task_20260128_rqRN9i","status":"todo","id":"evt_7Htl75W9lDIM","timestamp":"2026-01-28T18:11:48.611Z"} +{"type":"task.created","taskId":"task_20260128_VdXSKE","status":"todo","id":"evt_QRaVQrRuDGkh","timestamp":"2026-01-28T18:11:48.613Z"} +{"type":"task.status_changed","taskId":"task_20260128_VdXSKE","status":"in-progress","previousStatus":"todo","id":"evt_LljeTwr2UNlA","timestamp":"2026-01-28T18:11:48.634Z"} +{"type":"task.created","taskId":"task_20260128_OBSHXm","status":"todo","id":"evt_UJpjLtXwXIPW","timestamp":"2026-01-28T18:11:48.643Z"} +{"type":"task.created","taskId":"task_20260128_Rpl3KJ","status":"todo","id":"evt_coWyR6pCDggB","timestamp":"2026-01-28T18:11:48.647Z"} +{"type":"task.created","taskId":"task_20260128_znBAWz","status":"todo","id":"evt__0uWAiNwWrot","timestamp":"2026-01-28T18:11:48.654Z"} +{"type":"task.created","taskId":"task_20260128_fteh-o","status":"todo","id":"evt_T_od8EETBudP","timestamp":"2026-01-28T18:11:48.656Z"} +{"type":"task.created","taskId":"task_20260128_3y8irx","status":"todo","id":"evt_AUXMtERLNxZ1","timestamp":"2026-01-28T18:11:48.661Z"} +{"type":"task.created","taskId":"task_20260128_tK8pzJ","status":"todo","id":"evt_DAUdbB2j9SyM","timestamp":"2026-01-28T18:11:48.664Z"} +{"type":"task.created","taskId":"task_20260128_zScPpZ","status":"todo","id":"evt_2inc9Nw5nJAK","timestamp":"2026-01-28T18:11:48.669Z"} +{"type":"task.created","taskId":"task_20260128_h6E1UL","status":"todo","id":"evt_qmfsMaDlfLjp","timestamp":"2026-01-28T18:11:48.670Z"} +{"type":"task.created","taskId":"task_20260128_TUjAi2","status":"todo","id":"evt_VnMKBzwNxd3j","timestamp":"2026-01-28T18:11:48.684Z"} +{"type":"task.created","taskId":"task_20260128_0EClkv","status":"todo","id":"evt_C8ilXxG-V0Yf","timestamp":"2026-01-28T18:11:48.685Z"} +{"type":"task.created","taskId":"task_20260128_SWBMDh","status":"todo","id":"evt_r24LyZEucJ8R","timestamp":"2026-01-28T18:11:48.685Z"} +{"type":"task.created","taskId":"task_20260128_3_OXYG","status":"todo","id":"evt_BfsAMlEdoq5F","timestamp":"2026-01-28T18:11:48.687Z"} +{"type":"task.created","taskId":"task_20260128_zr04R0","status":"todo","id":"evt_fBbbj5YbQOYP","timestamp":"2026-01-28T18:11:48.688Z"} +{"type":"task.created","taskId":"task_20260128_IlWBM-","status":"todo","id":"evt_oHI236iLEk_P","timestamp":"2026-01-28T18:11:48.699Z"} +{"type":"task.created","taskId":"task_20260128_BbxkhZ","status":"todo","id":"evt_KAOjNuBCx2TN","timestamp":"2026-01-28T18:11:48.699Z"} +{"type":"task.created","taskId":"task_20260128_0VoSmW","status":"todo","id":"evt_-eVoP32ETVHB","timestamp":"2026-01-28T18:11:48.717Z"} +{"type":"task.created","taskId":"task_20260128_Ue8ELy","status":"todo","id":"evt_DUomeOquiqfB","timestamp":"2026-01-28T18:11:48.721Z"} +{"type":"task.created","taskId":"task_20260128_qfmqCM","project":"project-a","status":"todo","id":"evt_h_lRRZjsgfy7","timestamp":"2026-01-28T18:11:48.733Z"} +{"type":"task.created","taskId":"task_20260128_5Fg8MY","project":"project-a","status":"todo","id":"evt_VbM7dejrnzkS","timestamp":"2026-01-28T18:11:48.735Z"} +{"type":"task.created","taskId":"task_20260128_IbGo2I","project":"project-b","status":"todo","id":"evt_QFKC1gIjEuL1","timestamp":"2026-01-28T18:11:48.737Z"} +{"type":"task.created","taskId":"task_20260128_SzRC_-","status":"todo","id":"evt_ahnWw5Z8IyjY","timestamp":"2026-01-28T18:11:48.754Z"} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daafecb3..c497257c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: express: specifier: ^4.21.0 version: 4.22.1 + file-type: + specifier: ^21.3.0 + version: 21.3.0 gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -411,6 +414,9 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@borewit/text-codec@0.2.1': + resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1486,6 +1492,13 @@ packages: peerDependencies: react: ^18 || ^19 + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2468,6 +2481,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3676,6 +3693,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -3755,6 +3776,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + traverse@0.3.9: resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} @@ -3815,6 +3840,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -4179,6 +4208,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@borewit/text-codec@0.2.1': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.3)': dependencies: react: 19.2.3 @@ -5098,6 +5129,15 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.3 + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.6 @@ -6411,6 +6451,15 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-type@21.3.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -7688,6 +7737,10 @@ snapshots: strip-json-comments@3.1.1: {} + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -7801,6 +7854,12 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + traverse@0.3.9: {} tree-kill@1.2.2: {} @@ -7872,6 +7931,8 @@ snapshots: typescript@5.9.3: {} + uint8array-extras@1.5.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 3df81e0d..1bc5358b 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,59 @@ [ + { + "id": "activity_1769623785282_5ct7g0hqu", + "type": "comment_added", + "taskId": "task_20260128_swwATe", + "taskTitle": "PERF: Add response compression middleware (gzip/brotli)", + "details": { + "author": "Veritas", + "preview": "Added gzip response compression middleware using t..." + }, + "timestamp": "2026-01-28T18:09:45.282Z" + }, + { + "id": "activity_1769623780614_cocrgg5lj", + "type": "status_changed", + "taskId": "task_20260128_swwATe", + "taskTitle": "PERF: Add response compression middleware (gzip/brotli)", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T18:09:40.614Z" + }, + { + "id": "activity_1769623773684_uybg5070f", + "type": "comment_added", + "taskId": "task_20260128_CN-j0V", + "taskTitle": "SECURITY: Implement JWT secret rotation mechanism", + "details": { + "author": "Veritas", + "preview": "Implemented JWT secret rotation: added JwtSecretEn..." + }, + "timestamp": "2026-01-28T18:09:33.684Z" + }, + { + "id": "activity_1769623767643_0n5qemlpt", + "type": "status_changed", + "taskId": "task_20260128_CN-j0V", + "taskTitle": "SECURITY: Implement JWT secret rotation mechanism", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T18:09:27.643Z" + }, + { + "id": "activity_1769623760964_pkap6p5wr", + "type": "status_changed", + "taskId": "task_20260128_swwATe", + "taskTitle": "PERF: Add response compression middleware (gzip/brotli)", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T18:09:20.964Z" + }, { "id": "activity_1769623711885_ibjorjxag", "type": "comment_added", diff --git a/server/.veritas-kanban/status-history.json b/server/.veritas-kanban/status-history.json index 381b6a0c..126cf5b0 100644 --- a/server/.veritas-kanban/status-history.json +++ b/server/.veritas-kanban/status-history.json @@ -1,4 +1,14 @@ [ + { + "id": "status_1769623777416_gkqjv3llf", + "timestamp": "2026-01-28T18:09:37.416Z", + "previousStatus": "idle", + "newStatus": "sub-agent", + "taskId": "refactoring", + "taskTitle": "Codebase Refactoring Sprint", + "subAgentCount": 2, + "durationMs": 363371 + }, { "id": "status_1769623414045_xz4xmeaz8", "timestamp": "2026-01-28T18:03:34.045Z", diff --git a/server/package.json b/server/package.json index 85475480..c084fb1b 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "dotenv": "^17.2.3", "exceljs": "^4.4.0", "express": "^4.21.0", + "file-type": "^21.3.0", "gray-matter": "^4.0.3", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", diff --git a/server/src/__tests__/ws-origin-validation.test.ts b/server/src/__tests__/ws-origin-validation.test.ts new file mode 100644 index 00000000..a28c17f3 --- /dev/null +++ b/server/src/__tests__/ws-origin-validation.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for WebSocket Origin validation (CSWSH protection) + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { validateWebSocketOrigin } from '../middleware/auth.js'; + +const ALLOWED_ORIGINS = [ + 'http://localhost:5173', + 'http://localhost:3000', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:3000', +]; + +describe('validateWebSocketOrigin', () => { + const originalEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalEnv; + }); + + describe('no origin header (non-browser clients)', () => { + it('should allow undefined origin', () => { + const result = validateWebSocketOrigin(undefined, ALLOWED_ORIGINS); + expect(result.allowed).toBe(true); + expect(result.reason).toContain('non-browser'); + }); + }); + + describe('allowed origins', () => { + it('should allow origin in the allowed list', () => { + const result = validateWebSocketOrigin('http://localhost:5173', ALLOWED_ORIGINS); + expect(result.allowed).toBe(true); + expect(result.reason).toContain('allowed list'); + }); + + it('should allow 127.0.0.1 variant in the allowed list', () => { + const result = validateWebSocketOrigin('http://127.0.0.1:5173', ALLOWED_ORIGINS); + expect(result.allowed).toBe(true); + }); + + it('should reject origin not in the allowed list', () => { + process.env.NODE_ENV = 'production'; + const result = validateWebSocketOrigin('http://evil.com', ALLOWED_ORIGINS); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('not allowed'); + }); + }); + + describe('development mode localhost passthrough', () => { + beforeEach(() => { + process.env.NODE_ENV = 'development'; + }); + + it('should allow any localhost port in dev mode', () => { + const result = validateWebSocketOrigin('http://localhost:9999', ALLOWED_ORIGINS); + expect(result.allowed).toBe(true); + expect(result.reason).toContain('dev mode'); + }); + + it('should allow 127.0.0.1 with any port in dev mode', () => { + const result = validateWebSocketOrigin('http://127.0.0.1:8080', ALLOWED_ORIGINS); + expect(result.allowed).toBe(true); + expect(result.reason).toContain('dev mode'); + }); + + it('should still reject non-localhost origins in dev mode', () => { + const result = validateWebSocketOrigin('http://evil.com', ALLOWED_ORIGINS); + expect(result.allowed).toBe(false); + }); + }); + + describe('production mode strictness', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should reject localhost origin not in allowed list', () => { + const result = validateWebSocketOrigin('http://localhost:9999', ALLOWED_ORIGINS); + expect(result.allowed).toBe(false); + }); + + it('should reject external origins', () => { + const result = validateWebSocketOrigin('https://attacker.example.com', ALLOWED_ORIGINS); + expect(result.allowed).toBe(false); + }); + + it('should allow explicitly listed origins', () => { + const result = validateWebSocketOrigin('http://localhost:5173', ALLOWED_ORIGINS); + expect(result.allowed).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should reject malformed origin strings', () => { + process.env.NODE_ENV = 'production'; + const result = validateWebSocketOrigin('not-a-url', ALLOWED_ORIGINS); + expect(result.allowed).toBe(false); + }); + + it('should work with empty allowed list (non-browser still passes)', () => { + const result = validateWebSocketOrigin(undefined, []); + expect(result.allowed).toBe(true); + }); + + it('should reject everything except no-origin with empty allowed list in production', () => { + process.env.NODE_ENV = 'production'; + const result = validateWebSocketOrigin('http://localhost:5173', []); + expect(result.allowed).toBe(false); + }); + + it('should handle custom CORS_ORIGINS', () => { + const customOrigins = ['https://kanban.example.com', 'https://app.example.com']; + expect(validateWebSocketOrigin('https://kanban.example.com', customOrigins).allowed).toBe(true); + expect(validateWebSocketOrigin('https://other.example.com', customOrigins).allowed).toBe(false); + }); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 9af1c7b4..76cf6914 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -40,7 +40,7 @@ import { ConfigService } from './services/config-service.js'; import { initBroadcast } from './services/broadcast-service.js'; import { runStartupMigrations } from './services/migration-service.js'; import { errorHandler } from './middleware/error-handler.js'; -import { authenticate, authenticateWebSocket, getAuthStatus, type AuthenticatedWebSocket } from './middleware/auth.js'; +import { authenticate, authenticateWebSocket, validateWebSocketOrigin, getAuthStatus, type AuthenticatedWebSocket } from './middleware/auth.js'; import authRoutes from './routes/auth.js'; import { apiRateLimit } from './middleware/rate-limit.js'; import type { AgentOutput } from './services/clawdbot-agent-service.js'; @@ -244,7 +244,24 @@ app.use(errorHandler); const server = createServer(app); // WebSocket server for real-time updates -const wss = new WebSocketServer({ server, path: '/ws' }); +// verifyClient validates the Origin header BEFORE the upgrade handshake completes, +// blocking cross-site WebSocket hijacking (CSWSH) from malicious pages. +const wss = new WebSocketServer({ + server, + path: '/ws', + verifyClient: (info, callback) => { + const origin = info.origin || info.req.headers.origin; + const result = validateWebSocketOrigin(origin, ALLOWED_ORIGINS); + + if (!result.allowed) { + console.warn(`WebSocket origin rejected: ${origin} — ${result.reason}`); + callback(false, 403, 'Forbidden: origin not allowed'); + return; + } + + callback(true); + }, +}); // Initialize broadcast service for task change notifications initBroadcast(wss); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 63025d8f..31a32311 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -405,6 +405,48 @@ export interface AuthenticatedWebSocket extends WebSocket { }; } +// === Origin Validation === + +/** + * Validate the Origin header for WebSocket connections. + * Blocks cross-origin browser attacks (CSWSH) while allowing non-browser clients. + * + * Rules: + * 1. No origin header → ALLOW (non-browser clients: curl, Postman, agents) + * 2. Origin in allowed list → ALLOW + * 3. Development mode + localhost origin → ALLOW + * 4. Otherwise → REJECT + */ +export function validateWebSocketOrigin( + origin: string | undefined, + allowedOrigins: string[], +): { allowed: boolean; reason: string } { + // Non-browser clients don't send Origin — allow them through + if (!origin) { + return { allowed: true, reason: 'No origin header (non-browser client)' }; + } + + // Check against the explicit allowed list + if (allowedOrigins.includes(origin)) { + return { allowed: true, reason: 'Origin in allowed list' }; + } + + // In development, allow any localhost/127.0.0.1 origin + const isDev = process.env.NODE_ENV !== 'production'; + if (isDev) { + try { + const url = new URL(origin); + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + return { allowed: true, reason: 'Localhost origin (dev mode)' }; + } + } catch { + // Invalid URL — fall through to rejection + } + } + + return { allowed: false, reason: `Origin not allowed: ${origin}` }; +} + // === Utility Functions === /** diff --git a/web/src/components/task/AgentPanel.tsx b/web/src/components/task/AgentPanel.tsx index 4ee1af19..f1b9a747 100644 --- a/web/src/components/task/AgentPanel.tsx +++ b/web/src/components/task/AgentPanel.tsx @@ -49,6 +49,7 @@ import { } from 'lucide-react'; import type { Task, AgentType, AttemptStatus } from '@veritas-kanban/shared'; import { cn } from '@/lib/utils'; +import { sanitizeText } from '@/lib/sanitize'; import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary'; interface AgentPanelProps { @@ -265,7 +266,7 @@ export function AgentPanel({ task }: AgentPanelProps) { )} > {output.type === 'stdin' && You: } - {output.content} + {sanitizeText(output.content)} )) )} diff --git a/web/src/components/task/BlockedReasonSection.tsx b/web/src/components/task/BlockedReasonSection.tsx index 91fa647b..dd049367 100644 --- a/web/src/components/task/BlockedReasonSection.tsx +++ b/web/src/components/task/BlockedReasonSection.tsx @@ -9,6 +9,7 @@ import { } from '@/components/ui/select'; import { Ban, MessageSquare, Wrench, Link2, HelpCircle } from 'lucide-react'; import type { Task, BlockedCategory, BlockedReason } from '@veritas-kanban/shared'; +import { sanitizeText } from '@/lib/sanitize'; interface BlockedReasonSectionProps { task: Task; @@ -94,7 +95,7 @@ export function BlockedReasonSection({ task, onUpdate, readOnly = false }: Block {getCategoryInfo(currentCategory)?.label} {currentNote && ( -

{currentNote}

+

{sanitizeText(currentNote)}

)} ) : ( diff --git a/web/src/components/task/CommentsSection.tsx b/web/src/components/task/CommentsSection.tsx index 4cc854cf..54d349a9 100644 --- a/web/src/components/task/CommentsSection.tsx +++ b/web/src/components/task/CommentsSection.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/alert-dialog'; import { useAddComment, useEditComment, useDeleteComment } from '@/hooks/useTasks'; import type { Task, Comment } from '@veritas-kanban/shared'; +import { sanitizeText } from '@/lib/sanitize'; interface CommentsSectionProps { task: Task; @@ -144,7 +145,7 @@ function CommentItem({ comment, taskId }: { comment: Comment; taskId: string }) ) : (

- {comment.text} + {sanitizeText(comment.text)}

)} diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index 3594ac4b..d54d884e 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -18,6 +18,7 @@ import { getSprintLabel } from '@/hooks/useSprints'; import { useFeatureSettings } from '@/hooks/useFeatureSettings'; import { useTaskConfig } from '@/contexts/TaskConfigContext'; import { type TaskCardMetrics, formatCompactDuration } from '@/hooks/useBulkTaskMetrics'; +import { sanitizeText } from '@/lib/sanitize'; const agentNames: Record = { 'claude-code': 'Claude', @@ -167,7 +168,7 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe {!isCompact && task.description && (

- {task.description} + {sanitizeText(task.description)}

)} @@ -223,7 +224,7 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe

{info.label}

{task.blockedReason.note && ( -

{task.blockedReason.note}

+

{sanitizeText(task.blockedReason.note)}

)}
@@ -362,7 +363,7 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe

{task.title}

{task.description && ( -

{task.description}

+

{sanitizeText(task.description)}

)}
diff --git a/web/src/components/task/TimeTrackingSection.tsx b/web/src/components/task/TimeTrackingSection.tsx index c039f3bf..65499246 100644 --- a/web/src/components/task/TimeTrackingSection.tsx +++ b/web/src/components/task/TimeTrackingSection.tsx @@ -31,6 +31,7 @@ import { } from 'lucide-react'; import type { Task, TimeEntry } from '@veritas-kanban/shared'; import { cn } from '@/lib/utils'; +import { sanitizeText } from '@/lib/sanitize'; interface TimeTrackingSectionProps { task: Task; @@ -250,7 +251,7 @@ export function TimeTrackingSection({ task }: TimeTrackingSectionProps) { )}
- {entry.description || formatEntryTime(entry)} + {entry.description ? sanitizeText(entry.description) : formatEntryTime(entry)}
{entry.id !== task.timeTracking?.activeEntryId && ( diff --git a/web/src/components/task/detail/TaskDetailsTab.tsx b/web/src/components/task/detail/TaskDetailsTab.tsx index f257428c..272a2ab5 100644 --- a/web/src/components/task/detail/TaskDetailsTab.tsx +++ b/web/src/components/task/detail/TaskDetailsTab.tsx @@ -22,6 +22,7 @@ import { useDeleteTask } from '@/hooks/useTasks'; import { useFeatureSettings } from '@/hooks/useFeatureSettings'; import { Trash2, Calendar, Clock, RotateCcw } from 'lucide-react'; import type { Task, BlockedReason } from '@veritas-kanban/shared'; +import { sanitizeText } from '@/lib/sanitize'; interface TaskDetailsTabProps { task: Task; @@ -64,7 +65,7 @@ export function TaskDetailsTab({ {readOnly ? (
- {task.description || 'No description'} + {sanitizeText(task.description || '') || 'No description'}
) : (