@goodandready/dsh-kanban 0.2.29 → 0.2.30

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 CHANGED
@@ -12,6 +12,8 @@ import { registerPluginUpdater } from './updater.js'
12
12
  import {
13
13
  MAX_BODY_BYTES,
14
14
  isTrustedRequest,
15
+ rejectUntrustedRequest,
16
+ getRejectionStatus,
15
17
  parseTaskPath,
16
18
  buildBoard,
17
19
  applyMove,
@@ -634,12 +636,36 @@ export function apply(ctx, config) {
634
636
  return () => { rearmers.delete(arm); clearInterval(timer) }
635
637
  }, 'dsh-kanban: sync timer')
636
638
 
639
+ const resolveConnection = () => {
640
+ try {
641
+ if (ctx && typeof ctx.get === 'function') {
642
+ const c = ctx.get('connection')
643
+ if (c) return c
644
+ }
645
+ } catch { /* ignore */ }
646
+ try {
647
+ if (ctx && ctx.connection) {
648
+ return ctx.connection
649
+ }
650
+ } catch { /* ignore */ }
651
+ return undefined
652
+ }
653
+
654
+ const routeOptions = () => ({
655
+ get connection() {
656
+ return resolveConnection()
657
+ },
658
+ })
659
+
660
+ const isTrusted = (req) => isTrustedRequest(req, routeOptions())
661
+ const rejectUntrusted = (req, res) => rejectUntrustedRequest(req, res, routeOptions())
662
+
637
663
  ctx.effect(() => ctx.webServer.register({
638
664
  kind: 'exact',
639
665
  path: '/dsh-kanban/project-task',
640
666
  handler: async (req, res) => {
641
667
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
642
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
668
+ if (rejectUntrusted(req, res)) return
643
669
  try {
644
670
  const input = await readBody(req)
645
671
  reply(res, await createProjectTask({
@@ -661,7 +687,7 @@ export function apply(ctx, config) {
661
687
  // Both services are optional in minimal environments. Empty lists render gracefully.
662
688
  handler: async (req, res) => {
663
689
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
664
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
690
+ if (rejectUntrusted(req, res)) return
665
691
  const presets = ctx.get('agentPresets')
666
692
  const permissions = ctx.get('permissionPresets')
667
693
  let agentPresets = []
@@ -698,7 +724,7 @@ export function apply(ctx, config) {
698
724
  path: '/dsh-kanban/sessions',
699
725
  handler: (req, res) => {
700
726
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
701
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
727
+ if (rejectUntrusted(req, res)) return
702
728
  reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) })
703
729
  },
704
730
  }), 'dsh-kanban: live sessions')
@@ -708,7 +734,7 @@ export function apply(ctx, config) {
708
734
  path: '/dsh-kanban/batch',
709
735
  handler: async (req, res) => {
710
736
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
711
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
737
+ if (rejectUntrusted(req, res)) return
712
738
  try {
713
739
  const input = await readBody(req)
714
740
  const ids = Array.isArray(input.ids) ? input.ids : []
@@ -740,7 +766,7 @@ export function apply(ctx, config) {
740
766
  path: '/dsh-kanban/archive',
741
767
  handler: (req, res) => {
742
768
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
743
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
769
+ if (rejectUntrusted(req, res)) return
744
770
  reply(res, listArchive({ store }))
745
771
  },
746
772
  }), 'dsh-kanban: archive')
@@ -750,7 +776,7 @@ export function apply(ctx, config) {
750
776
  path: '/dsh-kanban/sync',
751
777
  handler: async (req, res) => {
752
778
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
753
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
779
+ if (rejectUntrusted(req, res)) return
754
780
  try {
755
781
  writeJson(res, 200, await runSync())
756
782
  } catch (error) {
@@ -846,7 +872,7 @@ export function apply(ctx, config) {
846
872
  path: '/dsh-kanban/board',
847
873
  handler: (req, res) => {
848
874
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
849
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
875
+ if (rejectUntrusted(req, res)) return
850
876
  const url = urlOf(req)
851
877
  reply(res, buildBoard({
852
878
  store,
@@ -881,7 +907,7 @@ export function apply(ctx, config) {
881
907
  path: '/dsh-kanban/metrics',
882
908
  handler: (req, res) => {
883
909
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
884
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
910
+ if (rejectUntrusted(req, res)) return
885
911
  const url = urlOf(req)
886
912
  const days = Number(url.searchParams.get('staleDays'))
887
913
  reply(res, boardMetrics({
@@ -898,7 +924,7 @@ export function apply(ctx, config) {
898
924
  // Empty login indicates unconfigured or unreachable Gitea; client omits "Mine" filter.
899
925
  handler: async (req, res) => {
900
926
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
901
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
927
+ if (rejectUntrusted(req, res)) return
902
928
  reply(res, { login: await whoAmI() })
903
929
  },
904
930
  }), 'dsh-kanban: whoami')
@@ -908,7 +934,7 @@ export function apply(ctx, config) {
908
934
  path: '/dsh-kanban/export.csv',
909
935
  handler: async (req, res) => {
910
936
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
911
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
937
+ if (rejectUntrusted(req, res)) return
912
938
  const u = urlOf(req)
913
939
  const board = u.searchParams.get('board') || 'main'
914
940
  const csv = exportTasksCsv({ store, board })
@@ -925,7 +951,7 @@ export function apply(ctx, config) {
925
951
  kind: 'exact',
926
952
  path: '/dsh-kanban/events',
927
953
  handler: (req, res) => {
928
- eventHub.handleSseRequest(req, res, { isTrustedRequest })
954
+ eventHub.handleSseRequest(req, res, { isTrustedRequest: isTrusted })
929
955
  },
930
956
  }), 'dsh-kanban: sse events stream')
931
957
 
@@ -941,7 +967,7 @@ export function apply(ctx, config) {
941
967
  path: '/dsh-kanban/templates',
942
968
  handler: (req, res) => {
943
969
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
944
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
970
+ if (rejectUntrusted(req, res)) return
945
971
  const u = new URL(req.url, 'http://localhost')
946
972
  const tid = u.searchParams.get('id')
947
973
  if (tid) {
@@ -958,7 +984,7 @@ export function apply(ctx, config) {
958
984
  kind: 'exact',
959
985
  path: '/dsh-kanban/snapshot',
960
986
  handler: async (req, res) => {
961
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
987
+ if (rejectUntrusted(req, res)) return
962
988
  if (req.method === 'GET') { reply(res, exportBoard({ store })); return }
963
989
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
964
990
  try {
@@ -984,7 +1010,7 @@ export function apply(ctx, config) {
984
1010
  // Path without ID handles local task creation.
985
1011
  if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
986
1012
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
987
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1013
+ if (rejectUntrusted(req, res)) return
988
1014
  try {
989
1015
  const resObj = createTask({ store, input: await readBody(req) })
990
1016
  if (resObj?.task) {
@@ -1001,7 +1027,7 @@ export function apply(ctx, config) {
1001
1027
  if (parsed === undefined) { writeJson(res, 404, { error: 'not-found' }); return }
1002
1028
  const { id, action } = parsed
1003
1029
 
1004
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1030
+ if (rejectUntrusted(req, res)) return
1005
1031
 
1006
1032
  if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
1007
1033
  if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
@@ -1393,7 +1419,7 @@ export function apply(ctx, config) {
1393
1419
  path: '/dsh-kanban/gitea/repos',
1394
1420
  handler: async (req, res) => {
1395
1421
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1396
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1422
+ if (rejectUntrusted(req, res)) return
1397
1423
  const url = urlOf(req)
1398
1424
  reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
1399
1425
  },
@@ -1404,7 +1430,7 @@ export function apply(ctx, config) {
1404
1430
  path: '/dsh-kanban/gitea/issues',
1405
1431
  handler: async (req, res) => {
1406
1432
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1407
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1433
+ if (rejectUntrusted(req, res)) return
1408
1434
  const url = urlOf(req)
1409
1435
  reply(res, await listImportable({
1410
1436
  gitea: git,
@@ -1421,7 +1447,7 @@ export function apply(ctx, config) {
1421
1447
  path: '/dsh-kanban/import',
1422
1448
  handler: async (req, res) => {
1423
1449
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1424
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1450
+ if (rejectUntrusted(req, res)) return
1425
1451
  try {
1426
1452
  const input = await readBody(req)
1427
1453
  reply(res, await importIssue({
@@ -1442,7 +1468,7 @@ export function apply(ctx, config) {
1442
1468
  path: '/dsh-kanban/models',
1443
1469
  handler: async (req, res) => {
1444
1470
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1445
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1471
+ if (rejectUntrusted(req, res)) return
1446
1472
  const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
1447
1473
  const llmService = ctx.get('llm')
1448
1474
  let providers = []
@@ -1471,7 +1497,7 @@ export function apply(ctx, config) {
1471
1497
  path: '/dsh-kanban/session',
1472
1498
  handler: (req, res) => {
1473
1499
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1474
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1500
+ if (rejectUntrusted(req, res)) return
1475
1501
  const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
1476
1502
  if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
1477
1503
  reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]), agents: ctx.agents }))
package/lib/routes.js CHANGED
@@ -57,40 +57,75 @@ export function isTrustedHost(host) {
57
57
  return false
58
58
  }
59
59
 
60
+ export function getClientIp(req) {
61
+ return (
62
+ req?.socket?.remoteAddress ||
63
+ req?.connection?.remoteAddress ||
64
+ req?.info?.remoteAddress ||
65
+ ''
66
+ )
67
+ }
68
+
60
69
  /**
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.
70
+ * Cross-site request, DNS rebinding, and authentication validation (#261, #369, #384).
71
+ *
72
+ * Requirements:
73
+ * 1. If DSH connection service is available (options.connection), requestRejection(req)
74
+ * is the authoritative gate for browser session authentication and trusted network access.
75
+ * If rejection is returned, the numeric status (e.g. 401 or 403) is returned.
76
+ * 2. Host header is required and must target a trusted host (loopback, LAN, .local).
77
+ * 3. Cross-site requests ('sec-fetch-site: cross-site' or origin mismatch) are rejected with 403.
78
+ * 4. Origin & Referer headers (if present) must match Host or a trusted host.
79
+ * 5. Network boundary validation:
80
+ * - Loopback callers (127.0.0.1, ::1) are allowed.
81
+ * - Non-loopback / LAN callers require valid connection authentication (fail-closed, return 401).
82
+ * - Unknown remote address (socket present but empty remoteAddress) fails closed with 403.
83
+ *
84
+ * @param {import('node:http').IncomingMessage} req
85
+ * @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
86
+ * @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
67
87
  */
68
- export function isTrustedRequest(req) {
69
- if (!req || !req.headers) return false
88
+ export function getRejectionStatus(req, options = {}) {
89
+ const connection = options?.connection
90
+ if (connection && typeof connection.requestRejection === 'function') {
91
+ try {
92
+ const rejection = connection.requestRejection(req)
93
+ if (rejection !== undefined) {
94
+ if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
95
+ return rejection
96
+ }
97
+ return rejection === 401 ? 401 : 403
98
+ }
99
+ } catch {
100
+ // Fail closed if connection service throws
101
+ return 403
102
+ }
103
+ }
104
+
105
+ if (!req || !req.headers) return 403
70
106
 
71
107
  const host = req.headers.host
72
108
  if (!host || !isTrustedHost(host)) {
73
- return false
109
+ return 403
74
110
  }
75
111
 
76
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
77
112
  const site = req.headers['sec-fetch-site']
78
113
 
79
114
  // 1. Explicit cross-site or same-site requests are rejected
80
115
  if (site === 'cross-site' || site === 'same-site') {
81
- return false
116
+ return 403
82
117
  }
83
118
 
84
119
  // 2. Validate origin against trusted hosts and host header
85
120
  const origin = req.headers.origin
86
121
  if (origin !== undefined) {
87
- if (origin === 'null' || !origin) return false
122
+ if (origin === 'null' || !origin) return 403
88
123
  try {
89
124
  const parsed = new URL(origin)
90
- if (!isTrustedHost(parsed.hostname)) return false
91
- if (parsed.host && parsed.host !== host) return false
125
+ if (!isTrustedHost(parsed.hostname)) return 403
126
+ if (parsed.host && parsed.host !== host) return 403
92
127
  } catch {
93
- return false
128
+ return 403
94
129
  }
95
130
  }
96
131
 
@@ -99,36 +134,74 @@ export function isTrustedRequest(req) {
99
134
  if (referer !== undefined && referer) {
100
135
  try {
101
136
  const parsedRef = new URL(referer)
102
- if (!isTrustedHost(parsedRef.hostname)) return false
103
- if (parsedRef.host && parsedRef.host !== host) return false
137
+ if (!isTrustedHost(parsedRef.hostname)) return 403
138
+ if (parsedRef.host && parsedRef.host !== host) return 403
104
139
  } catch {
105
- return false
140
+ return 403
106
141
  }
107
142
  }
108
143
 
109
- // 4. Browser request with declared site:
110
- // Must be same-origin or none (e.g. top-level navigation / bookmark / isolated subresource)
111
- if (site === 'same-origin' || site === 'none') {
112
- if (remote && !isLoopbackAddress(remote) && !isLanAddress(remote)) {
113
- return false
144
+ // 4. Validate remote address and network boundary
145
+ const hasSocket = !!(req.socket || req.connection)
146
+ const remote = getClientIp(req)
147
+
148
+ if (hasSocket) {
149
+ if (!remote) {
150
+ return 403
151
+ }
152
+ if (!isLoopbackAddress(remote) && !isLanAddress(remote)) {
153
+ return 403
154
+ }
155
+ // Remote / LAN callers without connection authentication are rejected (#384)
156
+ if (!isLoopbackAddress(remote)) {
157
+ if (!connection) {
158
+ return 401
159
+ }
160
+ }
161
+ } else if (!connection) {
162
+ // Synthetic mock request without socket: allow loopback host for unit tests
163
+ const hostname = String(host).split(':')[0].toLowerCase()
164
+ if (!isLoopbackAddress(hostname) && hostname !== 'localhost') {
165
+ return 401
114
166
  }
115
- return true
116
167
  }
117
168
 
118
- // 5. Request without declared site:
119
- // Allowed strictly for loopback remoteAddress (DSH Desktop Electron forwardWebRequest, local CLI, curl)
120
- if (remote && isLoopbackAddress(remote)) {
121
- return true
169
+ // 5. Browser request with declared site:
170
+ // Must be same-origin or none
171
+ if (site && site !== 'same-origin' && site !== 'none') {
172
+ return 403
122
173
  }
123
174
 
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]
127
- if (isLoopbackAddress(hostName)) {
128
- return true
129
- }
175
+ // 6. Request without declared site from non-loopback requires connection
176
+ if (!site && remote && !isLoopbackAddress(remote) && !connection) {
177
+ return 401
130
178
  }
131
179
 
180
+ return undefined
181
+ }
182
+
183
+ export function isTrustedRequest(req, options = {}) {
184
+ return getRejectionStatus(req, options) === undefined
185
+ }
186
+
187
+ export function rejectUntrustedRequest(req, res, options = {}) {
188
+ const status = getRejectionStatus(req, options)
189
+ if (status !== undefined) {
190
+ try {
191
+ res.writeHead(status, {
192
+ 'Content-Type': 'application/json; charset=utf-8',
193
+ 'Cache-Control': 'no-store',
194
+ })
195
+ res.end(
196
+ JSON.stringify({
197
+ error: status === 401 ? 'unauthorized' : 'cross-site',
198
+ })
199
+ )
200
+ } catch {
201
+ /* socket closed */
202
+ }
203
+ return true
204
+ }
132
205
  return false
133
206
  }
134
207
 
package/lib/updater.js CHANGED
@@ -42,8 +42,20 @@ function isPrivateLan(value) {
42
42
  )
43
43
  }
44
44
 
45
- export function isTrustedUpdateRequest(request) {
45
+ export function isTrustedUpdateRequest(request, targetCtx = null) {
46
46
  if (header(request, UPDATE_HEADER) !== '1') return false
47
+ const conn = (targetCtx && typeof targetCtx.get === 'function' ? targetCtx.get('connection') : null)
48
+ || targetCtx?.connection
49
+ || request?.connectionService
50
+ if (conn && typeof conn.requestRejection === 'function') {
51
+ let rejection
52
+ try {
53
+ rejection = conn.requestRejection(request)
54
+ } catch {
55
+ return false
56
+ }
57
+ if (rejection !== undefined) return false
58
+ }
47
59
  const remote = request.socket?.remoteAddress
48
60
  if (!isLoopback(remote) && !isPrivateLan(remote)) return false
49
61
  const site = header(request, 'sec-fetch-site')
@@ -300,7 +312,7 @@ export function registerPluginUpdater(ctx, options) {
300
312
  response.end()
301
313
  return
302
314
  }
303
- if (!isTrustedUpdateRequest(request)) {
315
+ if (!isTrustedUpdateRequest(request, ctx)) {
304
316
  json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
305
317
  return
306
318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.29",
3
+ "version": "0.2.30",
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",