dsh-instruction-memory 1.4.6 → 1.4.8
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 +129 -0
- package/README.md +155 -19
- package/install.mjs +79 -10
- package/lib/client.js +169 -9
- package/lib/index.js +504 -35
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
* user-chosen backup directory (F18).
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
29
|
-
import { readFileSync, watch } from 'node:fs'
|
|
28
|
+
import { chmod, copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
29
|
+
import { readFileSync, realpathSync, statSync, watch } from 'node:fs'
|
|
30
30
|
import { randomUUID } from 'node:crypto'
|
|
31
31
|
import { isIP } from 'node:net'
|
|
32
32
|
import { homedir, hostname } from 'node:os'
|
|
33
|
-
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
33
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
34
34
|
import { fileURLToPath } from 'node:url'
|
|
35
35
|
import {
|
|
36
36
|
FEEDBACK_ENV,
|
|
@@ -148,6 +148,35 @@ const BACKUP_FILE_SUFFIX = '.bak'
|
|
|
148
148
|
*/
|
|
149
149
|
const SECTION_ORDER = 950
|
|
150
150
|
const API_PREFIX = '/instruction-memory/api'
|
|
151
|
+
/**
|
|
152
|
+
* Extra hostnames this route accepts, comma separated (F19).
|
|
153
|
+
*
|
|
154
|
+
* A loopback bind normally only answers to `127.0.0.1`/`localhost`, which is
|
|
155
|
+
* what makes the DNS-rebinding guard meaningful — but it also made the route
|
|
156
|
+
* unusable behind a reverse proxy that forwards the public `Host` (nginx's
|
|
157
|
+
* default `proxy_set_header Host $host`). Listing the published hostname here
|
|
158
|
+
* re-opens exactly that one name and nothing else: the loopback rule still
|
|
159
|
+
* applies to everything else, and `Origin`/`Sec-Fetch-Site` are still enforced.
|
|
160
|
+
*/
|
|
161
|
+
const TRUSTED_HOSTS_ENV = 'DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS'
|
|
162
|
+
/**
|
|
163
|
+
* Escape hatch for F18 backups outside `$DSH_HOME` (F19). The default refuses
|
|
164
|
+
* them: an unauthenticated route that can create directories anywhere the host
|
|
165
|
+
* process can write is a disk-fill primitive. Setting this to `1` restores the
|
|
166
|
+
* old permissive behaviour for operators who really keep backups on another
|
|
167
|
+
* volume.
|
|
168
|
+
*/
|
|
169
|
+
const ALLOW_EXTERNAL_BACKUP_ENV = 'DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP'
|
|
170
|
+
/**
|
|
171
|
+
* How often the store file is stat-ed as a fallback for `fs.watch` (F19).
|
|
172
|
+
*
|
|
173
|
+
* Inotify/FSEvents/ReadDirectoryChangesW are reliable on a native filesystem
|
|
174
|
+
* and silently dead on DrvFs (`/mnt/c`, `/mnt/d`), SMB and some container
|
|
175
|
+
* overlays — there the watcher installs without error and then never fires, so
|
|
176
|
+
* an external edit to memory.json was simply invisible until a restart. A
|
|
177
|
+
* 5 s stat of one path costs nothing and turns that silence into a reload.
|
|
178
|
+
*/
|
|
179
|
+
const WATCH_POLL_MS = 5000
|
|
151
180
|
/**
|
|
152
181
|
* Feedback mail: the optional config file next to the store. Kept OUT of
|
|
153
182
|
* the repository on purpose — the operator's SMTP credentials must never be
|
|
@@ -175,13 +204,19 @@ const PLUGIN_VERSION = (() => {
|
|
|
175
204
|
})()
|
|
176
205
|
|
|
177
206
|
/**
|
|
178
|
-
*
|
|
207
|
+
* The harness home: the directory this plugin's data lives under.
|
|
179
208
|
*
|
|
180
209
|
* Precedence mirrors `@deepseek-ai/dsh-home-paths`: an explicit `$DSH_HOME`,
|
|
181
210
|
* then `~/.dsh`. A blank override counts as unset and must never resolve to the
|
|
182
211
|
* current working directory. Exported so tests can pin it.
|
|
212
|
+
*
|
|
213
|
+
* Resolved here rather than derived from the store path. `dirname(dirname(
|
|
214
|
+
* storePath))` happens to equal this today, but it is a guess about the layout:
|
|
215
|
+
* one extra directory level (or a store moved up a level) would quietly widen
|
|
216
|
+
* the "may I write outside the harness home" fence to the harness home's
|
|
217
|
+
* PARENT. A guard that guesses its own root is not a guard.
|
|
183
218
|
*/
|
|
184
|
-
export function
|
|
219
|
+
export function resolveDshRoot(env = process.env, home = homedir()) {
|
|
185
220
|
const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
|
|
186
221
|
// A relative override (`DSH_HOME=./data`) is resolved ONCE against the
|
|
187
222
|
// process cwd. Without this the store path stayed relative for the whole
|
|
@@ -190,8 +225,12 @@ export function resolveStorePath(env = process.env, home = homedir()) {
|
|
|
190
225
|
// how the path was spelled. Resolving does not make a relative override
|
|
191
226
|
// cwd-independent — it cannot be — but it pins one absolute location for
|
|
192
227
|
// this process and keeps every displayed path absolute.
|
|
193
|
-
|
|
194
|
-
|
|
228
|
+
return configured !== '' ? resolve(configured) : join(home, '.dsh')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Resolve this plugin's data file under the harness home. */
|
|
232
|
+
export function resolveStorePath(env = process.env, home = homedir()) {
|
|
233
|
+
return join(resolveDshRoot(env, home), STORE_DIR_NAME, STORE_FILE_NAME)
|
|
195
234
|
}
|
|
196
235
|
|
|
197
236
|
/**
|
|
@@ -1163,7 +1202,51 @@ export function isWildcardBindHost(value) {
|
|
|
1163
1202
|
* `Host: attacker.example` plus `Origin: attacker.example`. Wildcard binds keep
|
|
1164
1203
|
* the old comparison, because the address the client used is unknowable then.
|
|
1165
1204
|
*/
|
|
1166
|
-
|
|
1205
|
+
/**
|
|
1206
|
+
* Parse the trusted-host list into canonical hostnames.
|
|
1207
|
+
*
|
|
1208
|
+
* Accepts what an operator actually types: a bare `app.example`, a full
|
|
1209
|
+
* origin (`https://app.example:8443`), bracketed IPv6, comma or whitespace
|
|
1210
|
+
* separated. Everything is folded to the same lowercase, bracket-free,
|
|
1211
|
+
* port-free form `hostnameOf()`/`normalizeHostname()` produce, so a trusted
|
|
1212
|
+
* entry compares equal to a parsed `Host` or `Origin` without further work.
|
|
1213
|
+
*
|
|
1214
|
+
* @param {string|string[]} value raw environment value or an array.
|
|
1215
|
+
* @returns {string[]} canonical hostnames, deduplicated, order preserved.
|
|
1216
|
+
*/
|
|
1217
|
+
export function normalizeTrustedHosts(value) {
|
|
1218
|
+
const raw = Array.isArray(value) ? value.join(',') : typeof value === 'string' ? value : ''
|
|
1219
|
+
const out = []
|
|
1220
|
+
for (const piece of raw.split(',')) {
|
|
1221
|
+
let text = piece.trim()
|
|
1222
|
+
if (text === '') continue
|
|
1223
|
+
// A full origin is common in copy-pasted docs; keep only its host.
|
|
1224
|
+
const scheme = text.indexOf('://')
|
|
1225
|
+
if (scheme !== -1) {
|
|
1226
|
+
try {
|
|
1227
|
+
text = new URL(text).hostname
|
|
1228
|
+
} catch {
|
|
1229
|
+
text = text.slice(scheme + 3)
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
text = text.replace(/^\/+/, '')
|
|
1233
|
+
const slash = text.indexOf('/')
|
|
1234
|
+
if (slash !== -1) text = text.slice(0, slash)
|
|
1235
|
+
// hostnameOf() already drops a port and the IPv6 brackets.
|
|
1236
|
+
const name = hostnameOf(text)
|
|
1237
|
+
if (name === '') continue
|
|
1238
|
+
if (!out.includes(name)) out.push(name)
|
|
1239
|
+
}
|
|
1240
|
+
return out
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/** Is this canonical hostname one the operator explicitly trusted? */
|
|
1244
|
+
function isTrustedHostName(name, trusted) {
|
|
1245
|
+
return name !== '' && Array.isArray(trusted) && trusted.includes(name)
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
export function requestOriginVerdict(headers, guarded, expectedHostname = '', trustedHosts = []) {
|
|
1249
|
+
const trusted = Array.isArray(trustedHosts) ? trustedHosts : []
|
|
1167
1250
|
const read = (name) => {
|
|
1168
1251
|
if (headers === null || typeof headers !== 'object') return ''
|
|
1169
1252
|
const value = headers[name]
|
|
@@ -1172,7 +1255,7 @@ export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
|
|
|
1172
1255
|
const requestHost = read('host').trim().toLowerCase()
|
|
1173
1256
|
const requestHostname = hostnameOf(requestHost)
|
|
1174
1257
|
|
|
1175
|
-
if (guarded && !isLoopbackName(requestHostname)) {
|
|
1258
|
+
if (guarded && !isLoopbackName(requestHostname) && !isTrustedHostName(requestHostname, trusted)) {
|
|
1176
1259
|
return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
|
|
1177
1260
|
}
|
|
1178
1261
|
|
|
@@ -1213,7 +1296,7 @@ export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
|
|
|
1213
1296
|
// is what keeps those spellings working (the mapped/hex forms canonicalise).
|
|
1214
1297
|
const expected = !guarded && typeof expectedHostname === 'string' ? expectedHostname : ''
|
|
1215
1298
|
const authority = expected !== '' ? expected : requestHostname
|
|
1216
|
-
if (originHostname === '' || !sameHostname(originHostname, authority)) {
|
|
1299
|
+
if (originHostname === '' || (!sameHostname(originHostname, authority) && !isTrustedHostName(originHostname, trusted))) {
|
|
1217
1300
|
return {
|
|
1218
1301
|
ok: false,
|
|
1219
1302
|
reason: expected !== ''
|
|
@@ -1688,6 +1771,13 @@ export function apply(ctx) {
|
|
|
1688
1771
|
* temporal-dead-zone hazard (F12 publishes it as `security.hostGuarded`).
|
|
1689
1772
|
*/
|
|
1690
1773
|
let guardHost = true
|
|
1774
|
+
/**
|
|
1775
|
+
* F19: hostnames the operator explicitly allowed (see TRUSTED_HOSTS_ENV).
|
|
1776
|
+
* Read once at mount from the environment; a restart is required to change it,
|
|
1777
|
+
* which keeps the allowlist out of the settings page (a route that can widen
|
|
1778
|
+
* its own trust boundary is not a boundary).
|
|
1779
|
+
*/
|
|
1780
|
+
const trustedHosts = normalizeTrustedHosts(process.env[TRUSTED_HOSTS_ENV])
|
|
1691
1781
|
/**
|
|
1692
1782
|
* The authority a browser had to use to reach this route, when the bind
|
|
1693
1783
|
* address is concrete (`192.168.1.5`, `[::1]`). Empty for a wildcard/unknown
|
|
@@ -1747,6 +1837,13 @@ export function apply(ctx) {
|
|
|
1747
1837
|
let text = describeError(err)
|
|
1748
1838
|
const storeDir = dirname(storePath)
|
|
1749
1839
|
if (storeDir !== '' && storeDir !== '.') text = text.split(storeDir).join('<记忆目录>')
|
|
1840
|
+
// F19: a custom backup location is just as revealing (it is usually a
|
|
1841
|
+
// cloud-sync path with an account name in it) and used to survive the fold
|
|
1842
|
+
// whenever it was not under the home directory.
|
|
1843
|
+
const configuredBackup = typeof state.data.backupDir === 'string' ? state.data.backupDir.trim() : ''
|
|
1844
|
+
if (configuredBackup !== '' && configuredBackup !== storeDir) {
|
|
1845
|
+
text = text.split(configuredBackup).join('<备份目录>')
|
|
1846
|
+
}
|
|
1750
1847
|
try {
|
|
1751
1848
|
const home = homedir()
|
|
1752
1849
|
// Same token as `displayPath` so a folded path looks identical wherever
|
|
@@ -1802,6 +1899,41 @@ export function apply(ctx) {
|
|
|
1802
1899
|
else console.error('[instruction-memory] ' + message)
|
|
1803
1900
|
}
|
|
1804
1901
|
|
|
1902
|
+
/**
|
|
1903
|
+
* Create (or repair) a directory this plugin owns and make sure it is 0700.
|
|
1904
|
+
*
|
|
1905
|
+
* `mkdir(..., {mode: 0o700})` only sets the mode on a directory it actually
|
|
1906
|
+
* creates: a store first written by 1.4.7 stays 0755 forever, because every
|
|
1907
|
+
* later start just resolves the path that already exists. The snapshots in
|
|
1908
|
+
* `history/` were therefore still listable by other local users after the
|
|
1909
|
+
* mode fix shipped, which the review called out as a real (if minor) gap.
|
|
1910
|
+
* The chmod is deliberately best effort: a directory owned by someone else,
|
|
1911
|
+
* or a filesystem without POSIX modes (Windows, some network mounts), must
|
|
1912
|
+
* not turn a save or a boot into a failure.
|
|
1913
|
+
*/
|
|
1914
|
+
const repairDirMode = async (dir) => {
|
|
1915
|
+
if (process.platform === 'win32') return
|
|
1916
|
+
try {
|
|
1917
|
+
const info = await stat(dir)
|
|
1918
|
+
if ((info.mode & 0o777) === 0o700) return
|
|
1919
|
+
await chmod(dir, 0o700)
|
|
1920
|
+
logInfo('tightened ' + displayPath(dir) + ' to 0700 (was ' + (info.mode & 0o777).toString(8) + ')')
|
|
1921
|
+
} catch {
|
|
1922
|
+
// Not our directory, or no POSIX modes here: the write itself matters more.
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Create the directory if needed and make sure it ends up 0700. Boot-time
|
|
1928
|
+
* repair deliberately uses `repairDirMode` alone: a first run must not create
|
|
1929
|
+
* the store directory just to look at it (pinned by the "booting does not
|
|
1930
|
+
* even create the store directory" contract).
|
|
1931
|
+
*/
|
|
1932
|
+
const hardenDir = async (dir) => {
|
|
1933
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
1934
|
+
await repairDirMode(dir)
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1805
1937
|
const logDebug = (message) => {
|
|
1806
1938
|
if (logger !== null) {
|
|
1807
1939
|
if (typeof logger.debug === 'function') logger.debug(message)
|
|
@@ -1849,7 +1981,14 @@ export function apply(ctx) {
|
|
|
1849
1981
|
*/
|
|
1850
1982
|
const assembledSectionText = () => {
|
|
1851
1983
|
sectionPulls += 1
|
|
1852
|
-
|
|
1984
|
+
// The host calls this while assembling the prompt and does NOT wrap it in
|
|
1985
|
+
// try/catch, so a throwing logger would abort the whole turn. Counting the
|
|
1986
|
+
// pull is diagnostics; it must never be the reason a prompt fails.
|
|
1987
|
+
try {
|
|
1988
|
+
logDebug('section pulled into a prompt (pull #' + sectionPulls + ')')
|
|
1989
|
+
} catch {
|
|
1990
|
+
// Diagnostics only.
|
|
1991
|
+
}
|
|
1853
1992
|
return sectionText()
|
|
1854
1993
|
}
|
|
1855
1994
|
|
|
@@ -1889,6 +2028,29 @@ export function apply(ctx) {
|
|
|
1889
2028
|
* when something must be injected, absent otherwise. An empty memory must
|
|
1890
2029
|
* leave the prompt byte-identical to a deployment without this plugin.
|
|
1891
2030
|
*/
|
|
2031
|
+
/**
|
|
2032
|
+
* Where the host wants this section to sit, asked of the host itself.
|
|
2033
|
+
*
|
|
2034
|
+
* `950` is the documented gap between FILE_REFERENCE (900) and TOOL_BASH
|
|
2035
|
+
* (1000) in `dsh-system-prompt`'s central `SECTION_ORDERS` table. Hardcoding
|
|
2036
|
+
* it works until that table grows an entry at 950 — then the placement is a
|
|
2037
|
+
* silent tie, decided by dictionary order, and nothing reports it. The
|
|
2038
|
+
* service exposes `getSectionOrder(name)`, so take the midpoint of the two
|
|
2039
|
+
* neighbours we actually anchor against and fall back to 950 if the method
|
|
2040
|
+
* is gone or answers something unusable.
|
|
2041
|
+
*/
|
|
2042
|
+
const resolveSectionOrder = () => {
|
|
2043
|
+
try {
|
|
2044
|
+
if (systemPrompt === null || typeof systemPrompt.getSectionOrder !== 'function') return SECTION_ORDER
|
|
2045
|
+
const lo = systemPrompt.getSectionOrder('FILE_REFERENCE')
|
|
2046
|
+
const hi = systemPrompt.getSectionOrder('TOOL_BASH')
|
|
2047
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return SECTION_ORDER
|
|
2048
|
+
return lo + (hi - lo) / 2
|
|
2049
|
+
} catch {
|
|
2050
|
+
return SECTION_ORDER
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
1892
2054
|
const syncSection = () => {
|
|
1893
2055
|
if (disposed) return
|
|
1894
2056
|
const wanted = sectionText() !== ''
|
|
@@ -1902,15 +2064,16 @@ export function apply(ctx) {
|
|
|
1902
2064
|
// template or any `{{…}}` pair — so the default would abort the whole
|
|
1903
2065
|
// agent turn at renderPrompt(). This section is literal text and never
|
|
1904
2066
|
// references a host variable, so opt out explicitly.
|
|
2067
|
+
const order = resolveSectionOrder()
|
|
1905
2068
|
sectionDispose = systemPrompt.section({
|
|
1906
2069
|
name: SECTION_NAME,
|
|
1907
|
-
order
|
|
2070
|
+
order,
|
|
1908
2071
|
text: assembledSectionText,
|
|
1909
2072
|
interpolate: false,
|
|
1910
2073
|
})
|
|
1911
2074
|
state.sectionRegistered = true
|
|
1912
2075
|
state.sectionError = null
|
|
1913
|
-
logDebug('prompt section registered: order=' +
|
|
2076
|
+
logDebug('prompt section registered: order=' + order + ' chars=' + sectionText().length)
|
|
1914
2077
|
} catch (err) {
|
|
1915
2078
|
sectionDispose = null
|
|
1916
2079
|
state.sectionRegistered = false
|
|
@@ -1968,6 +2131,13 @@ export function apply(ctx) {
|
|
|
1968
2131
|
const sseStreams = new Map()
|
|
1969
2132
|
let storeWatcher = null
|
|
1970
2133
|
let watchDebounce = null
|
|
2134
|
+
/**
|
|
2135
|
+
* Fallback poller for filesystems where `fs.watch` installs but never fires
|
|
2136
|
+
* (DrvFs `/mnt/*`, SMB/NFS, some overlays). Null when not running.
|
|
2137
|
+
*/
|
|
2138
|
+
let storePoll = null
|
|
2139
|
+
/** Last `size:mtime:ino` signature the poller saw, so it only reacts to a change. */
|
|
2140
|
+
let storeSignature = ''
|
|
1971
2141
|
|
|
1972
2142
|
const pushSnapshot = () => {
|
|
1973
2143
|
if (disposed || sseStreams.size === 0) return
|
|
@@ -2042,11 +2212,51 @@ export function apply(ctx) {
|
|
|
2042
2212
|
}, WATCH_DEBOUNCE_MS)
|
|
2043
2213
|
}
|
|
2044
2214
|
|
|
2215
|
+
/** The stat signature a poller compares, or '' when the file is absent. */
|
|
2216
|
+
const currentStoreSignature = () => {
|
|
2217
|
+
try {
|
|
2218
|
+
const info = statSync(storePath)
|
|
2219
|
+
return String(info.size) + ':' + String(info.mtimeMs) + ':' + String(info.ino)
|
|
2220
|
+
} catch {
|
|
2221
|
+
return ''
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
/**
|
|
2226
|
+
* Start the fallback poller (idempotent).
|
|
2227
|
+
*
|
|
2228
|
+
* It is ALWAYS started alongside the watcher, not only after a failure: an
|
|
2229
|
+
* `fs.watch` that never fires cannot be distinguished from a quiet store, so
|
|
2230
|
+
* there is no event to trigger a downgrade. On a native filesystem the poller
|
|
2231
|
+
* finds the same signature the watcher just acted on and does nothing —
|
|
2232
|
+
* `onStoreFileEvent` already drops no-op reloads by comparing content.
|
|
2233
|
+
*/
|
|
2234
|
+
const startPolling = () => {
|
|
2235
|
+
if (disposed || storePoll !== null) return
|
|
2236
|
+
storeSignature = currentStoreSignature()
|
|
2237
|
+
storePoll = setInterval(() => {
|
|
2238
|
+
if (disposed) return
|
|
2239
|
+
const signature = currentStoreSignature()
|
|
2240
|
+
if (signature === storeSignature) return
|
|
2241
|
+
storeSignature = signature
|
|
2242
|
+
// A vanished file is not an edit; the next save recreates it.
|
|
2243
|
+
if (signature === '') return
|
|
2244
|
+
onStoreFileEvent()
|
|
2245
|
+
}, WATCH_POLL_MS)
|
|
2246
|
+
// A background timer must never be a reason for the host process to stay
|
|
2247
|
+
// alive after the plugin is unmounted.
|
|
2248
|
+
if (typeof storePoll.unref === 'function') storePoll.unref()
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2045
2251
|
const stopWatching = () => {
|
|
2046
2252
|
if (watchDebounce !== null) {
|
|
2047
2253
|
clearTimeout(watchDebounce)
|
|
2048
2254
|
watchDebounce = null
|
|
2049
2255
|
}
|
|
2256
|
+
if (storePoll !== null) {
|
|
2257
|
+
clearInterval(storePoll)
|
|
2258
|
+
storePoll = null
|
|
2259
|
+
}
|
|
2050
2260
|
if (storeWatcher !== null) {
|
|
2051
2261
|
const watcher = storeWatcher
|
|
2052
2262
|
storeWatcher = null
|
|
@@ -2075,10 +2285,20 @@ export function apply(ctx) {
|
|
|
2075
2285
|
if (typeof filename === 'string' && filename !== basename(storePath)) return
|
|
2076
2286
|
onStoreFileEvent()
|
|
2077
2287
|
})
|
|
2078
|
-
storeWatcher.on('error', () =>
|
|
2288
|
+
storeWatcher.on('error', (err) => {
|
|
2289
|
+
// Losing the OS watcher is survivable (the poller below still notices
|
|
2290
|
+
// an external edit) but it must not happen silently: the downgrade is
|
|
2291
|
+
// invisible otherwise.
|
|
2292
|
+
logInfo('store watcher failed, falling back to the 5s poller: ' + describeError(err))
|
|
2293
|
+
stopWatching()
|
|
2294
|
+
})
|
|
2079
2295
|
} catch {
|
|
2080
2296
|
storeWatcher = null
|
|
2081
2297
|
}
|
|
2298
|
+
// Started whether or not the watcher attached: on a filesystem where the
|
|
2299
|
+
// watch handle exists but never fires this is the ONLY reload signal, and
|
|
2300
|
+
// on a healthy one it is a cheap no-op.
|
|
2301
|
+
startPolling()
|
|
2082
2302
|
}
|
|
2083
2303
|
|
|
2084
2304
|
const disposeStreams = () => {
|
|
@@ -2117,6 +2337,64 @@ export function apply(ctx) {
|
|
|
2117
2337
|
/* ---------------- backup location + history snapshots (F14 / F18) ---------------- */
|
|
2118
2338
|
|
|
2119
2339
|
const backupDirOf = () => (typeof state.data.backupDir === 'string' ? state.data.backupDir : '')
|
|
2340
|
+
/** The harness home this plugin's data lives under (`$DSH_HOME` or `~/.dsh`). */
|
|
2341
|
+
const dshRootPath = () => resolveDshRoot()
|
|
2342
|
+
/**
|
|
2343
|
+
* F19: are we allowed to write outside `$DSH_HOME`?
|
|
2344
|
+
*
|
|
2345
|
+
* The settings route can be reached without a login on profiles with no
|
|
2346
|
+
* `connection` service, and `backupDir` used to accept ANY absolute path (it
|
|
2347
|
+
* was probe-written on set and then created on every save). That is a
|
|
2348
|
+
* disk-fill primitive, so the default is now "inside the harness home only";
|
|
2349
|
+
* an operator who really keeps backups on another volume opts in explicitly.
|
|
2350
|
+
*/
|
|
2351
|
+
const allowExternalBackup = () => {
|
|
2352
|
+
const raw = process.env[ALLOW_EXTERNAL_BACKUP_ENV]
|
|
2353
|
+
return typeof raw === 'string' && raw.trim() === '1'
|
|
2354
|
+
}
|
|
2355
|
+
/** `path`'s real location, or null when it (or a parent) is unavailable. */
|
|
2356
|
+
const realPathOrNull = (path) => {
|
|
2357
|
+
try {
|
|
2358
|
+
return realpathSync(path)
|
|
2359
|
+
} catch {
|
|
2360
|
+
return null
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
/** Is `inner` the same as `root` or below it? */
|
|
2364
|
+
const containsPath = (root, inner) => {
|
|
2365
|
+
const rel = relative(root, inner)
|
|
2366
|
+
return rel === '' || (!rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel))
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Is `target` inside `$DSH_HOME` (equal to it counts)?
|
|
2370
|
+
*
|
|
2371
|
+
* Spelling alone is not containment. A junction or symlink INSIDE the
|
|
2372
|
+
* harness home can point anywhere — on Windows a junction needs no privilege
|
|
2373
|
+
* at all — so the lexical check answers "does this text start with the root"
|
|
2374
|
+
* while the write lands in another volume. Both ends are therefore compared
|
|
2375
|
+
* by real path; a target that does not exist yet is judged by the real path
|
|
2376
|
+
* of its deepest existing ancestor plus the part still to be created.
|
|
2377
|
+
*/
|
|
2378
|
+
const isInsideDshRoot = (target) => {
|
|
2379
|
+
if (typeof target !== 'string' || target === '') return false
|
|
2380
|
+
const root = dshRootPath()
|
|
2381
|
+
const absolute = resolve(target)
|
|
2382
|
+
if (!containsPath(root, absolute)) return false
|
|
2383
|
+
const realRoot = realPathOrNull(root)
|
|
2384
|
+
if (realRoot === null) return true
|
|
2385
|
+
let existing = absolute
|
|
2386
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
2387
|
+
const realExisting = realPathOrNull(existing)
|
|
2388
|
+
if (realExisting !== null) {
|
|
2389
|
+
const rest = absolute.slice(existing.length)
|
|
2390
|
+
return containsPath(realRoot, rest === '' ? realExisting : join(realExisting, rest))
|
|
2391
|
+
}
|
|
2392
|
+
const parent = dirname(existing)
|
|
2393
|
+
if (parent === existing) return false
|
|
2394
|
+
existing = parent
|
|
2395
|
+
}
|
|
2396
|
+
return false
|
|
2397
|
+
}
|
|
2120
2398
|
/** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
|
|
2121
2399
|
const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
|
|
2122
2400
|
/** The user-facing copy under a custom backup location, or null (F18). */
|
|
@@ -2339,7 +2617,7 @@ export function apply(ctx) {
|
|
|
2339
2617
|
if (typeof text !== 'string' || text === '' || identity === lastHistoryKey) return
|
|
2340
2618
|
try {
|
|
2341
2619
|
const dir = historyDirPath()
|
|
2342
|
-
await
|
|
2620
|
+
await hardenDir(dir)
|
|
2343
2621
|
const epoch = Date.now()
|
|
2344
2622
|
let written = false
|
|
2345
2623
|
// `wx` never overwrites: same-millisecond saves (two rapid bulk actions)
|
|
@@ -2413,7 +2691,7 @@ export function apply(ctx) {
|
|
|
2413
2691
|
let lock = null
|
|
2414
2692
|
let previousRev = null
|
|
2415
2693
|
try {
|
|
2416
|
-
await
|
|
2694
|
+
await hardenDir(dirname(storePath))
|
|
2417
2695
|
lock = await acquireStoreLock()
|
|
2418
2696
|
if (lock === null) {
|
|
2419
2697
|
// The lock could not be taken within the wait. That is either a stale
|
|
@@ -2845,6 +3123,11 @@ export function apply(ctx) {
|
|
|
2845
3123
|
})
|
|
2846
3124
|
.then(async () => {
|
|
2847
3125
|
bootSettled = true
|
|
3126
|
+
// Upgrade path: directories created by an older version keep their old
|
|
3127
|
+
// mode because `mkdir` never re-applies it to an existing path. Repair
|
|
3128
|
+
// them on every start; a failure here is a warning, never a boot error.
|
|
3129
|
+
await repairDirMode(dirname(storePath))
|
|
3130
|
+
await repairDirMode(historyDirPath())
|
|
2848
3131
|
syncSection()
|
|
2849
3132
|
await refreshHistoryCount()
|
|
2850
3133
|
// The one always-on line: it answers "is memory mounted, where does it
|
|
@@ -2932,6 +3215,14 @@ export function apply(ctx) {
|
|
|
2932
3215
|
profiles: state.data.profiles.filter((profile) => belongsTo(profile, set.id)).length,
|
|
2933
3216
|
})),
|
|
2934
3217
|
},
|
|
3218
|
+
/**
|
|
3219
|
+
* The running plugin version. The panel renders it in the mount-failure
|
|
3220
|
+
* notice, which is the one place a user reads a number off the screen
|
|
3221
|
+
* when the settings page never appears — reading it from the store would
|
|
3222
|
+
* be wrong (the store survives upgrades) and the client cannot import
|
|
3223
|
+
* package.json.
|
|
3224
|
+
*/
|
|
3225
|
+
pluginVersion: PLUGIN_VERSION,
|
|
2935
3226
|
/**
|
|
2936
3227
|
* F12: the one security signal the panel was missing. `hostGuarded` is
|
|
2937
3228
|
* the SAME flag the route enforces on every request — false means the
|
|
@@ -2943,6 +3234,15 @@ export function apply(ctx) {
|
|
|
2943
3234
|
security: {
|
|
2944
3235
|
hostGuarded: guardHost === true,
|
|
2945
3236
|
boundHost: state.boundHost,
|
|
3237
|
+
/**
|
|
3238
|
+
* F19 follow-up: whether the harness authentication fence is attached
|
|
3239
|
+
* RIGHT NOW. `hostGuarded === false` on its own reads as "no
|
|
3240
|
+
* authentication at all", which is only true when this is false too:
|
|
3241
|
+
* with the fence attached the route still requires a logged-in browser
|
|
3242
|
+
* session (`connection.requestRejection`), and the panel must be able
|
|
3243
|
+
* to say which of the two situations it is looking at.
|
|
3244
|
+
*/
|
|
3245
|
+
authFence: connectionGate !== null,
|
|
2946
3246
|
},
|
|
2947
3247
|
/**
|
|
2948
3248
|
* F12: load-side diagnostics that used to be log-only. `loadedFrom` says
|
|
@@ -3280,6 +3580,18 @@ export function apply(ctx) {
|
|
|
3280
3580
|
snapshot: snapshot(),
|
|
3281
3581
|
}
|
|
3282
3582
|
}
|
|
3583
|
+
// F19: an unauthenticated caller must not be able to create
|
|
3584
|
+
// directories outside the harness home. Checked BEFORE the probe, so a
|
|
3585
|
+
// refused path is never touched at all.
|
|
3586
|
+
if (!allowExternalBackup() && !isInsideDshRoot(wanted)) {
|
|
3587
|
+
return {
|
|
3588
|
+
ok: false,
|
|
3589
|
+
saved: false,
|
|
3590
|
+
message: '备份位置必须位于 DSH 主目录内(' + dshRootPath() + ');要使用其他磁盘请设置环境变量 '
|
|
3591
|
+
+ ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
|
|
3592
|
+
snapshot: snapshot(),
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3283
3595
|
const problem = await probeBackupDir(wanted)
|
|
3284
3596
|
if (problem !== null) {
|
|
3285
3597
|
return { ok: false, saved: false, message: '备份位置不可用(' + problem + '):' + wanted, snapshot: snapshot() }
|
|
@@ -3868,6 +4180,11 @@ export function apply(ctx) {
|
|
|
3868
4180
|
snapshot: snapshot(),
|
|
3869
4181
|
}))
|
|
3870
4182
|
|
|
4183
|
+
/** F19: timestamp of the last export-to-dir, for the frequency guard. */
|
|
4184
|
+
let lastExportAt = 0
|
|
4185
|
+
/** F19: minimum spacing between export-to-dir writes. */
|
|
4186
|
+
const EXPORT_COOLDOWN_MS = 2000
|
|
4187
|
+
|
|
3871
4188
|
/**
|
|
3872
4189
|
* Write an export straight into the backup directory (F18) — the "land the
|
|
3873
4190
|
* backup in my cloud-synced folder" button. The default directory is the
|
|
@@ -3875,9 +4192,34 @@ export function apply(ctx) {
|
|
|
3875
4192
|
*/
|
|
3876
4193
|
const exportToDir = () => enqueue(async () => {
|
|
3877
4194
|
const dir = backupDirOf() !== '' ? backupDirOf() : dirname(storePath)
|
|
3878
|
-
|
|
4195
|
+
// F19: the same subtree rule the setting enforces, re-checked at write
|
|
4196
|
+
// time — a store hand-edited to point elsewhere must not become a way to
|
|
4197
|
+
// create arbitrary directories through this route either.
|
|
4198
|
+
if (!allowExternalBackup() && !isInsideDshRoot(dir)) {
|
|
4199
|
+
return {
|
|
4200
|
+
ok: false,
|
|
4201
|
+
saved: false,
|
|
4202
|
+
message: '导出目录必须位于 DSH 主目录内(' + dshRootPath() + ');要写入其他磁盘请设置环境变量 '
|
|
4203
|
+
+ ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
|
|
4204
|
+
snapshot: snapshot(),
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
// Each export writes a fixed-name JSON file of the whole store. Refusing a
|
|
4208
|
+
// second export inside the cooldown keeps a script from using the button
|
|
4209
|
+
// as a disk-fill loop.
|
|
4210
|
+
const now = Date.now()
|
|
4211
|
+
if (now - lastExportAt < EXPORT_COOLDOWN_MS) {
|
|
4212
|
+
return {
|
|
4213
|
+
ok: false,
|
|
4214
|
+
saved: false,
|
|
4215
|
+
message: '导出过于频繁,请 ' + Math.ceil((EXPORT_COOLDOWN_MS - (now - lastExportAt)) / 1000) + ' 秒后重试',
|
|
4216
|
+
snapshot: snapshot(),
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
lastExportAt = now
|
|
4220
|
+
const target = join(dir, exportFileName(now))
|
|
3879
4221
|
try {
|
|
3880
|
-
await
|
|
4222
|
+
await hardenDir(dir)
|
|
3881
4223
|
await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
3882
4224
|
} catch (err) {
|
|
3883
4225
|
return { ok: false, saved: false, message: '写入备份目录失败:' + describeErrorSafe(err), snapshot: snapshot() }
|
|
@@ -4287,6 +4629,16 @@ export function apply(ctx) {
|
|
|
4287
4629
|
if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
|
|
4288
4630
|
throw new Error('无法读取反馈配置文件 ' + path + ':' + describeErrorSafe(err))
|
|
4289
4631
|
}
|
|
4632
|
+
// This file can hold SMTP credentials. The plugin does not create it (the
|
|
4633
|
+
// operator does), so it cannot set its mode — it can only say something
|
|
4634
|
+
// when the mode is wrong, which is the part that used to be silent.
|
|
4635
|
+
if (process.platform !== 'win32') {
|
|
4636
|
+
const info = await stat(path).catch(() => null)
|
|
4637
|
+
if (info !== null && (info.mode & 0o077) !== 0) {
|
|
4638
|
+
logInfo('feedback config ' + displayPath(path) + ' is readable by group/others (mode '
|
|
4639
|
+
+ (info.mode & 0o777).toString(8) + '); it may hold SMTP credentials')
|
|
4640
|
+
}
|
|
4641
|
+
}
|
|
4290
4642
|
try {
|
|
4291
4643
|
return JSON.parse(text)
|
|
4292
4644
|
} catch (err) {
|
|
@@ -4504,6 +4856,21 @@ export function apply(ctx) {
|
|
|
4504
4856
|
// `security.hostGuarded`); only `routeDispose` lives here.
|
|
4505
4857
|
|
|
4506
4858
|
let routeDispose = null
|
|
4859
|
+
/**
|
|
4860
|
+
* F19: the composition's `connection` service, when the profile has one.
|
|
4861
|
+
*
|
|
4862
|
+
* It is the harness's single authentication fence: `requestRejection(req)`
|
|
4863
|
+
* applies the Host/Origin trust check AND requires the browser login cookie
|
|
4864
|
+
* (the same gate that makes `/api` answer 401 to an anonymous caller). This
|
|
4865
|
+
* route used to rely on the Origin guesswork below alone, which left it as
|
|
4866
|
+
* the only unauthenticated read/write endpoint on the instance — a stored
|
|
4867
|
+
* prompt-injection primitive for any local process.
|
|
4868
|
+
*
|
|
4869
|
+
* Read through a NON-BLOCKING child fiber for the same reason `webServer` is
|
|
4870
|
+
* (headless/tui profiles must still inject the prompt), and kept nullable:
|
|
4871
|
+
* when the service is absent the loopback-only rule below still applies.
|
|
4872
|
+
*/
|
|
4873
|
+
let connectionGate = null
|
|
4507
4874
|
|
|
4508
4875
|
/**
|
|
4509
4876
|
* Does this stream subscriber want lightweight frames?
|
|
@@ -4543,13 +4910,11 @@ export function apply(ctx) {
|
|
|
4543
4910
|
* `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
|
|
4544
4911
|
*/
|
|
4545
4912
|
const handleEventStream = (req, res) => {
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
return
|
|
4552
|
-
}
|
|
4913
|
+
// Same fence as the POST path: an EventSource carries the login cookie, so
|
|
4914
|
+
// an authenticated stream keeps working while an anonymous one is refused.
|
|
4915
|
+
const fence = rejectUntrustedCaller(req, res)
|
|
4916
|
+
if (fence === 'rejected') return
|
|
4917
|
+
if (originVerdictRefuses(req, res, fence)) return
|
|
4553
4918
|
// Every subscriber holds a Map entry, a `req.on('close')` listener and a
|
|
4554
4919
|
// live socket until it disconnects. A tab that is duplicated, a client that
|
|
4555
4920
|
// never closes, or a script that opens streams in a loop would otherwise
|
|
@@ -4609,7 +4974,94 @@ export function apply(ctx) {
|
|
|
4609
4974
|
}
|
|
4610
4975
|
}
|
|
4611
4976
|
|
|
4977
|
+
/**
|
|
4978
|
+
* F19: refuse a caller the harness has not authenticated.
|
|
4979
|
+
*
|
|
4980
|
+
* Order matters. The harness fence runs FIRST when it exists: it is the only
|
|
4981
|
+
* check that knows about the browser login cookie, and it also owns the
|
|
4982
|
+
* Host/Origin trust rules for every other route on the instance. The
|
|
4983
|
+
* hand-rolled Origin verdict below stays as the second line of defence (it
|
|
4984
|
+
* covers profiles that publish a web server without a `connection` service).
|
|
4985
|
+
*
|
|
4986
|
+
* When there is no fence at all, a NON-loopback bind fails closed: publishing
|
|
4987
|
+
* an unauthenticated endpoint that can rewrite the instructions injected into
|
|
4988
|
+
* every later turn is not a trade-off worth defaulting to. The loopback bind
|
|
4989
|
+
* keeps working, which is the documented deployment (and what the Android
|
|
4990
|
+
* build's route-guard patch assumes).
|
|
4991
|
+
*
|
|
4992
|
+
* @returns 'rejected' when this handler already answered the request (401,
|
|
4993
|
+
* 403 or 503 and the response is complete), 'gate' when the harness fence
|
|
4994
|
+
* accepted it (so the Host/Origin question is already settled and the
|
|
4995
|
+
* plugin's own Origin verdict must NOT run again), 'open' when there is no
|
|
4996
|
+
* fence and the caller still has to pass the local Origin rules.
|
|
4997
|
+
*/
|
|
4998
|
+
const rejectUntrustedCaller = (req, res) => {
|
|
4999
|
+
if (connectionGate !== null) {
|
|
5000
|
+
let rejection
|
|
5001
|
+
try {
|
|
5002
|
+
rejection = connectionGate.requestRejection(req)
|
|
5003
|
+
} catch (err) {
|
|
5004
|
+
// A throwing fence must not become an open door: treat it as 401.
|
|
5005
|
+
logInfo('connection fence threw, refusing the request: ' + describeError(err))
|
|
5006
|
+
rejection = 401
|
|
5007
|
+
}
|
|
5008
|
+
if (typeof rejection === 'number' && rejection >= 400) {
|
|
5009
|
+
logInfo('route refused an unauthenticated request (' + rejection + ')')
|
|
5010
|
+
res.writeHead(rejection, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
5011
|
+
res.end(JSON.stringify({
|
|
5012
|
+
ok: false,
|
|
5013
|
+
message: rejection === 403
|
|
5014
|
+
? '请求未被宿主信任(Host/Origin 校验失败)'
|
|
5015
|
+
: '需要登录 DSH 后的浏览器会话才能访问该路由',
|
|
5016
|
+
}))
|
|
5017
|
+
return 'rejected'
|
|
5018
|
+
}
|
|
5019
|
+
return 'gate'
|
|
5020
|
+
}
|
|
5021
|
+
if (!guardHost) {
|
|
5022
|
+
logInfo('route refused a request: non-loopback bind without an authentication fence')
|
|
5023
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
5024
|
+
res.end(JSON.stringify({
|
|
5025
|
+
ok: false,
|
|
5026
|
+
message: '该路由在非回环绑定下需要宿主的 connection 服务提供鉴权;当前 profile 没有该服务,已拒绝请求',
|
|
5027
|
+
}))
|
|
5028
|
+
return 'rejected'
|
|
5029
|
+
}
|
|
5030
|
+
return 'open'
|
|
5031
|
+
}
|
|
5032
|
+
|
|
5033
|
+
/**
|
|
5034
|
+
* Run the plugin's OWN Origin/Host verdict, but only when the harness fence
|
|
5035
|
+
* did not already answer the same question (F19 follow-up).
|
|
5036
|
+
*
|
|
5037
|
+
* Order: the fence runs first. When it accepts a request it has already
|
|
5038
|
+
* applied the host's Host / `Origin` / `Sec-Fetch-Site` rules AND verified
|
|
5039
|
+
* the browser login cookie (`dsh-client-connection`: `isTrustedApiRequest`
|
|
5040
|
+
* then `browserAuth`). It also knows the deployment's public name. This
|
|
5041
|
+
* plugin knows only its bind address, so re-judging `Origin` here produced a
|
|
5042
|
+
* false 403 for every legitimate reverse-proxy client: the proxy forwards
|
|
5043
|
+
* `Host: dsh.example.com`, the listener binds `192.168.1.5`, and the Origin
|
|
5044
|
+
* host could never equal the bind IP. The local verdict stays as the second
|
|
5045
|
+
* line of defence for profiles that publish a web server WITHOUT a
|
|
5046
|
+
* `connection` service — there it is the only fence there is.
|
|
5047
|
+
*
|
|
5048
|
+
* @returns true when the request was answered with 403 and must not continue.
|
|
5049
|
+
*/
|
|
5050
|
+
const originVerdictRefuses = (req, res, fence) => {
|
|
5051
|
+
if (fence === 'gate') return false
|
|
5052
|
+
const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname, trustedHosts)
|
|
5053
|
+
if (verdict.ok) return false
|
|
5054
|
+
logInfo('route rejected a request: ' + verdict.reason)
|
|
5055
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
5056
|
+
res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
|
|
5057
|
+
return true
|
|
5058
|
+
}
|
|
5059
|
+
|
|
4612
5060
|
const handler = (req, res) => {
|
|
5061
|
+
// Authentication before anything else: not the method, not the body, not
|
|
5062
|
+
// the size. A rejection must not leak which methods exist.
|
|
5063
|
+
const fence = rejectUntrustedCaller(req, res)
|
|
5064
|
+
if (fence === 'rejected') return
|
|
4613
5065
|
if (req.method === 'GET') {
|
|
4614
5066
|
const accept = String(
|
|
4615
5067
|
(req.headers !== null && typeof req.headers === 'object') ? (req.headers.accept || '') : '',
|
|
@@ -4649,16 +5101,9 @@ export function apply(ctx) {
|
|
|
4649
5101
|
return
|
|
4650
5102
|
}
|
|
4651
5103
|
}
|
|
4652
|
-
// Origin/Host guard, before anything else is read.
|
|
4653
|
-
//
|
|
4654
|
-
|
|
4655
|
-
const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname)
|
|
4656
|
-
if (!verdict.ok) {
|
|
4657
|
-
logInfo('route rejected a request: ' + verdict.reason)
|
|
4658
|
-
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
4659
|
-
res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
|
|
4660
|
-
return
|
|
4661
|
-
}
|
|
5104
|
+
// Origin/Host guard, before anything else is read. Skipped when the host
|
|
5105
|
+
// fence already accepted this request — see originVerdictRefuses().
|
|
5106
|
+
if (originVerdictRefuses(req, res, fence)) return
|
|
4662
5107
|
// The settings page always posts application/json. Enforcing it turns
|
|
4663
5108
|
// the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
|
|
4664
5109
|
// body) into a hard rule: a cross-site or malformed post now gets a
|
|
@@ -4736,6 +5181,30 @@ export function apply(ctx) {
|
|
|
4736
5181
|
// losing the prompt injection along with the settings page. The callback also
|
|
4737
5182
|
// fixes the original race properly — it runs whenever the service is
|
|
4738
5183
|
// published, not only if it happened to beat this row to the mount.
|
|
5184
|
+
// F19: take the harness authentication fence when the profile has one.
|
|
5185
|
+
// Same non-blocking shape as the web server below — a profile without a
|
|
5186
|
+
// browser never publishes `connection`, and the prompt injection must not
|
|
5187
|
+
// wait for one.
|
|
5188
|
+
ctx.inject(['connection'], (scope) => {
|
|
5189
|
+
const service = scope.connection
|
|
5190
|
+
if (service === undefined || service === null || typeof service.requestRejection !== 'function') {
|
|
5191
|
+
logDebug('no connection service; the route falls back to the loopback-only rule')
|
|
5192
|
+
return
|
|
5193
|
+
}
|
|
5194
|
+
connectionGate = service
|
|
5195
|
+
logDebug('connection fence attached; the route now requires an authenticated browser session')
|
|
5196
|
+
scope.effect(() => () => {
|
|
5197
|
+
if (connectionGate !== service) return
|
|
5198
|
+
connectionGate = null
|
|
5199
|
+
// A host that republishes (or disposes) the service would otherwise let
|
|
5200
|
+
// the route fall back to the loopback-only rule with nothing said about
|
|
5201
|
+
// it. That is not an open door on a loopback bind, but it IS a silent
|
|
5202
|
+
// authentication downgrade, and `snapshot.security.authFence` now shows
|
|
5203
|
+
// it for as long as it lasts.
|
|
5204
|
+
logInfo('connection fence detached while the plugin stayed mounted; the route falls back to the loopback-only rule until the service is published again')
|
|
5205
|
+
})
|
|
5206
|
+
})
|
|
5207
|
+
|
|
4739
5208
|
ctx.inject(['webServer'], (scope) => {
|
|
4740
5209
|
const webServer = scope.webServer
|
|
4741
5210
|
if (webServer === undefined || typeof webServer.register !== 'function') {
|