@goodandready/dsh-kanban 0.2.29 → 0.2.31

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,
@@ -109,7 +111,7 @@ export const name = '@goodandready/dsh-kanban'
109
111
  * Services are injected where needed. Declaring an unavailable service prevents
110
112
  * the plugin from loading entirely.
111
113
  */
112
- export const inject = ['settings', 'webServer', 'agents', 'agentDefaultModel', 'credentials', 'llm', 'tools']
114
+ export const inject = ['settings', 'webServer', 'agents', 'agentDefaultModel', 'credentials', 'llm', 'tools', 'connection']
113
115
 
114
116
  /** Settings namespace string matching client bundle definition. */
115
117
  // Constant string defined in this file; identical across core versions.
@@ -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
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto'
1
2
  // HTTP route handlers for the board.
2
3
  //
3
4
  // The client bundle runs in the browser without direct access to SQLite or host services;
@@ -57,40 +58,140 @@ export function isTrustedHost(host) {
57
58
  return false
58
59
  }
59
60
 
61
+ export function normalizeIp(addr) {
62
+ if (!addr || typeof addr !== 'string') return ''
63
+ return addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
64
+ }
65
+
66
+ export function cookieNameForAuthority(authority) {
67
+ if (!authority || typeof authority !== 'string') return ''
68
+ const hash = crypto.createHash('sha256').update(authority.trim()).digest('base64url')
69
+ return 'dsh-auth-' + hash
70
+ }
71
+
72
+ export function isLoopbackCookieName(name, host = '') {
73
+ if (!name || typeof name !== 'string' || !name.startsWith('dsh-auth-')) return false
74
+ const authorities = [
75
+ host,
76
+ '127.0.0.1:3000',
77
+ '127.0.0.1:3080',
78
+ '127.0.0.1:3082',
79
+ '127.0.0.1',
80
+ 'localhost:3000',
81
+ 'localhost:3080',
82
+ 'localhost:3082',
83
+ 'localhost',
84
+ '[::1]:3000',
85
+ '[::1]',
86
+ ]
87
+ for (const auth of authorities) {
88
+ if (auth && isLoopbackAddress(auth.split(':')[0])) {
89
+ if (cookieNameForAuthority(auth) === name) return true
90
+ }
91
+ }
92
+ return false
93
+ }
94
+
95
+ export function hasClientCredentials(req) {
96
+ if (!req || !req.headers) return false
97
+ if (req.headers.authorization) return true
98
+ if (req.headers['x-dsh-lan-pin']) return true
99
+ const cookie = req.headers.cookie
100
+ if (!cookie || typeof cookie !== 'string') return false
101
+ if (cookie.includes('dsh_token=') || cookie.includes('dsh_lan_pin=')) return true
102
+
103
+ const parts = cookie.split(';')
104
+ for (const part of parts) {
105
+ const trimmed = part.trim()
106
+ const eqIdx = trimmed.indexOf('=')
107
+ if (eqIdx === -1) continue
108
+ const name = trimmed.slice(0, eqIdx).trim()
109
+ if (name.startsWith('dsh-auth-')) {
110
+ if (!isLoopbackCookieName(name, req.headers.host)) {
111
+ return true
112
+ }
113
+ }
114
+ }
115
+ return false
116
+ }
117
+
118
+ export function getClientIp(req) {
119
+ const socketAddress =
120
+ req?.socket?.remoteAddress ||
121
+ req?.connection?.remoteAddress ||
122
+ req?.info?.remoteAddress ||
123
+ ''
124
+ const socketIp = normalizeIp(socketAddress)
125
+ const xff = req?.headers?.['x-forwarded-for']
126
+ if (xff && typeof xff === 'string' && (isLoopbackAddress(socketIp) || !socketIp)) {
127
+ const first = xff.split(',')[0].trim()
128
+ if (first) {
129
+ return normalizeIp(first)
130
+ }
131
+ }
132
+ return socketIp
133
+ }
134
+
60
135
  /**
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.
136
+ * Cross-site request, DNS rebinding, and authentication validation (#261, #369, #384).
137
+ *
138
+ * Requirements:
139
+ * 1. If DSH connection service is available (options.connection), requestRejection(req)
140
+ * is the authoritative gate for browser session authentication and trusted network access.
141
+ * If rejection is returned, the numeric status (e.g. 401 or 403) is returned.
142
+ * 2. Host header is required and must target a trusted host (loopback, LAN, .local).
143
+ * 3. Cross-site requests ('sec-fetch-site: cross-site' or origin mismatch) are rejected with 403.
144
+ * 4. Origin & Referer headers (if present) must match Host or a trusted host.
145
+ * 5. Network boundary validation:
146
+ * - Loopback callers (127.0.0.1, ::1) are allowed.
147
+ * - Non-loopback / LAN callers require valid connection authentication (fail-closed, return 401).
148
+ * - Unknown remote address (socket present but empty remoteAddress) fails closed with 403.
149
+ *
150
+ * @param {import('node:http').IncomingMessage} req
151
+ * @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
152
+ * @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
67
153
  */
68
- export function isTrustedRequest(req) {
69
- if (!req || !req.headers) return false
154
+ export function getRejectionStatus(req, options = {}) {
155
+ const connection = options?.connection
156
+ if (connection && typeof connection.requestRejection === 'function') {
157
+ try {
158
+ const rejection = connection.requestRejection(req)
159
+ if (rejection !== undefined) {
160
+ if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
161
+ return rejection
162
+ }
163
+ return rejection === 401 ? 401 : 403
164
+ }
165
+ } catch {
166
+ // Fail closed if connection service throws
167
+ return 403
168
+ }
169
+ }
170
+
171
+ if (!req || !req.headers) return 403
70
172
 
71
173
  const host = req.headers.host
72
174
  if (!host || !isTrustedHost(host)) {
73
- return false
175
+ return 403
74
176
  }
75
177
 
76
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
77
178
  const site = req.headers['sec-fetch-site']
78
179
 
79
180
  // 1. Explicit cross-site or same-site requests are rejected
80
181
  if (site === 'cross-site' || site === 'same-site') {
81
- return false
182
+ return 403
82
183
  }
83
184
 
84
185
  // 2. Validate origin against trusted hosts and host header
85
186
  const origin = req.headers.origin
86
187
  if (origin !== undefined) {
87
- if (origin === 'null' || !origin) return false
188
+ if (origin === 'null' || !origin) return 403
88
189
  try {
89
190
  const parsed = new URL(origin)
90
- if (!isTrustedHost(parsed.hostname)) return false
91
- if (parsed.host && parsed.host !== host) return false
191
+ if (!isTrustedHost(parsed.hostname)) return 403
192
+ if (parsed.host && parsed.host !== host) return 403
92
193
  } catch {
93
- return false
194
+ return 403
94
195
  }
95
196
  }
96
197
 
@@ -99,36 +200,91 @@ export function isTrustedRequest(req) {
99
200
  if (referer !== undefined && referer) {
100
201
  try {
101
202
  const parsedRef = new URL(referer)
102
- if (!isTrustedHost(parsedRef.hostname)) return false
103
- if (parsedRef.host && parsedRef.host !== host) return false
203
+ if (!isTrustedHost(parsedRef.hostname)) return 403
204
+ if (parsedRef.host && parsedRef.host !== host) return 403
104
205
  } catch {
105
- return false
206
+ return 403
106
207
  }
107
208
  }
108
209
 
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
210
+ // 4. Validate remote address and network boundary
211
+ const hasSocket = !!(req.socket || req.connection)
212
+ const remote = getClientIp(req)
213
+
214
+ if (hasSocket) {
215
+ if (!remote) {
216
+ return 403
217
+ }
218
+ if (!isLoopbackAddress(remote) && !isLanAddress(remote)) {
219
+ return 403
220
+ }
221
+ // Remote / LAN callers without connection authentication are rejected (#384)
222
+ if (!isLoopbackAddress(remote)) {
223
+ if (!connection) {
224
+ return 401
225
+ }
226
+ // If request was forwarded through a reverse proxy (x-forwarded-for),
227
+ // the proxy may have injected loopback credentials. Validate that the client
228
+ // itself supplied authentic credentials (bearer token, PIN, or client session cookie).
229
+ const xff = req.headers['x-forwarded-for']
230
+ if (xff && !hasClientCredentials(req)) {
231
+ return 401
232
+ }
233
+ }
234
+ } else {
235
+ // Synthetic mock request without socket
236
+ const xff = req.headers['x-forwarded-for']
237
+ if (xff) {
238
+ const first = xff.split(',')[0].trim()
239
+ if (first && !isLoopbackAddress(normalizeIp(first))) {
240
+ if (!connection || !hasClientCredentials(req)) {
241
+ return 401
242
+ }
243
+ }
244
+ } else if (!connection) {
245
+ const hostname = String(host).split(':')[0].toLowerCase()
246
+ if (!isLoopbackAddress(hostname) && hostname !== 'localhost') {
247
+ return 401
248
+ }
114
249
  }
115
- return true
116
250
  }
117
251
 
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
252
+ // 5. Browser request with declared site:
253
+ // Must be same-origin or none
254
+ if (site && site !== 'same-origin' && site !== 'none') {
255
+ return 403
122
256
  }
123
257
 
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
- }
258
+ // 6. Request without declared site from non-loopback requires connection
259
+ if (!site && remote && !isLoopbackAddress(remote) && !connection) {
260
+ return 401
130
261
  }
131
262
 
263
+ return undefined
264
+ }
265
+
266
+ export function isTrustedRequest(req, options = {}) {
267
+ return getRejectionStatus(req, options) === undefined
268
+ }
269
+
270
+ export function rejectUntrustedRequest(req, res, options = {}) {
271
+ const status = getRejectionStatus(req, options)
272
+ if (status !== undefined) {
273
+ try {
274
+ res.writeHead(status, {
275
+ 'Content-Type': 'application/json; charset=utf-8',
276
+ 'Cache-Control': 'no-store',
277
+ })
278
+ res.end(
279
+ JSON.stringify({
280
+ error: status === 401 ? 'unauthorized' : 'cross-site',
281
+ })
282
+ )
283
+ } catch {
284
+ /* socket closed */
285
+ }
286
+ return true
287
+ }
132
288
  return false
133
289
  }
134
290
 
package/lib/updater.js CHANGED
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { homedir } from 'node:os'
5
+ import { hasClientCredentials } from './routes.js'
5
6
  import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
7
  import { fileURLToPath } from 'node:url'
7
8
 
@@ -42,10 +43,28 @@ function isPrivateLan(value) {
42
43
  )
43
44
  }
44
45
 
45
- export function isTrustedUpdateRequest(request) {
46
+ export function isTrustedUpdateRequest(request, targetCtx = null) {
46
47
  if (header(request, UPDATE_HEADER) !== '1') return false
47
- const remote = request.socket?.remoteAddress
48
+ const conn = (targetCtx && typeof targetCtx.get === 'function' ? targetCtx.get('connection') : null)
49
+ || targetCtx?.connection
50
+ || request?.connectionService
51
+ if (conn && typeof conn.requestRejection === 'function') {
52
+ let rejection
53
+ try {
54
+ rejection = conn.requestRejection(request)
55
+ } catch {
56
+ return false
57
+ }
58
+ if (rejection !== undefined) return false
59
+ }
60
+ const socketAddress = request.socket?.remoteAddress
61
+ const xff = header(request, 'x-forwarded-for')
62
+ let remote = socketAddress
63
+ if (xff && isLoopback(socketAddress)) {
64
+ remote = xff.split(',')[0].trim()
65
+ }
48
66
  if (!isLoopback(remote) && !isPrivateLan(remote)) return false
67
+ if (!isLoopback(remote) && xff && !hasClientCredentials(request)) return false
49
68
  const site = header(request, 'sec-fetch-site')
50
69
  if (!site || (site !== 'same-origin' && site !== 'same-site' && site !== 'none')) return false
51
70
  const origin = header(request, 'origin')
@@ -300,7 +319,7 @@ export function registerPluginUpdater(ctx, options) {
300
319
  response.end()
301
320
  return
302
321
  }
303
- if (!isTrustedUpdateRequest(request)) {
322
+ if (!isTrustedUpdateRequest(request, ctx)) {
304
323
  json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
305
324
  return
306
325
  }
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.31",
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",