@goodandready/dsh-clinebot 0.5.32 → 0.5.33
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/CHANGELOG.md +9 -0
- package/lib/proxy-token.js +59 -12
- package/lib/routes/proxy.js +8 -27
- package/lib/updater.js +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.33] - 2026-10-19
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- **Proxy Token Isolation from Environment & Child Processes (Issue #247)**:
|
|
12
|
+
- Local proxy bearer authentication token (`CLINEBOT_LOCAL_PROXY_TOKEN`) is now retained strictly in module-private memory and never exported to `process.env`.
|
|
13
|
+
- Added startup and mutation scrubbing to ensure `process.env[CLINEBOT_LOCAL_PROXY_TOKEN]` is deleted immediately.
|
|
14
|
+
- Sanitized child process spawn environment in `lib/updater.js` to ensure the token cannot be inherited by sub-processes.
|
|
15
|
+
- Enforced unified DSH connection authorization gate (`connection.requestRejection`) across `/dsh-clinebot/v1/chat/completions` and `/dsh-clinebot/v1/models` endpoints.
|
|
16
|
+
|
|
8
17
|
## [0.5.32] - 2026-10-19
|
|
9
18
|
|
|
10
19
|
### Security
|
package/lib/proxy-token.js
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local Proxy Authentication Token (
|
|
2
|
+
* Local Proxy Authentication Token (Issues #145, #247)
|
|
3
3
|
*
|
|
4
4
|
* Secures transparent proxy endpoints (/dsh-clinebot/v1/*) from unauthorized
|
|
5
5
|
* LAN clients or cross-site browser requests (CSRF).
|
|
6
|
+
* Token is stored strictly in-memory and isolated from process.env and child processes (#247).
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import crypto from 'node:crypto'
|
|
9
10
|
import { createRequire } from 'node:module'
|
|
10
|
-
import { writeJson } from './http.js'
|
|
11
|
+
import { writeJson, isLoopbackAddress, getConnection } from './http.js'
|
|
12
|
+
import { resolveAccountPool } from './account-pool.js'
|
|
11
13
|
|
|
12
14
|
export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
|
|
13
15
|
|
|
14
16
|
let cachedToken = null
|
|
15
17
|
|
|
18
|
+
// At module startup, scrub any token from process.env if present (#247)
|
|
19
|
+
if (process.env[LOCAL_PROXY_KEY_ENV]) {
|
|
20
|
+
cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
|
|
21
|
+
delete process.env[LOCAL_PROXY_KEY_ENV]
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
const require = createRequire(import.meta.url)
|
|
17
|
-
let packageVersion = '0.5.
|
|
25
|
+
let packageVersion = '0.5.33'
|
|
18
26
|
try {
|
|
19
|
-
packageVersion = require('../package.json').version || '0.5.
|
|
27
|
+
packageVersion = require('../package.json').version || '0.5.33'
|
|
20
28
|
} catch (err) {
|
|
21
29
|
/* fallback to default version if manifest is unreadable */
|
|
22
30
|
}
|
|
@@ -25,6 +33,7 @@ export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
|
|
|
25
33
|
|
|
26
34
|
/**
|
|
27
35
|
* Get or generate the local proxy bearer token.
|
|
36
|
+
* Token is stored strictly in module-private memory and never exported to process.env (#247).
|
|
28
37
|
*
|
|
29
38
|
* @returns {string}
|
|
30
39
|
*/
|
|
@@ -32,25 +41,22 @@ export function getLocalProxyToken() {
|
|
|
32
41
|
if (cachedToken) return cachedToken
|
|
33
42
|
if (process.env[LOCAL_PROXY_KEY_ENV]) {
|
|
34
43
|
cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
|
|
44
|
+
delete process.env[LOCAL_PROXY_KEY_ENV]
|
|
35
45
|
return cachedToken
|
|
36
46
|
}
|
|
37
47
|
cachedToken = crypto.randomBytes(24).toString('hex')
|
|
38
|
-
process.env[LOCAL_PROXY_KEY_ENV] = cachedToken
|
|
39
48
|
return cachedToken
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* Manually set local proxy token (for testing).
|
|
53
|
+
* Never leaks token to process.env (#247).
|
|
44
54
|
*
|
|
45
55
|
* @param {string|null} token
|
|
46
56
|
*/
|
|
47
57
|
export function setLocalProxyToken(token) {
|
|
48
58
|
cachedToken = token
|
|
49
|
-
|
|
50
|
-
process.env[LOCAL_PROXY_KEY_ENV] = token
|
|
51
|
-
} else {
|
|
52
|
-
delete process.env[LOCAL_PROXY_KEY_ENV]
|
|
53
|
-
}
|
|
59
|
+
delete process.env[LOCAL_PROXY_KEY_ENV]
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
/**
|
|
@@ -75,6 +81,48 @@ export function isAuthorizedProxyRequest(req, accountPool = []) {
|
|
|
75
81
|
return Array.isArray(accountPool) && accountPool.some((acc) => acc.value && acc.value === token)
|
|
76
82
|
}
|
|
77
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Unified gate for proxy endpoints (/v1/chat/completions, /v1/models) (#247).
|
|
86
|
+
* Verifies loopback address, browser origin, DSH connection rejection, and bearer token.
|
|
87
|
+
*/
|
|
88
|
+
export async function assertAuthorizedProxyRequest(ctx, req, res, live) {
|
|
89
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) {
|
|
90
|
+
writeJson(res, 403, { ok: false, error: 'Loopback access only' })
|
|
91
|
+
return { ok: false }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (hasDisallowedBrowserOrigin(req)) {
|
|
95
|
+
writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
|
|
96
|
+
return { ok: false }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const conn = getConnection(ctx) || req?.connectionService
|
|
100
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
101
|
+
let rejection
|
|
102
|
+
try {
|
|
103
|
+
rejection = conn.requestRejection(req)
|
|
104
|
+
} catch {
|
|
105
|
+
rejection = 403
|
|
106
|
+
}
|
|
107
|
+
if (rejection !== undefined) {
|
|
108
|
+
const status = typeof rejection === 'number' ? rejection : 403
|
|
109
|
+
writeJson(res, status, {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: status === 401 ? 'Authentication required' : 'Forbidden',
|
|
112
|
+
})
|
|
113
|
+
return { ok: false }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const pool = await resolveAccountPool(ctx, typeof live === 'function' ? live() : live)
|
|
118
|
+
if (!isAuthorizedProxyRequest(req, pool)) {
|
|
119
|
+
writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
|
|
120
|
+
return { ok: false }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { ok: true, pool }
|
|
124
|
+
}
|
|
125
|
+
|
|
78
126
|
/**
|
|
79
127
|
* Write appropriate terminal JSON error response when retries are exhausted.
|
|
80
128
|
*/
|
|
@@ -119,7 +167,6 @@ export function sendProxyTerminalError(res, { hadTimeout, is429Exhausted, lastEr
|
|
|
119
167
|
})
|
|
120
168
|
}
|
|
121
169
|
|
|
122
|
-
|
|
123
170
|
/**
|
|
124
171
|
* Safely extract standard and reasoning token counts from usage object.
|
|
125
172
|
*/
|
|
@@ -137,4 +184,4 @@ export function extractUsageMetrics(usage) {
|
|
|
137
184
|
)
|
|
138
185
|
const totalTokens = Number(usage.total_tokens) || (promptTokens + completionTokens)
|
|
139
186
|
return { promptTokens, completionTokens, reasoningTokens, totalTokens }
|
|
140
|
-
}
|
|
187
|
+
}
|
package/lib/routes/proxy.js
CHANGED
|
@@ -15,19 +15,19 @@
|
|
|
15
15
|
* 7. Streaming SSE token usage interception & persistent telemetry recording.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { writeJson, readBody
|
|
18
|
+
import { writeJson, readBody } from '../http.js'
|
|
19
19
|
import { publicConfig, plainConfig } from '../config.js'
|
|
20
|
-
import { resolveAccountPool } from '../account-pool.js'
|
|
21
20
|
import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
|
|
22
21
|
import { recordUsage } from '../stats-storage.js'
|
|
23
22
|
import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
|
|
24
23
|
import { optimizePromptCacheAndBudget } from '../multimodal-budget.js'
|
|
25
24
|
import {
|
|
26
25
|
getLocalProxyToken, hasDisallowedBrowserOrigin, isAuthorizedProxyRequest,
|
|
26
|
+
assertAuthorizedProxyRequest,
|
|
27
27
|
USER_AGENT, sendProxyTerminalError, extractUsageMetrics
|
|
28
28
|
} from '../proxy-token.js'
|
|
29
29
|
|
|
30
|
-
export { hasDisallowedBrowserOrigin, isAuthorizedProxyRequest, USER_AGENT }
|
|
30
|
+
export { hasDisallowedBrowserOrigin, isAuthorizedProxyRequest, assertAuthorizedProxyRequest, USER_AGENT }
|
|
31
31
|
|
|
32
32
|
export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
33
33
|
return async (req, res) => {
|
|
@@ -35,18 +35,9 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
|
|
|
35
35
|
return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (hasDisallowedBrowserOrigin(req)) {
|
|
43
|
-
return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const pool = await resolveAccountPool(ctx, live())
|
|
47
|
-
if (!isAuthorizedProxyRequest(req, pool)) {
|
|
48
|
-
return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
|
|
49
|
-
}
|
|
38
|
+
const auth = await assertAuthorizedProxyRequest(ctx, req, res, live)
|
|
39
|
+
if (!auth.ok) return
|
|
40
|
+
const pool = auth.pool
|
|
50
41
|
|
|
51
42
|
let bodyBuf
|
|
52
43
|
try {
|
|
@@ -543,18 +534,8 @@ export function createModelsHandler(ctx, { live }) {
|
|
|
543
534
|
return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
544
535
|
}
|
|
545
536
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
if (hasDisallowedBrowserOrigin(req)) {
|
|
551
|
-
return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const pool = await resolveAccountPool(ctx, live())
|
|
555
|
-
if (!isAuthorizedProxyRequest(req, pool)) {
|
|
556
|
-
return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
|
|
557
|
-
}
|
|
537
|
+
const auth = await assertAuthorizedProxyRequest(ctx, req, res, live)
|
|
538
|
+
if (!auth.ok) return
|
|
558
539
|
|
|
559
540
|
const pub = publicConfig(live())
|
|
560
541
|
const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
|
package/lib/updater.js
CHANGED
|
@@ -263,7 +263,11 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
263
263
|
cwd: target.profileDir,
|
|
264
264
|
windowsHide: true,
|
|
265
265
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
266
|
-
env:
|
|
266
|
+
env: (() => {
|
|
267
|
+
const childEnv = { ...process.env, NO_COLOR: '1' }
|
|
268
|
+
delete childEnv.CLINEBOT_LOCAL_PROXY_TOKEN
|
|
269
|
+
return childEnv
|
|
270
|
+
})(),
|
|
267
271
|
detached: options.detached !== undefined ? options.detached : process.platform !== 'win32',
|
|
268
272
|
})
|
|
269
273
|
let detail = ''
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.33",
|
|
4
4
|
"description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|