@goodandready/dsh-kanban 0.2.27 → 0.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +16 -2
- package/lib/routes.js +81 -40
- package/lib/updater.js +0 -1
- package/package.json +6 -6
package/lib/index.js
CHANGED
|
@@ -658,6 +658,8 @@ export function apply(ctx, config) {
|
|
|
658
658
|
path: '/dsh-kanban/presets',
|
|
659
659
|
// Both services are optional in minimal environments. Empty lists render gracefully.
|
|
660
660
|
handler: async (req, res) => {
|
|
661
|
+
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
662
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
661
663
|
const presets = ctx.get('agentPresets')
|
|
662
664
|
const permissions = ctx.get('permissionPresets')
|
|
663
665
|
let agentPresets = []
|
|
@@ -694,6 +696,7 @@ export function apply(ctx, config) {
|
|
|
694
696
|
path: '/dsh-kanban/sessions',
|
|
695
697
|
handler: (req, res) => {
|
|
696
698
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
699
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
697
700
|
reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) })
|
|
698
701
|
},
|
|
699
702
|
}), 'dsh-kanban: live sessions')
|
|
@@ -735,6 +738,7 @@ export function apply(ctx, config) {
|
|
|
735
738
|
path: '/dsh-kanban/archive',
|
|
736
739
|
handler: (req, res) => {
|
|
737
740
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
741
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
738
742
|
reply(res, listArchive({ store }))
|
|
739
743
|
},
|
|
740
744
|
}), 'dsh-kanban: archive')
|
|
@@ -840,6 +844,7 @@ export function apply(ctx, config) {
|
|
|
840
844
|
path: '/dsh-kanban/board',
|
|
841
845
|
handler: (req, res) => {
|
|
842
846
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
847
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
843
848
|
const url = urlOf(req)
|
|
844
849
|
reply(res, buildBoard({
|
|
845
850
|
store,
|
|
@@ -874,6 +879,7 @@ export function apply(ctx, config) {
|
|
|
874
879
|
path: '/dsh-kanban/metrics',
|
|
875
880
|
handler: (req, res) => {
|
|
876
881
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
882
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
877
883
|
const url = urlOf(req)
|
|
878
884
|
const days = Number(url.searchParams.get('staleDays'))
|
|
879
885
|
reply(res, boardMetrics({
|
|
@@ -890,6 +896,7 @@ export function apply(ctx, config) {
|
|
|
890
896
|
// Empty login indicates unconfigured or unreachable Gitea; client omits "Mine" filter.
|
|
891
897
|
handler: async (req, res) => {
|
|
892
898
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
899
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
893
900
|
reply(res, { login: await whoAmI() })
|
|
894
901
|
},
|
|
895
902
|
}), 'dsh-kanban: whoami')
|
|
@@ -899,6 +906,7 @@ export function apply(ctx, config) {
|
|
|
899
906
|
path: '/dsh-kanban/export.csv',
|
|
900
907
|
handler: async (req, res) => {
|
|
901
908
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
909
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
902
910
|
const u = urlOf(req)
|
|
903
911
|
const board = u.searchParams.get('board') || 'main'
|
|
904
912
|
const csv = exportTasksCsv({ store, board })
|
|
@@ -931,6 +939,7 @@ export function apply(ctx, config) {
|
|
|
931
939
|
path: '/dsh-kanban/templates',
|
|
932
940
|
handler: (req, res) => {
|
|
933
941
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
942
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
934
943
|
const u = new URL(req.url, 'http://localhost')
|
|
935
944
|
const tid = u.searchParams.get('id')
|
|
936
945
|
if (tid) {
|
|
@@ -947,9 +956,9 @@ export function apply(ctx, config) {
|
|
|
947
956
|
kind: 'exact',
|
|
948
957
|
path: '/dsh-kanban/snapshot',
|
|
949
958
|
handler: async (req, res) => {
|
|
959
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
950
960
|
if (req.method === 'GET') { reply(res, exportBoard({ store })); return }
|
|
951
961
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
952
|
-
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
953
962
|
try {
|
|
954
963
|
const input = await readBody(req)
|
|
955
964
|
const impResult = importBoard({ store, input })
|
|
@@ -990,6 +999,8 @@ export function apply(ctx, config) {
|
|
|
990
999
|
if (parsed === undefined) { writeJson(res, 404, { error: 'not-found' }); return }
|
|
991
1000
|
const { id, action } = parsed
|
|
992
1001
|
|
|
1002
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
1003
|
+
|
|
993
1004
|
if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
|
|
994
1005
|
if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
|
|
995
1006
|
|
|
@@ -1000,7 +1011,6 @@ export function apply(ctx, config) {
|
|
|
1000
1011
|
writeJson(res, 200, { text: buildStartMessage(task, getConfig()) })
|
|
1001
1012
|
return
|
|
1002
1013
|
}
|
|
1003
|
-
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
1004
1014
|
if (req.method === 'DELETE' && action === undefined) {
|
|
1005
1015
|
const resObj = deleteTask({ store, id })
|
|
1006
1016
|
if (resObj?.deleted) {
|
|
@@ -1381,6 +1391,7 @@ export function apply(ctx, config) {
|
|
|
1381
1391
|
path: '/dsh-kanban/gitea/repos',
|
|
1382
1392
|
handler: async (req, res) => {
|
|
1383
1393
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
1394
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
1384
1395
|
const url = urlOf(req)
|
|
1385
1396
|
reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
|
|
1386
1397
|
},
|
|
@@ -1391,6 +1402,7 @@ export function apply(ctx, config) {
|
|
|
1391
1402
|
path: '/dsh-kanban/gitea/issues',
|
|
1392
1403
|
handler: async (req, res) => {
|
|
1393
1404
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
1405
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
1394
1406
|
const url = urlOf(req)
|
|
1395
1407
|
reply(res, await listImportable({
|
|
1396
1408
|
gitea: git,
|
|
@@ -1428,6 +1440,7 @@ export function apply(ctx, config) {
|
|
|
1428
1440
|
path: '/dsh-kanban/models',
|
|
1429
1441
|
handler: async (req, res) => {
|
|
1430
1442
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
1443
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
1431
1444
|
const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
|
|
1432
1445
|
const llmService = ctx.get('llm')
|
|
1433
1446
|
let providers = []
|
|
@@ -1456,6 +1469,7 @@ export function apply(ctx, config) {
|
|
|
1456
1469
|
path: '/dsh-kanban/session',
|
|
1457
1470
|
handler: (req, res) => {
|
|
1458
1471
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
1472
|
+
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
1459
1473
|
const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
|
|
1460
1474
|
if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
|
|
1461
1475
|
reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]), agents: ctx.agents }))
|
package/lib/routes.js
CHANGED
|
@@ -16,24 +16,63 @@ import { computeNextRun } from './cron.js'
|
|
|
16
16
|
/** Maximum request payload size to prevent denial of service. */
|
|
17
17
|
export const MAX_BODY_BYTES = 256 * 1024
|
|
18
18
|
|
|
19
|
-
function isLoopbackAddress(addr) {
|
|
20
|
-
if (!addr) return
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
export function isLoopbackAddress(addr) {
|
|
20
|
+
if (!addr || typeof addr !== 'string') return false
|
|
21
|
+
const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
|
|
22
|
+
return (
|
|
23
|
+
clean === '127.0.0.1' ||
|
|
24
|
+
clean === '::1' ||
|
|
25
|
+
clean === 'localhost' ||
|
|
26
|
+
clean === 'localhost.' ||
|
|
27
|
+
clean.startsWith('127.')
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isLanAddress(addr) {
|
|
32
|
+
if (!addr || typeof addr !== 'string') return false
|
|
33
|
+
const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
|
|
34
|
+
if (isLoopbackAddress(clean)) return true
|
|
35
|
+
if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
|
|
36
|
+
const m = clean.match(/^172\.(\d+)\./)
|
|
37
|
+
if (m) {
|
|
38
|
+
const octet = parseInt(m[1], 10)
|
|
39
|
+
if (octet >= 16 && octet <= 31) return true
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isTrustedHost(host) {
|
|
45
|
+
if (!host || typeof host !== 'string') return false
|
|
46
|
+
let hostname = host.trim()
|
|
47
|
+
if (hostname.startsWith('[')) {
|
|
48
|
+
const end = hostname.indexOf(']')
|
|
49
|
+
if (end > 0) hostname = hostname.slice(1, end)
|
|
50
|
+
} else {
|
|
51
|
+
hostname = hostname.split(':')[0]
|
|
52
|
+
}
|
|
53
|
+
hostname = hostname.toLowerCase()
|
|
54
|
+
if (!hostname) return false
|
|
55
|
+
if (hostname === 'localhost' || hostname === 'localhost.' || hostname.endsWith('.local')) return true
|
|
56
|
+
if (isLoopbackAddress(hostname) || isLanAddress(hostname)) return true
|
|
57
|
+
return false
|
|
25
58
|
}
|
|
26
59
|
|
|
27
60
|
/**
|
|
28
|
-
* Cross-site request validation (#261).
|
|
29
|
-
*
|
|
30
|
-
* 1.
|
|
31
|
-
* 2. Origin
|
|
32
|
-
* 3.
|
|
61
|
+
* Cross-site request and DNS rebinding validation (#261, #369).
|
|
62
|
+
* Endpoints reject cross-site and DNS-rebinding requests:
|
|
63
|
+
* 1. Host header is required and must target a trusted host (loopback, LAN, .local).
|
|
64
|
+
* 2. Origin/Referer (if present) must target a trusted host and match Host header.
|
|
65
|
+
* 3. Safe sec-fetch-site ('same-origin', 'same-site', 'none').
|
|
66
|
+
* 4. Loopback or private LAN remoteAddress.
|
|
33
67
|
*/
|
|
34
68
|
export function isTrustedRequest(req) {
|
|
35
69
|
if (!req || !req.headers) return false
|
|
36
70
|
|
|
71
|
+
const host = req.headers.host
|
|
72
|
+
if (!host || !isTrustedHost(host)) {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
|
|
37
76
|
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
|
|
38
77
|
const site = req.headers['sec-fetch-site']
|
|
39
78
|
|
|
@@ -42,47 +81,49 @@ export function isTrustedRequest(req) {
|
|
|
42
81
|
return false
|
|
43
82
|
}
|
|
44
83
|
|
|
45
|
-
// 2.
|
|
84
|
+
// 2. Validate origin against trusted hosts and host header
|
|
85
|
+
const origin = req.headers.origin
|
|
86
|
+
if (origin !== undefined) {
|
|
87
|
+
if (origin === 'null' || !origin) return false
|
|
88
|
+
try {
|
|
89
|
+
const parsed = new URL(origin)
|
|
90
|
+
if (!isTrustedHost(parsed.hostname)) return false
|
|
91
|
+
if (parsed.host && parsed.host !== host) return false
|
|
92
|
+
} catch {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. Validate referer against trusted hosts and host header if present
|
|
98
|
+
const referer = req.headers.referer
|
|
99
|
+
if (referer !== undefined && referer) {
|
|
100
|
+
try {
|
|
101
|
+
const parsedRef = new URL(referer)
|
|
102
|
+
if (!isTrustedHost(parsedRef.hostname)) return false
|
|
103
|
+
if (parsedRef.host && parsedRef.host !== host) return false
|
|
104
|
+
} catch {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 4. Browser request with declared site:
|
|
46
110
|
// Must be same-origin or none (e.g. top-level navigation / bookmark / isolated subresource)
|
|
47
111
|
if (site === 'same-origin' || site === 'none') {
|
|
48
|
-
|
|
49
|
-
if (origin !== undefined) {
|
|
50
|
-
if (origin === 'null') return false
|
|
51
|
-
try {
|
|
52
|
-
const parsed = new URL(origin)
|
|
53
|
-
const host = req.headers.host || ''
|
|
54
|
-
if (parsed.host && parsed.host !== host) return false
|
|
55
|
-
} catch {
|
|
56
|
-
return false
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
// Remote client must be loopback if present
|
|
60
|
-
if (remote && !isLoopbackAddress(remote)) {
|
|
112
|
+
if (remote && !isLoopbackAddress(remote) && !isLanAddress(remote)) {
|
|
61
113
|
return false
|
|
62
114
|
}
|
|
63
115
|
return true
|
|
64
116
|
}
|
|
65
117
|
|
|
66
|
-
//
|
|
118
|
+
// 5. Request without declared site:
|
|
67
119
|
// Allowed strictly for loopback remoteAddress (DSH Desktop Electron forwardWebRequest, local CLI, curl)
|
|
68
120
|
if (remote && isLoopbackAddress(remote)) {
|
|
69
|
-
const origin = req.headers.origin
|
|
70
|
-
if (origin !== undefined) {
|
|
71
|
-
if (origin === 'null') return false
|
|
72
|
-
try {
|
|
73
|
-
const parsed = new URL(origin)
|
|
74
|
-
const host = req.headers.host || ''
|
|
75
|
-
if (parsed.host && parsed.host !== host) return false
|
|
76
|
-
} catch {
|
|
77
|
-
return false
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
121
|
return true
|
|
81
122
|
}
|
|
82
123
|
|
|
83
|
-
// Fallback for test harnesses / mocks without socket where host header targets loopback
|
|
84
|
-
if (!remote &&
|
|
85
|
-
const hostName = String(
|
|
124
|
+
// 6. Fallback for test harnesses / mocks without socket where host header targets loopback
|
|
125
|
+
if (!remote && host) {
|
|
126
|
+
const hostName = String(host).split(':')[0]
|
|
86
127
|
if (isLoopbackAddress(hostName)) {
|
|
87
128
|
return true
|
|
88
129
|
}
|
package/lib/updater.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.28",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
"README.ru.md",
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.mjs",
|
|
23
|
+
"compat": "node tools/compat.mjs"
|
|
24
|
+
},
|
|
21
25
|
"keywords": [
|
|
22
26
|
"dsh",
|
|
23
27
|
"dsh-plugin",
|
|
@@ -89,9 +93,5 @@
|
|
|
89
93
|
"@deepseek-ai/dsh-tools": {
|
|
90
94
|
"optional": true
|
|
91
95
|
}
|
|
92
|
-
},
|
|
93
|
-
"scripts": {
|
|
94
|
-
"test": "node --test test/*.test.mjs",
|
|
95
|
-
"compat": "node tools/compat.mjs"
|
|
96
96
|
}
|
|
97
|
-
}
|
|
97
|
+
}
|