@goodandready/dsh-github-ops 0.6.4 → 0.6.5
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/routes.js +56 -10
- package/lib/status.js +204 -86
- package/package.json +1 -1
package/lib/routes.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, statSync, readFileSync } from 'node:fs'
|
|
9
9
|
import { join as joinPath } from 'node:path'
|
|
10
10
|
|
|
11
|
-
import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
|
|
11
|
+
import { statusPayload, isTrustedRequest, isTrustedWriteRequest, getRejectionStatus, getWriteRejectionStatus, sendJson } from './status.js'
|
|
12
12
|
import { buildAction, validateCwd, validateStashRef, resolveConflictFile } from './scm-write.js'
|
|
13
13
|
import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom, hasConflicts, parseConflictBlocks, parseDiffHunks, parseGitGraph } from './scm.js'
|
|
14
14
|
import { SseEventManager } from './events.js'
|
|
@@ -64,6 +64,25 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
64
64
|
makeGitRunner = () => () => ({ code: 1, stdout: '', stderr: 'git not available' }),
|
|
65
65
|
cacheStats = () => null,
|
|
66
66
|
} = deps
|
|
67
|
+
|
|
68
|
+
const resolveConnection = deps.resolveConnection || (() => {
|
|
69
|
+
try {
|
|
70
|
+
if (ctx && typeof ctx.get === 'function') {
|
|
71
|
+
const c = ctx.get('connection')
|
|
72
|
+
if (c) return c
|
|
73
|
+
}
|
|
74
|
+
} catch { /* ignore */ }
|
|
75
|
+
try {
|
|
76
|
+
if (ctx && ctx.connection) return ctx.connection
|
|
77
|
+
} catch { /* ignore */ }
|
|
78
|
+
try {
|
|
79
|
+
const c = Reflect.get(ctx, 'connection')
|
|
80
|
+
if (c) return c
|
|
81
|
+
} catch { /* ignore */ }
|
|
82
|
+
return undefined
|
|
83
|
+
})
|
|
84
|
+
const routeOptions = () => ({ get connection() { return resolveConnection() } })
|
|
85
|
+
|
|
67
86
|
// The card reads the access status from here instead of from a tool result: the model has
|
|
68
87
|
// no business holding the access value, and the payload describes this machine's account,
|
|
69
88
|
// so only a local or same-origin caller is answered.
|
|
@@ -74,7 +93,10 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
74
93
|
path: STATUS_PATH,
|
|
75
94
|
handler: async (req, res) => {
|
|
76
95
|
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
77
|
-
if (!isTrustedRequest(req))
|
|
96
|
+
if (!isTrustedRequest(req, routeOptions())) {
|
|
97
|
+
const status = getRejectionStatus(req, routeOptions()) || 403
|
|
98
|
+
return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
99
|
+
}
|
|
78
100
|
const access = await resolveToken()
|
|
79
101
|
let identity = null
|
|
80
102
|
let rateLimit = null
|
|
@@ -120,9 +142,15 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
120
142
|
try {
|
|
121
143
|
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
122
144
|
if (req.method === 'POST') {
|
|
123
|
-
if (!isTrustedWriteRequest(req))
|
|
145
|
+
if (!isTrustedWriteRequest(req, routeOptions())) {
|
|
146
|
+
const status = getWriteRejectionStatus(req, routeOptions()) || 403
|
|
147
|
+
return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
148
|
+
}
|
|
124
149
|
} else {
|
|
125
|
-
if (!isTrustedRequest(req))
|
|
150
|
+
if (!isTrustedRequest(req, routeOptions())) {
|
|
151
|
+
const status = getRejectionStatus(req, routeOptions()) || 403
|
|
152
|
+
return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
153
|
+
}
|
|
126
154
|
}
|
|
127
155
|
const git = makeGitRunner()
|
|
128
156
|
const url = new URL(req.url || '/', 'http://localhost')
|
|
@@ -306,7 +334,10 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
306
334
|
path: PANEL_PATH,
|
|
307
335
|
handler: async (req, res) => {
|
|
308
336
|
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
309
|
-
if (!isTrustedRequest(req))
|
|
337
|
+
if (!isTrustedRequest(req, routeOptions())) {
|
|
338
|
+
const status = getRejectionStatus(req, routeOptions()) || 403
|
|
339
|
+
return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
340
|
+
}
|
|
310
341
|
const access = await resolveToken()
|
|
311
342
|
if (!access.value) return sendJson(res, 200, { configured: false, guidance: access.guidance })
|
|
312
343
|
const url = new URL(req.url || '/', 'http://localhost')
|
|
@@ -381,9 +412,15 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
381
412
|
try {
|
|
382
413
|
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
383
414
|
if (req.method === 'POST') {
|
|
384
|
-
if (!isTrustedWriteRequest(req))
|
|
415
|
+
if (!isTrustedWriteRequest(req, routeOptions())) {
|
|
416
|
+
const status = getWriteRejectionStatus(req, routeOptions()) || 403
|
|
417
|
+
return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
418
|
+
}
|
|
385
419
|
} else {
|
|
386
|
-
if (!isTrustedRequest(req))
|
|
420
|
+
if (!isTrustedRequest(req, routeOptions())) {
|
|
421
|
+
const status = getRejectionStatus(req, routeOptions()) || 403
|
|
422
|
+
return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
423
|
+
}
|
|
387
424
|
}
|
|
388
425
|
const git = makeGitRunner()
|
|
389
426
|
const url = new URL(req.url || '/', 'http://localhost')
|
|
@@ -451,7 +488,10 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
451
488
|
handler: async (req, res) => {
|
|
452
489
|
try {
|
|
453
490
|
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
454
|
-
if (!isTrustedRequest(req))
|
|
491
|
+
if (!isTrustedRequest(req, routeOptions())) {
|
|
492
|
+
const status = getRejectionStatus(req, routeOptions()) || 403
|
|
493
|
+
return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
494
|
+
}
|
|
455
495
|
const access = await resolveToken()
|
|
456
496
|
if (!access.value) return sendJson(res, 200, { configured: false, guidance: access.guidance })
|
|
457
497
|
const url = new URL(req.url || '/', 'http://localhost')
|
|
@@ -474,7 +514,10 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
474
514
|
handler: async (req, res) => {
|
|
475
515
|
try {
|
|
476
516
|
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
477
|
-
if (!isTrustedRequest(req))
|
|
517
|
+
if (!isTrustedRequest(req, routeOptions())) {
|
|
518
|
+
const status = getRejectionStatus(req, routeOptions()) || 403
|
|
519
|
+
return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
520
|
+
}
|
|
478
521
|
const git = makeGitRunner()
|
|
479
522
|
const url = new URL(req.url || '/', 'http://localhost')
|
|
480
523
|
const requested = validateCwd(url.searchParams.get('cwd') || '')
|
|
@@ -515,7 +558,10 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
515
558
|
path: EVENTS_PATH,
|
|
516
559
|
handler: async (req, res) => {
|
|
517
560
|
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
518
|
-
if (!isTrustedRequest(req))
|
|
561
|
+
if (!isTrustedRequest(req, routeOptions())) {
|
|
562
|
+
const status = getRejectionStatus(req, routeOptions()) || 403
|
|
563
|
+
return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
564
|
+
}
|
|
519
565
|
res.writeHead(200, {
|
|
520
566
|
'Content-Type': 'text/event-stream',
|
|
521
567
|
'Cache-Control': 'no-cache, no-transform',
|
package/lib/status.js
CHANGED
|
@@ -8,8 +8,46 @@ import { auditTokenScopes } from './token-audit.js'
|
|
|
8
8
|
// The payload never contains the access value. The route answers only a local or same-origin
|
|
9
9
|
// caller, because the payload describes the machine's account.
|
|
10
10
|
|
|
11
|
-
/** Whether
|
|
12
|
-
function
|
|
11
|
+
/** Whether an address or host string represents local loopback. */
|
|
12
|
+
export function isLoopbackAddress(val) {
|
|
13
|
+
if (!val || typeof val !== 'string') return false
|
|
14
|
+
let h = val.trim().toLowerCase()
|
|
15
|
+
if (h.startsWith('[')) {
|
|
16
|
+
const end = h.indexOf(']')
|
|
17
|
+
if (end > 0) h = h.slice(1, end)
|
|
18
|
+
} else if (h.includes(':')) {
|
|
19
|
+
const colons = h.split(':').length - 1
|
|
20
|
+
if (colons === 1) h = h.split(':')[0]
|
|
21
|
+
}
|
|
22
|
+
const clean = h.replace(/^::ffff:/, '')
|
|
23
|
+
return clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.') || clean === 'localhost' || clean === 'localhost.' || clean === '[::1]'
|
|
24
|
+
}
|
|
25
|
+
export const isLoopback = isLoopbackAddress
|
|
26
|
+
|
|
27
|
+
/** Whether an address or host string represents a private LAN network. */
|
|
28
|
+
export function isLanAddress(ip) {
|
|
29
|
+
if (!ip || typeof ip !== 'string') return false
|
|
30
|
+
let h = ip.trim().toLowerCase()
|
|
31
|
+
if (h.startsWith('[')) {
|
|
32
|
+
const end = h.indexOf(']')
|
|
33
|
+
if (end > 0) h = h.slice(1, end)
|
|
34
|
+
} else if (h.includes(':')) {
|
|
35
|
+
const colons = h.split(':').length - 1
|
|
36
|
+
if (colons === 1) h = h.split(':')[0]
|
|
37
|
+
}
|
|
38
|
+
const clean = h.replace(/^::ffff:/, '')
|
|
39
|
+
if (isLoopbackAddress(clean)) return true
|
|
40
|
+
if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
|
|
41
|
+
const match172 = clean.match(/^172\.(\d+)\./)
|
|
42
|
+
if (match172) {
|
|
43
|
+
const octet = parseInt(match172[1], 10)
|
|
44
|
+
if (octet >= 16 && octet <= 31) return true
|
|
45
|
+
}
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether a host header passes DNS-rebinding guards. Fail-closed. */
|
|
50
|
+
export function isTrustedHost(host) {
|
|
13
51
|
if (!host || typeof host !== 'string') return false
|
|
14
52
|
let hostname = host.trim()
|
|
15
53
|
if (hostname.startsWith('[')) {
|
|
@@ -24,146 +62,226 @@ function isTrustedHost(host) {
|
|
|
24
62
|
hostname = hostname.toLowerCase()
|
|
25
63
|
if (!hostname) return false
|
|
26
64
|
if (hostname === 'localhost' || hostname.endsWith('.local') || hostname.endsWith('.lan')) return true
|
|
27
|
-
|
|
28
|
-
if (clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.')) return true
|
|
29
|
-
if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
|
|
30
|
-
const match172 = clean.match(/^172\.(\d+)\./)
|
|
31
|
-
if (match172) {
|
|
32
|
-
const octet = parseInt(match172[1], 10)
|
|
33
|
-
if (octet >= 16 && octet <= 31) return true
|
|
34
|
-
}
|
|
35
|
-
return false
|
|
65
|
+
return isLoopbackAddress(hostname) || isLanAddress(hostname)
|
|
36
66
|
}
|
|
37
67
|
|
|
38
|
-
/**
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (colons === 1) h = h.split(':')[0]
|
|
48
|
-
}
|
|
49
|
-
const clean = h.replace(/^::ffff:/, '')
|
|
50
|
-
return clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.') || clean === 'localhost' || clean === 'localhost.'
|
|
68
|
+
/** Resolves remote client IP from socket or request metadata. */
|
|
69
|
+
export function getClientIp(req) {
|
|
70
|
+
return (
|
|
71
|
+
req?.socket?.remoteAddress ||
|
|
72
|
+
req?.socket?.address?.()?.address ||
|
|
73
|
+
req?.connection?.remoteAddress ||
|
|
74
|
+
req?.info?.remoteAddress ||
|
|
75
|
+
''
|
|
76
|
+
)
|
|
51
77
|
}
|
|
52
78
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Evaluates whether an incoming HTTP request is trusted and authenticated (#129).
|
|
81
|
+
*
|
|
82
|
+
* Requirements:
|
|
83
|
+
* 1. If DSH connection service is available (options.connection), requestRejection(req)
|
|
84
|
+
* is the authoritative gate for browser session authentication and trusted network access.
|
|
85
|
+
* If rejection is returned, the numeric status (e.g. 401 or 403) is returned.
|
|
86
|
+
* 2. Host header, if present, must pass isTrustedHost (prevents DNS rebinding).
|
|
87
|
+
* 3. Cross-site requests (sec-fetch-site: cross-site) are strictly rejected with 403.
|
|
88
|
+
* 4. Origin & Referer headers (if present) must match Host or a trusted host.
|
|
89
|
+
* 5. Fallback when connection service is absent (unit tests, standalone runner):
|
|
90
|
+
* - Loopback callers (127.0.0.1, ::1) are allowed.
|
|
91
|
+
* - Remote / LAN callers without connection authentication are rejected with 403 (fail closed).
|
|
92
|
+
* - Unknown remote address (socket present but empty remoteAddress) fails closed with 403.
|
|
93
|
+
*
|
|
94
|
+
* @param {import('node:http').IncomingMessage} req
|
|
95
|
+
* @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
|
|
96
|
+
* @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
|
|
97
|
+
*/
|
|
98
|
+
export function getRejectionStatus(req, options = {}) {
|
|
99
|
+
if (!req) return 403
|
|
56
100
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
101
|
+
const connection = options?.connection
|
|
102
|
+
if (connection && typeof connection.requestRejection === 'function') {
|
|
103
|
+
try {
|
|
104
|
+
const rejection = connection.requestRejection(req)
|
|
105
|
+
if (rejection !== undefined) {
|
|
106
|
+
if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
|
|
107
|
+
return rejection
|
|
108
|
+
}
|
|
109
|
+
return rejection === 401 ? 401 : 403
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Fail closed if connection service throws
|
|
113
|
+
return 403
|
|
114
|
+
}
|
|
115
|
+
}
|
|
60
116
|
|
|
61
|
-
const
|
|
62
|
-
const cleanIp = address.replace(/^::ffff:/, '')
|
|
63
|
-
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
117
|
+
const host = header(req, 'host')
|
|
64
118
|
|
|
65
|
-
//
|
|
66
|
-
if (
|
|
119
|
+
// 1. Host header must be present and trusted if provided
|
|
120
|
+
if (host && !isTrustedHost(host)) {
|
|
121
|
+
return 403
|
|
122
|
+
}
|
|
67
123
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
124
|
+
// 2. Strict rejection of cross-site requests
|
|
125
|
+
const site = header(req, 'sec-fetch-site')
|
|
126
|
+
if (site === 'cross-site') {
|
|
127
|
+
return 403
|
|
128
|
+
}
|
|
71
129
|
|
|
130
|
+
// 3. Origin header validation if present
|
|
72
131
|
const origin = header(req, 'origin')
|
|
73
|
-
const referer = header(req, 'referer')
|
|
74
|
-
|
|
75
|
-
// Check origin header first
|
|
76
132
|
if (origin) {
|
|
133
|
+
if (origin === 'null') return 403
|
|
77
134
|
try {
|
|
78
135
|
const originUrl = new URL(origin)
|
|
79
|
-
if (!isTrustedHost(originUrl.hostname)) return
|
|
80
|
-
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return
|
|
81
|
-
return true
|
|
136
|
+
if (!isTrustedHost(originUrl.hostname)) return 403
|
|
137
|
+
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return 403
|
|
82
138
|
} catch {
|
|
83
|
-
return
|
|
139
|
+
return 403
|
|
84
140
|
}
|
|
85
141
|
}
|
|
86
142
|
|
|
87
|
-
//
|
|
143
|
+
// 4. Referer header validation if present
|
|
144
|
+
const referer = header(req, 'referer')
|
|
88
145
|
if (referer) {
|
|
89
146
|
try {
|
|
90
147
|
const refUrl = new URL(referer)
|
|
91
148
|
if (refUrl.protocol === 'http:' || refUrl.protocol === 'https:') {
|
|
92
|
-
if (!isTrustedHost(refUrl.hostname)) return
|
|
93
|
-
if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return true
|
|
149
|
+
if (!isTrustedHost(refUrl.hostname)) return 403
|
|
150
|
+
if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return 403
|
|
151
|
+
} else {
|
|
152
|
+
// Non-http custom scheme referer (e.g. dsh-app://app/ in DSH Desktop Electron):
|
|
153
|
+
// Permitted only from local loopback
|
|
154
|
+
const ip = getClientIp(req)
|
|
155
|
+
const hostIsLoopback = host ? isLoopbackAddress(host) : false
|
|
156
|
+
const isLocal = ip ? isLoopbackAddress(ip) : hostIsLoopback
|
|
157
|
+
if (!isLocal) return 403
|
|
102
158
|
}
|
|
103
|
-
return false
|
|
104
159
|
} catch {
|
|
105
|
-
return
|
|
160
|
+
return 403
|
|
106
161
|
}
|
|
107
162
|
}
|
|
108
163
|
|
|
109
|
-
//
|
|
110
|
-
|
|
164
|
+
// 5. Network boundary validation when connection service is absent:
|
|
165
|
+
const hasSocket = !!(req.socket || req.connection)
|
|
166
|
+
const ip = getClientIp(req)
|
|
111
167
|
|
|
112
|
-
|
|
113
|
-
|
|
168
|
+
if (hasSocket) {
|
|
169
|
+
// Fail closed on missing/unknown remoteAddress
|
|
170
|
+
if (!ip) {
|
|
171
|
+
return 403
|
|
172
|
+
}
|
|
173
|
+
// Remote / LAN callers without connection authentication are rejected (fail closed!)
|
|
174
|
+
if (!isLoopbackAddress(ip)) {
|
|
175
|
+
if (!connection) {
|
|
176
|
+
return 403
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else if (!connection) {
|
|
180
|
+
// Synthetic mock request without socket: allow loopback host for unit tests
|
|
181
|
+
if (host && !isLoopbackAddress(host)) {
|
|
182
|
+
return 403
|
|
183
|
+
}
|
|
184
|
+
}
|
|
114
185
|
|
|
115
|
-
|
|
116
|
-
|
|
186
|
+
// If browser headers are absent and caller is non-loopback without connection
|
|
187
|
+
if (!connection) {
|
|
188
|
+
const isLocal = ip ? isLoopbackAddress(ip) : (host ? isLoopbackAddress(host) : false)
|
|
189
|
+
if (!isLocal) return 403
|
|
190
|
+
}
|
|
117
191
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!req) return false
|
|
121
|
-
if (!isTrustedRequest(req)) return false
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
122
194
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Evaluates whether an incoming HTTP write request is safe for mutation (#129).
|
|
197
|
+
*
|
|
198
|
+
* Additional Write Requirements:
|
|
199
|
+
* 1. Must satisfy all getRejectionStatus checks (including connection.requestRejection).
|
|
200
|
+
* 2. If Origin header is present:
|
|
201
|
+
* - must match Host exactly (same-origin).
|
|
202
|
+
* 3. If Referer header is present:
|
|
203
|
+
* - must match Host exactly (same-origin).
|
|
204
|
+
* 4. If Sec-Fetch-Site header is present:
|
|
205
|
+
* - must be "same-origin" or "none" (same-site and cross-site are rejected).
|
|
206
|
+
* 5. If connection service is absent, only local loopback callers are allowed for writes.
|
|
207
|
+
*
|
|
208
|
+
* @param {import('node:http').IncomingMessage} req
|
|
209
|
+
* @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
|
|
210
|
+
* @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
|
|
211
|
+
*/
|
|
212
|
+
export function getWriteRejectionStatus(req, options = {}) {
|
|
213
|
+
const readStatus = getRejectionStatus(req, options)
|
|
214
|
+
if (readStatus !== undefined) {
|
|
215
|
+
return readStatus
|
|
126
216
|
}
|
|
127
217
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (cleanIp && !isTrustedHost(cleanIp)) return false
|
|
218
|
+
const site = header(req, 'sec-fetch-site')
|
|
219
|
+
if (site && site !== 'same-origin' && site !== 'none') {
|
|
220
|
+
return 403
|
|
221
|
+
}
|
|
133
222
|
|
|
134
223
|
const host = header(req, 'host')
|
|
135
224
|
const origin = header(req, 'origin')
|
|
136
225
|
const referer = header(req, 'referer')
|
|
226
|
+
const ip = getClientIp(req)
|
|
227
|
+
const connection = options?.connection
|
|
137
228
|
|
|
138
229
|
if (origin) {
|
|
230
|
+
if (origin === 'null') return 403
|
|
139
231
|
try {
|
|
140
232
|
const originUrl = new URL(origin)
|
|
141
|
-
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return
|
|
233
|
+
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return 403
|
|
142
234
|
} catch {
|
|
143
|
-
return
|
|
235
|
+
return 403
|
|
144
236
|
}
|
|
145
237
|
} else if (referer) {
|
|
146
238
|
try {
|
|
147
239
|
const refUrl = new URL(referer)
|
|
148
240
|
if (refUrl.protocol === 'http:' || refUrl.protocol === 'https:') {
|
|
149
|
-
if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return
|
|
241
|
+
if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return 403
|
|
150
242
|
} else {
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
if (!
|
|
243
|
+
const hostIsLoopback = host ? isLoopbackAddress(host) : false
|
|
244
|
+
const isLocal = ip ? isLoopbackAddress(ip) : hostIsLoopback
|
|
245
|
+
if (!isLocal) return 403
|
|
154
246
|
}
|
|
155
247
|
} catch {
|
|
156
|
-
return
|
|
248
|
+
return 403
|
|
157
249
|
}
|
|
158
250
|
} else {
|
|
159
251
|
// Write request without origin/referer: only permit local loopback
|
|
160
|
-
|
|
252
|
+
const hostIsLoopback = host ? isLoopbackAddress(host) : false
|
|
253
|
+
const isLocal = ip ? isLoopbackAddress(ip) : hostIsLoopback
|
|
254
|
+
if (!isLocal) return 403
|
|
161
255
|
}
|
|
162
256
|
|
|
163
|
-
|
|
257
|
+
// If connection service is absent, only local loopback callers are allowed for writes
|
|
258
|
+
if (!connection) {
|
|
259
|
+
const hasSocket = !!(req.socket || req.connection)
|
|
260
|
+
if (hasSocket) {
|
|
261
|
+
if (!ip || !isLoopbackAddress(ip)) {
|
|
262
|
+
return 403
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
if (!host || !isLoopbackAddress(host)) {
|
|
266
|
+
return 403
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return undefined
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Whether a request may read the status payload. Fail-closed. */
|
|
275
|
+
export function isTrustedRequest(req, options = {}) {
|
|
276
|
+
return getRejectionStatus(req, options) === undefined
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Whether a write/mutating request (e.g. POST /scm) is safe. Strict write guard against CSRF. */
|
|
280
|
+
export function isTrustedWriteRequest(req, options = {}) {
|
|
281
|
+
return getWriteRejectionStatus(req, options) === undefined
|
|
164
282
|
}
|
|
165
283
|
|
|
166
|
-
function header(req, name) {
|
|
284
|
+
export function header(req, name) {
|
|
167
285
|
if (!req || !req.headers) return ''
|
|
168
286
|
const value = req.headers[name] || req.headers[name.toLowerCase()]
|
|
169
287
|
return typeof value === 'string' ? value.trim() : ''
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|