@goodandready/dsh-kanban 0.2.28 → 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/config.js +24 -1
- package/lib/index.js +51 -23
- package/lib/routes.js +107 -34
- package/lib/updater.js +14 -2
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -107,7 +107,7 @@ export const CONFIG_HINTS = {
|
|
|
107
107
|
progressDumpEnabled: 'Enable PROGRESSDUMP state checkpoints between agent sessions.',
|
|
108
108
|
permissionGateEnabled: 'Require human confirmation before launching elevated permission tasks.',
|
|
109
109
|
sessionDefaultPermission: 'Default permission tier for task sessions (read-only, workspace-write, danger-full-access).',
|
|
110
|
-
announceToAgent: '
|
|
110
|
+
announceToAgent: 'Inject board instructions and handover guidance into the agent system prompt.',
|
|
111
111
|
maxConcurrentSessions: 'Maximum number of simultaneous active agent sessions on the board.',
|
|
112
112
|
workspaceClaimBoundaries: 'Enforce workspace ownership boundaries to prevent cross-workspace task execution.',
|
|
113
113
|
autoSyncSessions: 'Automatically sync and attach live background agent sessions to cards.',
|
|
@@ -178,3 +178,26 @@ export function columnNamesOf(config) {
|
|
|
178
178
|
}
|
|
179
179
|
return out
|
|
180
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build prompt section text based on enabled board features.
|
|
184
|
+
* Returns null if no features requiring agent guidance are active.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} cfg configuration
|
|
187
|
+
* @returns {string|null}
|
|
188
|
+
*/
|
|
189
|
+
export function buildPromptSectionText(cfg) {
|
|
190
|
+
if (cfg?.announceToAgent === false) return null
|
|
191
|
+
const parts = []
|
|
192
|
+
if (cfg?.boardToolEnabled === true) {
|
|
193
|
+
parts.push(
|
|
194
|
+
'The dsh-kanban task board is available in the system. When working on tasks, you can move cards using the board_move tool and manage the plan checklist via board_plan.'
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
if (cfg?.progressDumpEnabled === true) {
|
|
198
|
+
parts.push(
|
|
199
|
+
'When context is running low or a milestone is reached, output a <<<PROGRESSDUMP\nGoal: ...\nProgress: ...\nNext steps: ...\n>>>PROGRESSDUMP block to hand over to the next session.'
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
return parts.length > 0 ? parts.join(' ') : null
|
|
203
|
+
}
|
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,
|
|
@@ -94,12 +96,13 @@ import {
|
|
|
94
96
|
withDefaults,
|
|
95
97
|
CONFIG_DEFAULTS,
|
|
96
98
|
CONFIG_HINTS,
|
|
99
|
+
buildPromptSectionText,
|
|
97
100
|
rootOf,
|
|
98
101
|
} from './config.js'
|
|
99
102
|
import { eventHub } from './events.js'
|
|
100
103
|
import { TASK_TEMPLATES, getTemplateById } from './templates.js'
|
|
101
104
|
|
|
102
|
-
export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
|
|
105
|
+
export { COLUMN_ORDER, buildPromptSectionText, wipLimitField, withDefaults } from './config.js'
|
|
103
106
|
|
|
104
107
|
/** Stable cordis plugin name (patch identifier). */
|
|
105
108
|
export const name = '@goodandready/dsh-kanban'
|
|
@@ -370,12 +373,13 @@ export function apply(ctx, config) {
|
|
|
370
373
|
}
|
|
371
374
|
const cfg = getConfig()
|
|
372
375
|
const sp = ctx.get('systemPrompt')
|
|
373
|
-
|
|
376
|
+
const promptText = buildPromptSectionText(cfg)
|
|
377
|
+
if (promptText && sp && typeof sp.section === 'function') {
|
|
374
378
|
try {
|
|
375
379
|
disposePromptSection = sp.section({
|
|
376
380
|
name: 'plugin:dsh-kanban',
|
|
377
381
|
order: 200,
|
|
378
|
-
text:
|
|
382
|
+
text: promptText,
|
|
379
383
|
})
|
|
380
384
|
} catch (err) {
|
|
381
385
|
ctx.logger?.warn?.(`dsh-kanban: failed to register system prompt section: ${err?.message}`)
|
|
@@ -632,12 +636,36 @@ export function apply(ctx, config) {
|
|
|
632
636
|
return () => { rearmers.delete(arm); clearInterval(timer) }
|
|
633
637
|
}, 'dsh-kanban: sync timer')
|
|
634
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
|
+
|
|
635
663
|
ctx.effect(() => ctx.webServer.register({
|
|
636
664
|
kind: 'exact',
|
|
637
665
|
path: '/dsh-kanban/project-task',
|
|
638
666
|
handler: async (req, res) => {
|
|
639
667
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
640
|
-
if (
|
|
668
|
+
if (rejectUntrusted(req, res)) return
|
|
641
669
|
try {
|
|
642
670
|
const input = await readBody(req)
|
|
643
671
|
reply(res, await createProjectTask({
|
|
@@ -659,7 +687,7 @@ export function apply(ctx, config) {
|
|
|
659
687
|
// Both services are optional in minimal environments. Empty lists render gracefully.
|
|
660
688
|
handler: async (req, res) => {
|
|
661
689
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
662
|
-
if (
|
|
690
|
+
if (rejectUntrusted(req, res)) return
|
|
663
691
|
const presets = ctx.get('agentPresets')
|
|
664
692
|
const permissions = ctx.get('permissionPresets')
|
|
665
693
|
let agentPresets = []
|
|
@@ -696,7 +724,7 @@ export function apply(ctx, config) {
|
|
|
696
724
|
path: '/dsh-kanban/sessions',
|
|
697
725
|
handler: (req, res) => {
|
|
698
726
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
699
|
-
if (
|
|
727
|
+
if (rejectUntrusted(req, res)) return
|
|
700
728
|
reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) })
|
|
701
729
|
},
|
|
702
730
|
}), 'dsh-kanban: live sessions')
|
|
@@ -706,7 +734,7 @@ export function apply(ctx, config) {
|
|
|
706
734
|
path: '/dsh-kanban/batch',
|
|
707
735
|
handler: async (req, res) => {
|
|
708
736
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
709
|
-
if (
|
|
737
|
+
if (rejectUntrusted(req, res)) return
|
|
710
738
|
try {
|
|
711
739
|
const input = await readBody(req)
|
|
712
740
|
const ids = Array.isArray(input.ids) ? input.ids : []
|
|
@@ -738,7 +766,7 @@ export function apply(ctx, config) {
|
|
|
738
766
|
path: '/dsh-kanban/archive',
|
|
739
767
|
handler: (req, res) => {
|
|
740
768
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
741
|
-
if (
|
|
769
|
+
if (rejectUntrusted(req, res)) return
|
|
742
770
|
reply(res, listArchive({ store }))
|
|
743
771
|
},
|
|
744
772
|
}), 'dsh-kanban: archive')
|
|
@@ -748,7 +776,7 @@ export function apply(ctx, config) {
|
|
|
748
776
|
path: '/dsh-kanban/sync',
|
|
749
777
|
handler: async (req, res) => {
|
|
750
778
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
751
|
-
if (
|
|
779
|
+
if (rejectUntrusted(req, res)) return
|
|
752
780
|
try {
|
|
753
781
|
writeJson(res, 200, await runSync())
|
|
754
782
|
} catch (error) {
|
|
@@ -844,7 +872,7 @@ export function apply(ctx, config) {
|
|
|
844
872
|
path: '/dsh-kanban/board',
|
|
845
873
|
handler: (req, res) => {
|
|
846
874
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
847
|
-
if (
|
|
875
|
+
if (rejectUntrusted(req, res)) return
|
|
848
876
|
const url = urlOf(req)
|
|
849
877
|
reply(res, buildBoard({
|
|
850
878
|
store,
|
|
@@ -879,7 +907,7 @@ export function apply(ctx, config) {
|
|
|
879
907
|
path: '/dsh-kanban/metrics',
|
|
880
908
|
handler: (req, res) => {
|
|
881
909
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
882
|
-
if (
|
|
910
|
+
if (rejectUntrusted(req, res)) return
|
|
883
911
|
const url = urlOf(req)
|
|
884
912
|
const days = Number(url.searchParams.get('staleDays'))
|
|
885
913
|
reply(res, boardMetrics({
|
|
@@ -896,7 +924,7 @@ export function apply(ctx, config) {
|
|
|
896
924
|
// Empty login indicates unconfigured or unreachable Gitea; client omits "Mine" filter.
|
|
897
925
|
handler: async (req, res) => {
|
|
898
926
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
899
|
-
if (
|
|
927
|
+
if (rejectUntrusted(req, res)) return
|
|
900
928
|
reply(res, { login: await whoAmI() })
|
|
901
929
|
},
|
|
902
930
|
}), 'dsh-kanban: whoami')
|
|
@@ -906,7 +934,7 @@ export function apply(ctx, config) {
|
|
|
906
934
|
path: '/dsh-kanban/export.csv',
|
|
907
935
|
handler: async (req, res) => {
|
|
908
936
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
909
|
-
if (
|
|
937
|
+
if (rejectUntrusted(req, res)) return
|
|
910
938
|
const u = urlOf(req)
|
|
911
939
|
const board = u.searchParams.get('board') || 'main'
|
|
912
940
|
const csv = exportTasksCsv({ store, board })
|
|
@@ -923,7 +951,7 @@ export function apply(ctx, config) {
|
|
|
923
951
|
kind: 'exact',
|
|
924
952
|
path: '/dsh-kanban/events',
|
|
925
953
|
handler: (req, res) => {
|
|
926
|
-
eventHub.handleSseRequest(req, res, { isTrustedRequest })
|
|
954
|
+
eventHub.handleSseRequest(req, res, { isTrustedRequest: isTrusted })
|
|
927
955
|
},
|
|
928
956
|
}), 'dsh-kanban: sse events stream')
|
|
929
957
|
|
|
@@ -939,7 +967,7 @@ export function apply(ctx, config) {
|
|
|
939
967
|
path: '/dsh-kanban/templates',
|
|
940
968
|
handler: (req, res) => {
|
|
941
969
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
942
|
-
if (
|
|
970
|
+
if (rejectUntrusted(req, res)) return
|
|
943
971
|
const u = new URL(req.url, 'http://localhost')
|
|
944
972
|
const tid = u.searchParams.get('id')
|
|
945
973
|
if (tid) {
|
|
@@ -956,7 +984,7 @@ export function apply(ctx, config) {
|
|
|
956
984
|
kind: 'exact',
|
|
957
985
|
path: '/dsh-kanban/snapshot',
|
|
958
986
|
handler: async (req, res) => {
|
|
959
|
-
if (
|
|
987
|
+
if (rejectUntrusted(req, res)) return
|
|
960
988
|
if (req.method === 'GET') { reply(res, exportBoard({ store })); return }
|
|
961
989
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
962
990
|
try {
|
|
@@ -982,7 +1010,7 @@ export function apply(ctx, config) {
|
|
|
982
1010
|
// Path without ID handles local task creation.
|
|
983
1011
|
if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
|
|
984
1012
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
985
|
-
if (
|
|
1013
|
+
if (rejectUntrusted(req, res)) return
|
|
986
1014
|
try {
|
|
987
1015
|
const resObj = createTask({ store, input: await readBody(req) })
|
|
988
1016
|
if (resObj?.task) {
|
|
@@ -999,7 +1027,7 @@ export function apply(ctx, config) {
|
|
|
999
1027
|
if (parsed === undefined) { writeJson(res, 404, { error: 'not-found' }); return }
|
|
1000
1028
|
const { id, action } = parsed
|
|
1001
1029
|
|
|
1002
|
-
if (
|
|
1030
|
+
if (rejectUntrusted(req, res)) return
|
|
1003
1031
|
|
|
1004
1032
|
if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
|
|
1005
1033
|
if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
|
|
@@ -1391,7 +1419,7 @@ export function apply(ctx, config) {
|
|
|
1391
1419
|
path: '/dsh-kanban/gitea/repos',
|
|
1392
1420
|
handler: async (req, res) => {
|
|
1393
1421
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
1394
|
-
if (
|
|
1422
|
+
if (rejectUntrusted(req, res)) return
|
|
1395
1423
|
const url = urlOf(req)
|
|
1396
1424
|
reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
|
|
1397
1425
|
},
|
|
@@ -1402,7 +1430,7 @@ export function apply(ctx, config) {
|
|
|
1402
1430
|
path: '/dsh-kanban/gitea/issues',
|
|
1403
1431
|
handler: async (req, res) => {
|
|
1404
1432
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
1405
|
-
if (
|
|
1433
|
+
if (rejectUntrusted(req, res)) return
|
|
1406
1434
|
const url = urlOf(req)
|
|
1407
1435
|
reply(res, await listImportable({
|
|
1408
1436
|
gitea: git,
|
|
@@ -1419,7 +1447,7 @@ export function apply(ctx, config) {
|
|
|
1419
1447
|
path: '/dsh-kanban/import',
|
|
1420
1448
|
handler: async (req, res) => {
|
|
1421
1449
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
1422
|
-
if (
|
|
1450
|
+
if (rejectUntrusted(req, res)) return
|
|
1423
1451
|
try {
|
|
1424
1452
|
const input = await readBody(req)
|
|
1425
1453
|
reply(res, await importIssue({
|
|
@@ -1440,7 +1468,7 @@ export function apply(ctx, config) {
|
|
|
1440
1468
|
path: '/dsh-kanban/models',
|
|
1441
1469
|
handler: async (req, res) => {
|
|
1442
1470
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
1443
|
-
if (
|
|
1471
|
+
if (rejectUntrusted(req, res)) return
|
|
1444
1472
|
const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
|
|
1445
1473
|
const llmService = ctx.get('llm')
|
|
1446
1474
|
let providers = []
|
|
@@ -1469,7 +1497,7 @@ export function apply(ctx, config) {
|
|
|
1469
1497
|
path: '/dsh-kanban/session',
|
|
1470
1498
|
handler: (req, res) => {
|
|
1471
1499
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
1472
|
-
if (
|
|
1500
|
+
if (rejectUntrusted(req, res)) return
|
|
1473
1501
|
const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
|
|
1474
1502
|
if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
|
|
1475
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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
69
|
-
|
|
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
|
|
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
|
|
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
|
|
122
|
+
if (origin === 'null' || !origin) return 403
|
|
88
123
|
try {
|
|
89
124
|
const parsed = new URL(origin)
|
|
90
|
-
if (!isTrustedHost(parsed.hostname)) return
|
|
91
|
-
if (parsed.host && parsed.host !== host) return
|
|
125
|
+
if (!isTrustedHost(parsed.hostname)) return 403
|
|
126
|
+
if (parsed.host && parsed.host !== host) return 403
|
|
92
127
|
} catch {
|
|
93
|
-
return
|
|
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
|
|
103
|
-
if (parsedRef.host && parsedRef.host !== host) return
|
|
137
|
+
if (!isTrustedHost(parsedRef.hostname)) return 403
|
|
138
|
+
if (parsedRef.host && parsedRef.host !== host) return 403
|
|
104
139
|
} catch {
|
|
105
|
-
return
|
|
140
|
+
return 403
|
|
106
141
|
}
|
|
107
142
|
}
|
|
108
143
|
|
|
109
|
-
// 4.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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.
|
|
119
|
-
//
|
|
120
|
-
if (
|
|
121
|
-
return
|
|
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.
|
|
125
|
-
if (!remote &&
|
|
126
|
-
|
|
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.
|
|
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",
|