fix(security): validate Origin header for WebSocket connections

This commit is contained in:
Brad Groux 2026-01-28 12:11:53 -06:00
parent d77f5dfa17
commit 06df2e2050
17 changed files with 702 additions and 11 deletions

View file

@ -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"}

61
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

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

View file

@ -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);

View file

@ -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 ===
/**

View file

@ -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' && <span className="font-bold">You: </span>}
{output.content}
{sanitizeText(output.content)}
</div>
))
)}

View file

@ -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
<span className="font-medium">{getCategoryInfo(currentCategory)?.label}</span>
</div>
{currentNote && (
<p className="text-sm text-muted-foreground">{currentNote}</p>
<p className="text-sm text-muted-foreground">{sanitizeText(currentNote)}</p>
)}
</>
) : (

View file

@ -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 })
</div>
) : (
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
{comment.text}
{sanitizeText(comment.text)}
</p>
)}
</div>

View file

@ -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<string, string> = {
'claude-code': 'Claude',
@ -167,7 +168,7 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe
</h3>
{!isCompact && task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{task.description}
{sanitizeText(task.description)}
</p>
)}
</div>
@ -223,7 +224,7 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe
<TooltipContent>
<p className="font-medium">{info.label}</p>
{task.blockedReason.note && (
<p className="text-sm text-muted-foreground mt-1">{task.blockedReason.note}</p>
<p className="text-sm text-muted-foreground mt-1">{sanitizeText(task.blockedReason.note)}</p>
)}
</TooltipContent>
</Tooltip>
@ -362,7 +363,7 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe
<TooltipContent side="top" className="max-w-xs">
<p className="font-medium">{task.title}</p>
{task.description && (
<p className="text-muted-foreground text-sm mt-1">{task.description}</p>
<p className="text-muted-foreground text-sm mt-1">{sanitizeText(task.description)}</p>
)}
</TooltipContent>
</Tooltip>

View file

@ -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) {
)}
</div>
<div className="text-xs text-muted-foreground pl-5 truncate">
{entry.description || formatEntryTime(entry)}
{entry.description ? sanitizeText(entry.description) : formatEntryTime(entry)}
</div>
</div>
{entry.id !== task.timeTracking?.activeEntryId && (

View file

@ -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({
<Label className="text-muted-foreground">Description</Label>
{readOnly ? (
<div className="text-sm whitespace-pre-wrap text-foreground/80 bg-muted/30 rounded-md p-3 min-h-[60px]">
{task.description || 'No description'}
{sanitizeText(task.description || '') || 'No description'}
</div>
) : (
<Textarea

View file

@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { X } from 'lucide-react';
import type { ReviewComment } from '@veritas-kanban/shared';
import { sanitizeText } from '@/lib/sanitize';
interface CommentInputProps {
onSubmit: (content: string) => void;
@ -50,7 +51,7 @@ export function CommentDisplay({ comment, onRemove }: CommentDisplayProps) {
return (
<div className="p-2 bg-amber-500/10 border-l-2 border-amber-500 group">
<div className="flex items-start justify-between">
<p className="text-xs whitespace-pre-wrap">{comment.content}</p>
<p className="text-xs whitespace-pre-wrap">{sanitizeText(comment.content)}</p>
<button
onClick={onRemove}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"

View file

@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest';
import { sanitizeHtml, sanitizeText } from '../sanitize';
describe('sanitizeHtml', () => {
it('strips script tags', () => {
expect(sanitizeHtml('<script>alert("xss")</script>')).toBe('');
});
it('strips script tags mixed with content', () => {
const input = 'Hello <script>alert("xss")</script> world';
expect(sanitizeHtml(input)).toBe('Hello world');
});
it('strips event handlers on img tags', () => {
const input = '<img onerror="alert(\'xss\')" src=x>';
const result = sanitizeHtml(input);
expect(result).not.toContain('onerror');
expect(result).not.toContain('alert');
});
it('strips javascript: links', () => {
const input = '<a href="javascript:alert(\'xss\')">click me</a>';
const result = sanitizeHtml(input);
expect(result).not.toContain('javascript:');
expect(result).toContain('click me');
});
it('preserves safe Markdown HTML tags', () => {
const input = '<p>Hello <strong>bold</strong> and <em>italic</em></p>';
expect(sanitizeHtml(input)).toContain('<strong>bold</strong>');
expect(sanitizeHtml(input)).toContain('<em>italic</em>');
});
it('preserves code blocks', () => {
const input = '<pre><code>const x = 1;</code></pre>';
expect(sanitizeHtml(input)).toContain('<code>const x = 1;</code>');
});
it('preserves headings', () => {
const input = '<h1>Title</h1><h2>Subtitle</h2>';
expect(sanitizeHtml(input)).toContain('<h1>Title</h1>');
expect(sanitizeHtml(input)).toContain('<h2>Subtitle</h2>');
});
it('preserves lists', () => {
const input = '<ul><li>Item 1</li><li>Item 2</li></ul>';
expect(sanitizeHtml(input)).toContain('<li>Item 1</li>');
});
it('preserves blockquotes', () => {
const input = '<blockquote>Quote text</blockquote>';
expect(sanitizeHtml(input)).toContain('<blockquote>Quote text</blockquote>');
});
it('preserves safe links', () => {
const input = '<a href="https://example.com">link</a>';
const result = sanitizeHtml(input);
expect(result).toContain('href="https://example.com"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener noreferrer"');
});
it('preserves tables', () => {
const input = '<table><tr><td>Cell</td></tr></table>';
expect(sanitizeHtml(input)).toContain('<td>Cell</td>');
});
it('strips iframe tags', () => {
const input = '<iframe src="https://evil.com"></iframe>';
expect(sanitizeHtml(input)).toBe('');
});
it('strips form elements', () => {
const input = '<form action="https://evil.com"><input type="text"></form>';
expect(sanitizeHtml(input)).toBe('');
});
it('strips style tags', () => {
const input = '<style>body { background: red; }</style>';
expect(sanitizeHtml(input)).toBe('');
});
it('handles empty/falsy input', () => {
expect(sanitizeHtml('')).toBe('');
expect(sanitizeHtml(null as unknown as string)).toBe('');
expect(sanitizeHtml(undefined as unknown as string)).toBe('');
});
it('strips nested script injection', () => {
const input = '<div><scr<script>ipt>alert("xss")</scr</script>ipt></div>';
const result = sanitizeHtml(input);
expect(result).not.toContain('alert');
});
it('strips SVG-based XSS', () => {
const input = '<svg onload="alert(\'xss\')"><circle r="10"/></svg>';
const result = sanitizeHtml(input);
expect(result).not.toContain('onload');
expect(result).not.toContain('alert');
});
it('strips data: URI in links', () => {
const input = '<a href="data:text/html,<script>alert(1)</script>">click</a>';
const result = sanitizeHtml(input);
expect(result).not.toContain('data:');
});
});
describe('sanitizeText', () => {
it('strips ALL HTML tags', () => {
const input = '<p>Hello <strong>world</strong></p>';
expect(sanitizeText(input)).toBe('Hello world');
});
it('strips script tags and their content', () => {
const input = '<script>alert("xss")</script>';
expect(sanitizeText(input)).toBe('');
});
it('strips img tags with event handlers', () => {
const input = '<img onerror="alert(\'xss\')" src=x>';
expect(sanitizeText(input)).toBe('');
});
it('strips javascript: links but keeps text', () => {
const input = '<a href="javascript:alert(\'xss\')">click me</a>';
expect(sanitizeText(input)).toBe('click me');
});
it('preserves plain text content', () => {
const input = 'Just a normal description with no HTML';
expect(sanitizeText(input)).toBe('Just a normal description with no HTML');
});
it('preserves text with special characters', () => {
const input = 'Use array[0] && value > 5';
// DOMPurify may encode special chars, but should preserve meaning
const result = sanitizeText(input);
expect(result).toContain('array[0]');
expect(result).toContain('value');
});
it('handles Markdown-like XSS payloads', () => {
const input = '[link](javascript:alert("xss"))';
const result = sanitizeText(input);
// As plain text, the markdown syntax is kept as literal characters
expect(result).not.toContain('<script>');
expect(result).not.toContain('javascript:');
});
it('handles empty/falsy input', () => {
expect(sanitizeText('')).toBe('');
expect(sanitizeText(null as unknown as string)).toBe('');
expect(sanitizeText(undefined as unknown as string)).toBe('');
});
it('strips complex nested XSS', () => {
const input = '"><img src=x onerror=alert(1)>';
const result = sanitizeText(input);
expect(result).not.toContain('onerror');
expect(result).not.toContain('<img');
expect(result).toBe('">');
});
});

152
web/src/lib/sanitize.ts Normal file
View file

@ -0,0 +1,152 @@
/**
* Content sanitization utilities to prevent stored XSS.
*
* Defense-in-depth: React's JSX already escapes text content rendered via `{}`.
* These utilities add an additional layer of protection:
*
* - `sanitizeHtml()` — Cleans HTML for use with dangerouslySetInnerHTML or
* Markdown-to-HTML renderers. Allows safe Markdown tags, strips scripts,
* event handlers, and javascript: links.
*
* - `sanitizeText()` — Strips ALL HTML for plain-text rendering contexts.
* Use this when content should never contain markup.
*/
import DOMPurify from 'dompurify';
/**
* Allowed HTML tags that are safe for Markdown-rendered content.
* These cover standard Markdown output (headings, lists, emphasis, code, etc.).
*/
const ALLOWED_TAGS = [
// Headings
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Block elements
'p', 'blockquote', 'pre', 'hr', 'br', 'div',
// Lists
'ul', 'ol', 'li',
// Inline formatting
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins', 'mark', 'sub', 'sup',
// Code
'code', 'kbd', 'samp', 'var',
// Links & images
'a', 'img',
// Tables
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
// Definition lists
'dl', 'dt', 'dd',
// Details/summary
'details', 'summary',
// Misc inline
'span', 'abbr', 'cite', 'q', 'small',
];
/**
* Allowed HTML attributes. Kept minimal to reduce attack surface.
*/
const ALLOWED_ATTR = [
// Links
'href', 'target', 'rel',
// Images
'src', 'alt', 'title', 'width', 'height',
// Tables
'colspan', 'rowspan', 'scope',
// General
'class', 'id',
// Accessibility
'aria-label', 'aria-hidden', 'role',
// Code highlighting
'data-language',
];
/**
* URI schemes allowed in href/src attributes.
* Blocks javascript:, data:, vbscript:, etc.
*/
const ALLOWED_URI_REGEXP = /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i;
/**
* Sanitize HTML content for safe rendering.
*
* Use when rendering user-generated HTML (e.g., from a Markdown-to-HTML
* converter) via `dangerouslySetInnerHTML` or similar.
*
* Strips:
* - `<script>` tags and content
* - Event handler attributes (onclick, onerror, onload, etc.)
* - `javascript:` and other dangerous URI schemes
* - `<style>` tags (to prevent CSS-based attacks)
* - `<iframe>`, `<object>`, `<embed>`, `<form>` elements
*
* @example
* ```tsx
* // In a component using a Markdown renderer:
* const html = markdownToHtml(task.description);
* return <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }} />;
* ```
*/
export function sanitizeHtml(dirty: string): string {
if (!dirty) return '';
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOWED_URI_REGEXP,
// Force all links to open in new tab with safe rel
ADD_ATTR: ['target'],
// Strip any tag not in allowlist (don't just escape)
KEEP_CONTENT: true,
// Forbid dangerous tags explicitly (belt + suspenders)
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit', 'onkeydown', 'onkeyup', 'onkeypress'],
});
}
/**
* Strip ALL HTML from a string, returning plain text only.
*
* Use for content that should never contain markup — descriptions rendered
* as plain text, comment bodies, tooltip text, etc.
*
* This is a defense-in-depth measure. React's JSX `{}` interpolation already
* escapes HTML entities, but this ensures no HTML reaches the render layer
* even if the rendering approach changes.
*
* @example
* ```tsx
* <p className="text-sm">{sanitizeText(comment.text)}</p>
* ```
*/
export function sanitizeText(dirty: string): string {
if (!dirty) return '';
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: [], // Strip ALL tags
ALLOWED_ATTR: [], // Strip ALL attributes
KEEP_CONTENT: true, // Keep text content of stripped tags
});
}
/**
* Hook DOMPurify to enforce safe link targets.
* All <a> tags get target="_blank" and rel="noopener noreferrer".
*/
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
// Remove any remaining javascript: URIs that might slip through
if (node.hasAttribute('href')) {
const href = node.getAttribute('href') || '';
if (/^\s*javascript\s*:/i.test(href)) {
node.removeAttribute('href');
}
}
if (node.hasAttribute('src')) {
const src = node.getAttribute('src') || '';
if (/^\s*javascript\s*:/i.test(src)) {
node.removeAttribute('src');
}
}
});