@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 +47 -21
- package/lib/routes.js +190 -34
- package/lib/updater.js +22 -3
- package/package.json +1 -1
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
69
|
-
|
|
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
|
|
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
|
|
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
|
|
188
|
+
if (origin === 'null' || !origin) return 403
|
|
88
189
|
try {
|
|
89
190
|
const parsed = new URL(origin)
|
|
90
|
-
if (!isTrustedHost(parsed.hostname)) return
|
|
91
|
-
if (parsed.host && parsed.host !== host) return
|
|
191
|
+
if (!isTrustedHost(parsed.hostname)) return 403
|
|
192
|
+
if (parsed.host && parsed.host !== host) return 403
|
|
92
193
|
} catch {
|
|
93
|
-
return
|
|
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
|
|
103
|
-
if (parsedRef.host && parsedRef.host !== host) return
|
|
203
|
+
if (!isTrustedHost(parsedRef.hostname)) return 403
|
|
204
|
+
if (parsedRef.host && parsedRef.host !== host) return 403
|
|
104
205
|
} catch {
|
|
105
|
-
return
|
|
206
|
+
return 403
|
|
106
207
|
}
|
|
107
208
|
}
|
|
108
209
|
|
|
109
|
-
// 4.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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.
|
|
119
|
-
//
|
|
120
|
-
if (
|
|
121
|
-
return
|
|
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.
|
|
125
|
-
if (!remote &&
|
|
126
|
-
|
|
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
|
|
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.
|
|
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",
|