@goodandready/dsh-kanban 0.2.26 → 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 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 true
21
- return addr === '127.0.0.1' ||
22
- addr === '::1' ||
23
- addr === '::ffff:127.0.0.1' ||
24
- addr === 'localhost'
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
- * Mutating endpoints modify local state and reject cross-site requests:
30
- * 1. Safe sec-fetch-site ('same-origin', 'same-site', 'none').
31
- * 2. Origin matches host header (when origin is present).
32
- * 3. Loopback remoteAddress (127.0.0.1, ::1, ::ffff:127.0.0.1).
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. Browser request with declared site:
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
- const origin = req.headers.origin
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
- // 3. Request without declared site:
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 && req.headers.host) {
85
- const hostName = String(req.headers.host).split(':')[0]
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
@@ -232,7 +232,6 @@ async function installExact(target, packageSpec, options) {
232
232
  '--profile',
233
233
  target.profileName,
234
234
  'add',
235
- '--config.minimumReleaseAge=0',
236
235
  packageSpec,
237
236
  `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
238
237
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.26",
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
+ }