@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 +23 -5
- package/lib/security.js +166 -0
- package/lib/updater.js +9 -19
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
(
|
|
59
|
-
|
|
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.
|
|
4
|
-
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers
|
|
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",
|