@goodandready/dsh-github-ops 0.6.0 → 0.6.2
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 +1 -0
- package/lib/scm-write.js +30 -12
- package/lib/status.js +60 -7
- package/package.json +1 -1
package/lib/routes.js
CHANGED
|
@@ -12,6 +12,7 @@ import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from
|
|
|
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'
|
|
15
|
+
import { encodeRepoPath } from './github.js'
|
|
15
16
|
import { listIssues } from './tools/issues.js'
|
|
16
17
|
import { listRuns } from './tools/actions.js'
|
|
17
18
|
import { listNotifications, repoTreeView } from './tools/panel-data.js'
|
package/lib/scm-write.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { join as joinPath } from 'node:path'
|
|
2
|
+
import { join as joinPath, posix, win32 } from 'node:path'
|
|
3
3
|
import { hasConflicts, resolveConflictContent } from './scm.js'
|
|
4
4
|
// Writing to a working copy, from the Source Control tab.
|
|
5
5
|
//
|
|
@@ -206,29 +206,47 @@ export const OPERATION_VERBS = Object.freeze(CONTINUE_VERBS.slice())
|
|
|
206
206
|
*/
|
|
207
207
|
const DISALLOWED_ROOTS = ['/etc', '/proc', '/sys', '/dev', '/root', '/bin', '/sbin', '/usr', '/boot', '/var/run']
|
|
208
208
|
|
|
209
|
+
function checkDisallowed(p) {
|
|
210
|
+
const norm = p.replace(/\\/g, '/')
|
|
211
|
+
for (const root of DISALLOWED_ROOTS) {
|
|
212
|
+
if (norm === root || norm.startsWith(root + '/')) {
|
|
213
|
+
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const driveMatch = norm.match(/^[a-zA-Z]:(\/.*)?$/)
|
|
217
|
+
if (driveMatch) {
|
|
218
|
+
const sub = (driveMatch[1] || '/').toLowerCase()
|
|
219
|
+
if (sub === '/' || sub === '') {
|
|
220
|
+
throw new Error('access to system drive root is not allowed')
|
|
221
|
+
}
|
|
222
|
+
for (const winDir of ['/windows', '/winnt', '/system32', '/boot']) {
|
|
223
|
+
if (sub === winDir || sub.startsWith(winDir + '/')) {
|
|
224
|
+
throw new Error(`access to system directory "${winDir.slice(1)}" is not allowed`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
209
230
|
export function validateCwd(path) {
|
|
210
231
|
const value = String(path == null ? '' : path).trim()
|
|
211
232
|
if (!value) return ''
|
|
212
|
-
if (!
|
|
233
|
+
if (!posix.isAbsolute(value) && !win32.isAbsolute(value)) {
|
|
234
|
+
throw new Error('a repository path must be absolute')
|
|
235
|
+
}
|
|
213
236
|
if (value.includes('..')) throw new Error('a repository path may not contain ".."')
|
|
214
237
|
if (/[\x00-\x1f]/.test(value)) throw new Error('a repository path may not contain control characters')
|
|
215
238
|
if (value.length > 500) throw new Error('a repository path is too long')
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
}
|
|
239
|
+
|
|
240
|
+
checkDisallowed(value)
|
|
241
|
+
|
|
221
242
|
let realPath = value
|
|
222
243
|
try {
|
|
223
244
|
realPath = realpathSync(value)
|
|
224
245
|
} catch {
|
|
225
246
|
realPath = value
|
|
226
247
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
230
|
-
}
|
|
231
|
-
}
|
|
248
|
+
checkDisallowed(realPath)
|
|
249
|
+
|
|
232
250
|
return value
|
|
233
251
|
}
|
|
234
252
|
|
package/lib/status.js
CHANGED
|
@@ -35,6 +35,21 @@ function isTrustedHost(host) {
|
|
|
35
35
|
return false
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Whether an address or host string represents local loopback. */
|
|
39
|
+
function isLoopback(val) {
|
|
40
|
+
if (!val || typeof val !== 'string') return false
|
|
41
|
+
let h = val.trim().toLowerCase()
|
|
42
|
+
if (h.startsWith('[')) {
|
|
43
|
+
const end = h.indexOf(']')
|
|
44
|
+
if (end > 0) h = h.slice(1, end)
|
|
45
|
+
} else if (h.includes(':')) {
|
|
46
|
+
const colons = h.split(':').length - 1
|
|
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.'
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/** Whether a request may read the status payload. Fail-closed. */
|
|
39
54
|
export function isTrustedRequest(req) {
|
|
40
55
|
if (!req) return false
|
|
@@ -51,15 +66,13 @@ export function isTrustedRequest(req) {
|
|
|
51
66
|
if (cleanIp && !isTrustedHost(cleanIp)) return false
|
|
52
67
|
|
|
53
68
|
const host = header(req, 'host')
|
|
54
|
-
const origin = header(req, 'origin') || header(req, 'referer')
|
|
55
|
-
|
|
56
69
|
// If host is provided, it MUST be a trusted local/private host (DNS rebinding guard)
|
|
57
70
|
if (host && !isTrustedHost(host)) return false
|
|
58
71
|
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
const origin = header(req, 'origin')
|
|
73
|
+
const referer = header(req, 'referer')
|
|
61
74
|
|
|
62
|
-
//
|
|
75
|
+
// Check origin header first
|
|
63
76
|
if (origin) {
|
|
64
77
|
try {
|
|
65
78
|
const originUrl = new URL(origin)
|
|
@@ -71,6 +84,31 @@ export function isTrustedRequest(req) {
|
|
|
71
84
|
}
|
|
72
85
|
}
|
|
73
86
|
|
|
87
|
+
// Check referer header
|
|
88
|
+
if (referer) {
|
|
89
|
+
try {
|
|
90
|
+
const refUrl = new URL(referer)
|
|
91
|
+
if (refUrl.protocol === 'http:' || refUrl.protocol === 'https:') {
|
|
92
|
+
if (!isTrustedHost(refUrl.hostname)) return false
|
|
93
|
+
if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return false
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
// Non-http referer (e.g. dsh-app://app/ in DSH Desktop Electron):
|
|
97
|
+
// Desktop removes origin/sec-fetch-site and sets custom scheme referer.
|
|
98
|
+
// Accept only if caller is loopback (socket or loopback host).
|
|
99
|
+
const hostIsLoopback = host ? isLoopback(host) : false
|
|
100
|
+
if (loopback || (!address && hostIsLoopback)) {
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
return false
|
|
104
|
+
} catch {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// If browser headers (origin/referer/sec-fetch-site) are absent and caller is loopback, allow local caller
|
|
110
|
+
if (!secFetchSite && loopback) return true
|
|
111
|
+
|
|
74
112
|
// Non-loopback caller without origin/referer is refused
|
|
75
113
|
if (!loopback) return false
|
|
76
114
|
|
|
@@ -94,7 +132,9 @@ export function isTrustedWriteRequest(req) {
|
|
|
94
132
|
if (cleanIp && !isTrustedHost(cleanIp)) return false
|
|
95
133
|
|
|
96
134
|
const host = header(req, 'host')
|
|
97
|
-
const origin = header(req, 'origin')
|
|
135
|
+
const origin = header(req, 'origin')
|
|
136
|
+
const referer = header(req, 'referer')
|
|
137
|
+
|
|
98
138
|
if (origin) {
|
|
99
139
|
try {
|
|
100
140
|
const originUrl = new URL(origin)
|
|
@@ -102,8 +142,21 @@ export function isTrustedWriteRequest(req) {
|
|
|
102
142
|
} catch {
|
|
103
143
|
return false
|
|
104
144
|
}
|
|
145
|
+
} else if (referer) {
|
|
146
|
+
try {
|
|
147
|
+
const refUrl = new URL(referer)
|
|
148
|
+
if (refUrl.protocol === 'http:' || refUrl.protocol === 'https:') {
|
|
149
|
+
if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return false
|
|
150
|
+
} else {
|
|
151
|
+
// Non-http custom scheme referer from loopback: permitted as local loopback writer
|
|
152
|
+
const hostIsLoopback = host ? isLoopback(host) : false
|
|
153
|
+
if (!loopback && (address || !hostIsLoopback)) return false
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
105
158
|
} else {
|
|
106
|
-
// Write request without origin: only permit local loopback
|
|
159
|
+
// Write request without origin/referer: only permit local loopback
|
|
107
160
|
if (!loopback) return false
|
|
108
161
|
}
|
|
109
162
|
|
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.2",
|
|
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",
|