@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 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 (!value.startsWith('/')) throw new Error('a repository path must be absolute')
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
- for (const root of DISALLOWED_ROOTS) {
217
- if (value === root || value.startsWith(root + '/')) {
218
- throw new Error(`access to system directory "${root}" is not allowed`)
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
- for (const root of DISALLOWED_ROOTS) {
228
- if (realPath === root || realPath.startsWith(root + '/')) {
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
- // If browser headers (origin/referer/sec-fetch-site) are absent and caller is loopback, allow local caller
60
- if (!origin && !secFetchSite && loopback) return true
72
+ const origin = header(req, 'origin')
73
+ const referer = header(req, 'referer')
61
74
 
62
- // If origin/referer is present, verify it matches host or is a trusted local host
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') || header(req, 'referer')
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.0",
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",