mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 04:07:50 +00:00
fix(security): validate Origin header for WebSocket connections
This commit is contained in:
parent
d77f5dfa17
commit
06df2e2050
17 changed files with 702 additions and 11 deletions
|
|
@ -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
61
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
117
server/src/__tests__/ws-origin-validation.test.ts
Normal file
117
server/src/__tests__/ws-origin-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ===
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
164
web/src/lib/__tests__/sanitize.test.ts
Normal file
164
web/src/lib/__tests__/sanitize.test.ts
Normal 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
152
web/src/lib/sanitize.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue