@goodandready/dsh-image-gen 0.10.30 → 0.10.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 CHANGED
@@ -15,7 +15,7 @@
15
15
  import z from '@deepseek-ai/schemastery'
16
16
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
17
17
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
18
- import { enforceSecurePermissions } from './security.js'
18
+ import { enforceSecurePermissions, isTrustedLocalRequest } from './security.js'
19
19
  import { saveAndAttachResult } from './attachment-helper.js'
20
20
  import {
21
21
  historyFile,
@@ -431,6 +431,11 @@ export function apply(ctx, config) {
431
431
  res.end(JSON.stringify({ error: 'GET only' }))
432
432
  return
433
433
  }
434
+ if (!isTrustedLocalRequest(req)) {
435
+ res.writeHead(403, { 'Content-Type': 'application/json' })
436
+ res.end(JSON.stringify({ error: 'forbidden' }))
437
+ return
438
+ }
434
439
  const query = new URL(req.url ?? '/', 'http://x').searchParams
435
440
  const id = query.get('id') ?? ''
436
441
  if (!/^sha256:[0-9a-f]{64}$/.test(id)) {
@@ -504,6 +509,11 @@ export function apply(ctx, config) {
504
509
  res.end(JSON.stringify({ error: 'GET only' }))
505
510
  return
506
511
  }
512
+ if (!isTrustedLocalRequest(req)) {
513
+ res.writeHead(403, { 'Content-Type': 'application/json' })
514
+ res.end(JSON.stringify({ error: 'forbidden' }))
515
+ return
516
+ }
507
517
  try {
508
518
  const u = new URL(req.url, 'http://localhost')
509
519
  const provider = u.searchParams.get('provider') || config.provider || 'fal'
@@ -533,12 +543,20 @@ export function apply(ctx, config) {
533
543
  res.end(JSON.stringify({ error: 'GET only' }))
534
544
  return
535
545
  }
546
+ if (!isTrustedLocalRequest(req)) {
547
+ res.writeHead(403, { 'Content-Type': 'application/json' })
548
+ res.end(JSON.stringify({ error: 'forbidden' }))
549
+ return
550
+ }
536
551
  const exists = (p) => existsSync(p)
537
552
  const entries = await readHistory()
538
- const withThumbs = filterHistory(entries, exists).map((e) => ({
539
- ...e,
540
- thumbnailUrl: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
541
- }))
553
+ const withThumbs = filterHistory(entries, exists).map((e) => {
554
+ const { path: _discardPath, ...safeEntry } = e
555
+ return {
556
+ ...safeEntry,
557
+ thumbnailUrl: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
558
+ }
559
+ })
542
560
  res.writeHead(200, { 'Content-Type': 'application/json' })
543
561
  res.end(JSON.stringify(withThumbs))
544
562
  },
package/lib/security.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // lib/security.js
2
2
  // Secure credential handling, 0600 file permissions, and token masking (#169)
3
+ // Address validation and trusted request guards against DNS rebinding & cross-site leaks (Refs: GitHub #1, #280, #276, #277)
3
4
 
4
5
  import { chmodSync, existsSync } from 'node:fs'
5
6
 
@@ -67,3 +68,168 @@ export function enforceSecurePermissions(filePath) {
67
68
  return false
68
69
  }
69
70
  }
71
+
72
+ /**
73
+ * Strict loopback address verification.
74
+ * Supports:
75
+ * - 'localhost' and RFC 6761 reserved '.localhost' TLD (e.g. 'sub.localhost')
76
+ * - IPv6 loopback '::1'
77
+ * - Strict IPv4 loopback 127.0.0.0/8 (anchored ^ and $, valid octets 0..255)
78
+ * - IPv6-mapped IPv4 loopback '::ffff:127.x.x.x'
79
+ *
80
+ * Explicitly rejects prefix-matched hostnames such as 127.0.0.1.evil.com.
81
+ */
82
+ export function isLoopbackAddress(value) {
83
+ if (!value || typeof value !== 'string') return false
84
+ const address = value.trim().toLowerCase().replace(/^\[|\]$/g, '')
85
+ if (
86
+ address === 'localhost' ||
87
+ address === 'localhost.' ||
88
+ address === '::1' ||
89
+ address.endsWith('.localhost') ||
90
+ address.endsWith('.localhost.')
91
+ ) {
92
+ return true
93
+ }
94
+ const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
95
+ const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipv4)
96
+ if (!m) return false
97
+ const o = [Number(m[1]), Number(m[2]), Number(m[3])]
98
+ return o.every((n) => n >= 0 && n <= 255)
99
+ }
100
+
101
+ /**
102
+ * Strict private LAN address verification.
103
+ * Supports:
104
+ * - 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
105
+ * - 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
106
+ * - 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
107
+ * - 169.254.0.0/16 (link-local)
108
+ * - IPv6-mapped IPv4 equivalents (::ffff:...)
109
+ * - IPv6 ULA (fc00::/7) and link-local (fe80::/10)
110
+ *
111
+ * Explicitly rejects prefix-matched hostnames such as 10.evil.com,
112
+ * 192.168.evil.com, 172.16.evil.com, 10.0.0.1.nip.io.
113
+ */
114
+ export function isPrivateLanAddress(value) {
115
+ if (!value || typeof value !== 'string') return false
116
+ const address = value.trim().toLowerCase().replace(/^\[|\]$/g, '')
117
+ const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
118
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipv4)
119
+ if (m) {
120
+ const o = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
121
+ if (o.some((n) => n < 0 || n > 255)) return false
122
+ if (o[0] === 10) return true
123
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true
124
+ if (o[0] === 192 && o[1] === 168) return true
125
+ if (o[0] === 169 && o[1] === 254) return true
126
+ return false
127
+ }
128
+
129
+ // IPv6 ULA (fc00::/7) or link-local (fe80::/10)
130
+ if (/^(fc|fd)[0-9a-f]{2}:/i.test(address) || /^fe80:/i.test(address)) {
131
+ return true
132
+ }
133
+
134
+ return false
135
+ }
136
+
137
+ /**
138
+ * Safely extracts hostname without port or IPv6 brackets from Host header or authority string.
139
+ */
140
+ export function extractHostName(hostHeader) {
141
+ if (!hostHeader || typeof hostHeader !== 'string') return ''
142
+ const trimmed = hostHeader.trim()
143
+ try {
144
+ const url = new URL(`http://${trimmed}`)
145
+ return url.hostname.replace(/^\[|\]$/g, '')
146
+ } catch {
147
+ return trimmed.replace(/^\[|\]$/g, '').split(':')[0] || ''
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Helper to get header value in case-insensitive manner.
153
+ */
154
+ function getHeader(req, name) {
155
+ if (!req?.headers) return undefined
156
+ const direct = req.headers[name]
157
+ if (direct !== undefined) return Array.isArray(direct) ? direct[0] : direct
158
+ const lower = req.headers[name.toLowerCase()]
159
+ return Array.isArray(lower) ? lower[0] : lower
160
+ }
161
+
162
+ /**
163
+ * Verifies that an incoming HTTP request originates from and targets a trusted local or private LAN authority.
164
+ * Guards against DNS rebinding, cross-site leaks, and unauthorized external probing.
165
+ *
166
+ * Rules:
167
+ * 1. Client remote address (if present on socket) must be loopback or private LAN.
168
+ * 2. Sec-Fetch-Site (if present) must NOT be 'cross-site'.
169
+ * 3. Host header must be present and its hostname must be loopback or private LAN.
170
+ * 4. Origin header (if present) must have valid http/https protocol, its hostname must be loopback/LAN,
171
+ * and its host authority must match the request's Host header.
172
+ * 5. Referer header (if present) must have valid http/https protocol and its hostname must be loopback/LAN.
173
+ */
174
+ export function isTrustedLocalRequest(req) {
175
+ if (!req) return false
176
+
177
+ const remote = req.socket?.remoteAddress
178
+ if (remote && !isLoopbackAddress(remote) && !isPrivateLanAddress(remote)) {
179
+ return false
180
+ }
181
+
182
+ const site = getHeader(req, 'sec-fetch-site')
183
+ if (site === 'cross-site') {
184
+ return false
185
+ }
186
+
187
+ const host = getHeader(req, 'host')
188
+ if (!host || typeof host !== 'string') {
189
+ return false
190
+ }
191
+ const hostName = extractHostName(host)
192
+ if (!isLoopbackAddress(hostName) && !isPrivateLanAddress(hostName)) {
193
+ return false
194
+ }
195
+
196
+ const origin = getHeader(req, 'origin')
197
+ if (origin !== undefined) {
198
+ if (typeof origin !== 'string' || origin === '' || origin === 'null') {
199
+ return false
200
+ }
201
+ try {
202
+ const url = new URL(origin)
203
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
204
+ return false
205
+ }
206
+ const originHostName = url.hostname.replace(/^\[|\]$/g, '')
207
+ if (!isLoopbackAddress(originHostName) && !isPrivateLanAddress(originHostName)) {
208
+ return false
209
+ }
210
+ if (url.host.toLowerCase() !== host.toLowerCase()) {
211
+ return false
212
+ }
213
+ } catch {
214
+ return false
215
+ }
216
+ }
217
+
218
+ const referer = getHeader(req, 'referer')
219
+ if (referer !== undefined && typeof referer === 'string' && referer !== '') {
220
+ try {
221
+ const url = new URL(referer)
222
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
223
+ return false
224
+ }
225
+ const refererHostName = url.hostname.replace(/^\[|\]$/g, '')
226
+ if (!isLoopbackAddress(refererHostName) && !isPrivateLanAddress(refererHostName)) {
227
+ return false
228
+ }
229
+ } catch {
230
+ return false
231
+ }
232
+ }
233
+
234
+ return true
235
+ }
package/lib/updater.js CHANGED
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises'
4
4
  import { homedir } from 'node:os'
5
5
  import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import { isLoopbackAddress, isPrivateLanAddress, extractHostName } from './security.js'
7
8
 
8
9
  /**
9
10
  * Host-side one-click updater for @goodandready/dsh-image-gen.
@@ -21,31 +22,17 @@ function header(request, name) {
21
22
  }
22
23
 
23
24
  function isLoopback(value) {
24
- const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
25
- return (
26
- address === 'localhost' ||
27
- address === 'localhost.' ||
28
- address === '::1' ||
29
- address?.startsWith('127.') === true ||
30
- address?.startsWith('::ffff:127.') === true
31
- )
25
+ return isLoopbackAddress(value)
32
26
  }
33
27
 
34
28
  function isPrivateLan(value) {
35
- const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
36
- if (!address) return false
37
- const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
38
- return (
39
- ipv4.startsWith('192.168.') ||
40
- ipv4.startsWith('10.') ||
41
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
42
- )
29
+ return isPrivateLanAddress(value)
43
30
  }
44
31
 
45
32
  export function isTrustedUpdateRequest(request) {
46
33
  if (header(request, UPDATE_HEADER) !== '1') return false
47
34
  const remote = request.socket?.remoteAddress
48
- if (!isLoopback(remote) && !isPrivateLan(remote)) return false
35
+ if (!isLoopbackAddress(remote) && !isPrivateLanAddress(remote)) return false
49
36
  const site = header(request, 'sec-fetch-site')
50
37
  if (site !== undefined && site !== 'same-origin') return false
51
38
  const origin = header(request, 'origin')
@@ -53,10 +40,13 @@ export function isTrustedUpdateRequest(request) {
53
40
  if (origin === undefined || host === undefined) return false
54
41
  try {
55
42
  const url = new URL(origin)
43
+ const originHostName = extractHostName(url.host)
44
+ const hostName = extractHostName(host)
56
45
  return (
57
46
  (url.protocol === 'http:' || url.protocol === 'https:') &&
58
- (isLoopback(url.hostname) || isPrivateLan(url.hostname)) &&
59
- url.host === host
47
+ (isLoopbackAddress(originHostName) || isPrivateLanAddress(originHostName)) &&
48
+ (isLoopbackAddress(hostName) || isPrivateLanAddress(hostName)) &&
49
+ url.host.toLowerCase() === host.toLowerCase()
60
50
  )
61
51
  } catch {
62
52
  return false
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.10.30",
4
- "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers \u2014 the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
3
+ "version": "0.10.31",
4
+ "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",