@240xu/dsh-websearch 2.8.2 → 2.8.4
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/README.md +9 -0
- package/lib/backends/anthropic-like.js +2 -1
- package/lib/backends/brave.js +2 -1
- package/lib/backends/ddg.js +2 -1
- package/lib/backends/mojeek.js +2 -1
- package/lib/backends/openai.js +2 -1
- package/lib/backends/searxng.js +2 -1
- package/lib/backends/serper.js +2 -1
- package/lib/backends/tavily.js +2 -1
- package/lib/client.js +139 -20
- package/lib/health.js +7 -0
- package/lib/history.js +7 -5
- package/lib/index.js +25 -4
- package/lib/multiquery.js +8 -1
- package/lib/prompting.js +1 -1
- package/lib/provider.js +9 -0
- package/lib/settings-api.js +277 -0
- package/lib/trust.js +179 -0
- package/lib/util/egress-fetch.js +338 -0
- package/lib/util/mcp-client.js +5 -4
- package/package.json +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced settings bridge for the browser settings card.
|
|
3
|
+
*
|
|
4
|
+
* The card needs three things from the host: the resolved value of every
|
|
5
|
+
* field, the revision that guards concurrent saves, and a way to write path
|
|
6
|
+
* ops back into the same profile entry the provider reads. On the 0.2.x
|
|
7
|
+
* settings line there is no `settingsScope` client service and the settings
|
|
8
|
+
* shell renders client slot sections only — so this module servers that data
|
|
9
|
+
* over the plugin's own HTTP surface instead.
|
|
10
|
+
*
|
|
11
|
+
* Data flow: the host keeps plugin config in its `settings` service
|
|
12
|
+
* (`SettingsForms.describe` / `mutate`, keyed by the profile entry id). This
|
|
13
|
+
* route is a thin bridge to it: writes land in the volatile config entry the
|
|
14
|
+
* provider resolves from on the next call, so there is no second store and no
|
|
15
|
+
* out-of-band state. Mutations are validated against the plugin's own schema
|
|
16
|
+
* here because the host only checks the volatile path, not the value shape.
|
|
17
|
+
*
|
|
18
|
+
* Both routes sit behind the host connection admission fence (the same
|
|
19
|
+
* decision the kernel applies to its own `/api` routes, including the browser
|
|
20
|
+
* auth cookie), so a LAN or tunnel page holding a valid session can save
|
|
21
|
+
* settings, and nothing weaker than the app can.
|
|
22
|
+
*
|
|
23
|
+
* GET /api/unified-search/settings -> { ok, namespace, writable, revision,
|
|
24
|
+
* value, base, user, reason? }
|
|
25
|
+
* POST /api/unified-search/settings -> { ok } + fresh payload on success,
|
|
26
|
+
* 409 { code: 'SETTINGS_CONFLICT' },
|
|
27
|
+
* 400 invalid op value/field,
|
|
28
|
+
* 503 settings service unavailable.
|
|
29
|
+
*
|
|
30
|
+
* Headless / CI profiles never mount `webServer`, so registration is skipped
|
|
31
|
+
* and nothing here runs.
|
|
32
|
+
*
|
|
33
|
+
* @module lib/settings-api.js
|
|
34
|
+
*/
|
|
35
|
+
import { rejectionFor } from "./trust.js";
|
|
36
|
+
|
|
37
|
+
const SETTINGS_PATH = "/api/unified-search/settings";
|
|
38
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
39
|
+
const MAX_OPS = 64;
|
|
40
|
+
/**
|
|
41
|
+
* Field names the card owns. Used only to find the profile entry in
|
|
42
|
+
* `describe()` output — never as a whitelist; the whitelist is the live
|
|
43
|
+
* `Config.dict`, so schema growth is picked up without touching this file.
|
|
44
|
+
*/
|
|
45
|
+
const DISCOVERY_FIELDS = ["numResults", "backendTimeoutMs", "enabledBackends", "enableExa"];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register the (exact) settings route on the late-mounted webServer.
|
|
49
|
+
* @param {{ register?: (route: { kind: string, path: string, handler: Function }) => void }} ws
|
|
50
|
+
* @param {{ getSettings: () => unknown, getConfig: () => unknown, getConnection?: () => unknown }} deps
|
|
51
|
+
*/
|
|
52
|
+
export function registerSettingsRoutes(ws, deps) {
|
|
53
|
+
if (!ws || typeof ws.register !== "function") return;
|
|
54
|
+
ws.register({
|
|
55
|
+
kind: "exact",
|
|
56
|
+
path: SETTINGS_PATH,
|
|
57
|
+
handler: guarded(deps, (req, res) => {
|
|
58
|
+
const url = new URL(String(req.url ?? "/"), "http://localhost");
|
|
59
|
+
if (url.pathname !== SETTINGS_PATH) {
|
|
60
|
+
sendJson(res, 404, { ok: false, error: "not found" });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
64
|
+
const payload = describe(deps);
|
|
65
|
+
sendJson(res, payload.ok ? 200 : 503, payload);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (req.method === "POST") {
|
|
69
|
+
void write(req, res, deps);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
res.setHeader("allow", "GET, POST");
|
|
73
|
+
sendJson(res, 405, { ok: false, error: "method not allowed (GET/POST)" });
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------------ */
|
|
79
|
+
/* handlers */
|
|
80
|
+
/* ------------------------------------------------------------------ */
|
|
81
|
+
|
|
82
|
+
function guarded(deps, handler) {
|
|
83
|
+
return (req, res) => {
|
|
84
|
+
const rejection = rejectionFor(req, typeof deps.getConnection === "function" ? deps.getConnection() : undefined);
|
|
85
|
+
if (rejection !== undefined) {
|
|
86
|
+
sendJson(res, rejection, { ok: false, error: rejection === 503 ? "admission unavailable" : "untrusted request" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
Promise.resolve()
|
|
90
|
+
.then(() => handler(req, res))
|
|
91
|
+
.catch((err) => {
|
|
92
|
+
const status = err && Number.isInteger(err.status) ? err.status : 500;
|
|
93
|
+
sendJson(res, status, { ok: false, error: sanitize(err) });
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read the plugin's volatile form snapshot from the host settings service. */
|
|
99
|
+
function describe(deps) {
|
|
100
|
+
const settings = typeof deps.getSettings === "function" ? deps.getSettings() : undefined;
|
|
101
|
+
if (settings === undefined || settings === null || typeof settings.describe !== "function") {
|
|
102
|
+
return { ok: false, writable: false, reason: "settings-unavailable" };
|
|
103
|
+
}
|
|
104
|
+
let descriptors;
|
|
105
|
+
try {
|
|
106
|
+
descriptors = settings.describe();
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, writable: false, reason: "settings-unavailable" };
|
|
109
|
+
}
|
|
110
|
+
const view = findNamespace(descriptors);
|
|
111
|
+
if (view === undefined) return { ok: false, writable: false, reason: "namespace-not-found" };
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
namespace: view.ns,
|
|
115
|
+
writable: true,
|
|
116
|
+
applies: view.applies,
|
|
117
|
+
revision: view.revision,
|
|
118
|
+
value: view.value,
|
|
119
|
+
base: view.base,
|
|
120
|
+
user: view.user,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Locate this plugin's descriptor: first by shape (the card's own fields
|
|
126
|
+
* present in the resolved value), then by the profile entry id this package's
|
|
127
|
+
* bundle patch inserts. Shape-first survives a renamed insert id; the literal
|
|
128
|
+
* survives a host that reports an empty value.
|
|
129
|
+
*/
|
|
130
|
+
function findNamespace(descriptors) {
|
|
131
|
+
if (!Array.isArray(descriptors)) return undefined;
|
|
132
|
+
const rows = descriptors.filter((row) => row !== null && typeof row === "object" && typeof row.ns === "string");
|
|
133
|
+
return rows.find((row) => row.value !== undefined && row.value !== null
|
|
134
|
+
&& typeof row.value === "object" && DISCOVERY_FIELDS.some((key) => Object.hasOwn(row.value, key)))
|
|
135
|
+
?? rows.find((row) => row.ns === "dsh-websearch");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function write(req, res, deps) {
|
|
139
|
+
const type = String(req.headers["content-type"] ?? "").toLowerCase();
|
|
140
|
+
if (!type.includes("application/json")) {
|
|
141
|
+
sendJson(res, 400, { ok: false, error: "content-type must be application/json" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
let body;
|
|
145
|
+
try {
|
|
146
|
+
body = JSON.parse(await readBody(req));
|
|
147
|
+
} catch (err) {
|
|
148
|
+
sendJson(res, err && Number.isInteger(err.status) ? err.status : 400, { ok: false, error: sanitize(err) || "request body is not valid JSON" });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
152
|
+
sendJson(res, 400, { ok: false, error: "request body must be a JSON object" });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const wanted = Array.isArray(body.ops) ? body.ops : undefined;
|
|
156
|
+
if (wanted === undefined || wanted.length === 0 || wanted.length > MAX_OPS) {
|
|
157
|
+
sendJson(res, 400, { ok: false, error: `ops must be a non-empty array of at most ${MAX_OPS} entries` });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const settings = typeof deps.getSettings === "function" ? deps.getSettings() : undefined;
|
|
161
|
+
if (settings === undefined || settings === null || typeof settings.mutate !== "function") {
|
|
162
|
+
sendJson(res, 503, { ok: false, error: "settings service unavailable" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const config = typeof deps.getConfig === "function" ? deps.getConfig() : undefined;
|
|
166
|
+
const fields = config !== undefined && config !== null && config.dict !== undefined && config.dict !== null
|
|
167
|
+
? config.dict
|
|
168
|
+
: {};
|
|
169
|
+
const ops = [];
|
|
170
|
+
for (const raw of wanted) {
|
|
171
|
+
const parsed = validateOp(raw, fields);
|
|
172
|
+
if (typeof parsed === "string") {
|
|
173
|
+
sendJson(res, 400, { ok: false, error: parsed });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
ops.push(parsed);
|
|
177
|
+
}
|
|
178
|
+
const view = findNamespace(safeDescribe(settings));
|
|
179
|
+
if (view === undefined) {
|
|
180
|
+
sendJson(res, 503, { ok: false, error: "settings namespace unavailable" });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const expected = Number.isInteger(body.expectedRevision) ? body.expectedRevision : undefined;
|
|
184
|
+
try {
|
|
185
|
+
await settings.mutate(view.ns, ops, expected);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (isConflict(err)) {
|
|
188
|
+
sendJson(res, 409, { ok: false, code: "SETTINGS_CONFLICT", error: "settings changed elsewhere; reload and retry" });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
sendJson(res, 400, { ok: false, error: sanitize(err) || "settings write failed" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const payload = describe(deps);
|
|
195
|
+
sendJson(res, payload.ok ? 200 : 503, payload);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Shape-check one path op against the live schema. The host validates only
|
|
200
|
+
* that the path is inside the volatile subtree; a wrong-typed value would be
|
|
201
|
+
* written into the profile patch and break the entry's next reload, so the
|
|
202
|
+
* value must be validated here.
|
|
203
|
+
* @returns {object|string} the op to forward, or a fixed reason string
|
|
204
|
+
*/
|
|
205
|
+
function validateOp(raw, fields) {
|
|
206
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return "ops entries must be objects";
|
|
207
|
+
const op = raw.op === "set" || raw.op === "unset" ? raw.op : undefined;
|
|
208
|
+
if (op === undefined) return "unsupported op (expected set|unset)";
|
|
209
|
+
const path = Array.isArray(raw.path) ? raw.path : undefined;
|
|
210
|
+
if (path === undefined || path.length !== 1 || typeof path[0] !== "string" || path[0] === "") {
|
|
211
|
+
return "path must be a one-element array holding a field name";
|
|
212
|
+
}
|
|
213
|
+
const field = path[0];
|
|
214
|
+
if (!Object.hasOwn(fields, field)) return `unknown settings field "${field}"`;
|
|
215
|
+
if (op === "unset") return { op, path: [field] };
|
|
216
|
+
if (!Object.hasOwn(raw, "value")) return `op "set" on "${field}" requires a value`;
|
|
217
|
+
const schema = fields[field];
|
|
218
|
+
if (typeof schema !== "function") return `field "${field}" is not writable`;
|
|
219
|
+
let checked;
|
|
220
|
+
try {
|
|
221
|
+
checked = schema(raw.value);
|
|
222
|
+
} catch {
|
|
223
|
+
return `invalid value for "${field}"`;
|
|
224
|
+
}
|
|
225
|
+
return { op, path: [field], value: checked };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isConflict(err) {
|
|
229
|
+
if (err === null || err === undefined) return false;
|
|
230
|
+
if (typeof err === "object" && typeof err.code === "string" && err.code === "SETTINGS_CONFLICT") return true;
|
|
231
|
+
const message = typeof err === "object" && "message" in err ? String(err.message) : String(err);
|
|
232
|
+
return /SETTINGS_CONFLICT/.test(message);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function safeDescribe(settings) {
|
|
236
|
+
try {
|
|
237
|
+
return settings.describe();
|
|
238
|
+
} catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ------------------------------------------------------------------ */
|
|
244
|
+
/* plumbing */
|
|
245
|
+
/* ------------------------------------------------------------------ */
|
|
246
|
+
|
|
247
|
+
function readBody(req) {
|
|
248
|
+
return new Promise((resolveBody, rejectBody) => {
|
|
249
|
+
const chunks = [];
|
|
250
|
+
let size = 0;
|
|
251
|
+
req.on("data", (chunk) => {
|
|
252
|
+
size += chunk.length;
|
|
253
|
+
if (size > MAX_BODY_BYTES) {
|
|
254
|
+
const oversize = new Error("request body too large");
|
|
255
|
+
oversize.status = 413;
|
|
256
|
+
rejectBody(oversize);
|
|
257
|
+
req.removeAllListeners("data");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
chunks.push(chunk);
|
|
261
|
+
});
|
|
262
|
+
req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
|
|
263
|
+
req.on("error", rejectBody);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sendJson(res, status, payload) {
|
|
268
|
+
if (res.writableEnded === true) return;
|
|
269
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
270
|
+
res.end(JSON.stringify(payload));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Fixed-shape diagnostics only: never echo header text or raw error details. */
|
|
274
|
+
function sanitize(err) {
|
|
275
|
+
const raw = err === null || err === undefined ? "" : String(err && err.message ? err.message : err);
|
|
276
|
+
return raw.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
277
|
+
}
|
package/lib/trust.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request trust fence for the plugin's HTTP surface.
|
|
3
|
+
*
|
|
4
|
+
* The plugin registers a prefix *longer* than the kernel's `/api`, and webServer
|
|
5
|
+
* dispatch is longest-prefix-wins — so these routes run before the connection
|
|
6
|
+
* service's own admission check and would otherwise answer any loopback caller.
|
|
7
|
+
* That was measured and documented as an open boundary; this module closes it.
|
|
8
|
+
*
|
|
9
|
+
* Two layers, tried in order:
|
|
10
|
+
*
|
|
11
|
+
* 1. the composition's own `connection` service when present — the exact
|
|
12
|
+
* admission decision the kernel applies to its `/api` routes (trust fence
|
|
13
|
+
* plus browser-auth cookie), so the plugin is never weaker than the app;
|
|
14
|
+
* 2. a structural replica of that fence for compositions without the service:
|
|
15
|
+
* loopback host only, no cross-site fetches, and an `Origin`/`Referer` that
|
|
16
|
+
* matches the `Host` authority whenever the client supplies one, with a
|
|
17
|
+
* narrow exception for the desktop's dsh-app://app relay Referer.
|
|
18
|
+
*
|
|
19
|
+
* @module src/trust.js
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Hostnames a same-machine caller can legitimately use. */
|
|
23
|
+
const LOOPBACK_NAMES = new Set(['127.0.0.1', '[::1]', '::1', 'localhost'])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Is this address one a same-machine caller can reach — i.e. may the forward
|
|
27
|
+
* listener bind it? The lanes are keyed to this machine's egress and priced
|
|
28
|
+
* against whoever shares it, so binding a routable interface would hand the
|
|
29
|
+
* quota to the whole subnet on the strength of a string in a settings file.
|
|
30
|
+
*/
|
|
31
|
+
export function isLoopbackHost(value) {
|
|
32
|
+
const authority = authorityOf(String(value ?? ''), 'http')
|
|
33
|
+
return authority !== null && LOOPBACK_NAMES.has(authority.hostname)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The fence's view of one connection service, keyed under `admit` (issue #89).
|
|
38
|
+
*
|
|
39
|
+
* The composition may publish `connection` long after plugins have loaded, so
|
|
40
|
+
* index.js reads it per request through this late-binding view. Hosts before
|
|
41
|
+
* dsh 0.1.7 carry the same decision as `requestRejection`: the view maps that
|
|
42
|
+
* method onto `admit` instead of wrapping a property that does not exist —
|
|
43
|
+
* the wrapping call used to throw inside the fence and read as 503 admission
|
|
44
|
+
* unavailable on every request. A bare status travels as the `{rejection}`
|
|
45
|
+
* envelope `rejectionFor` unwraps: handed through bare, an object-shaped
|
|
46
|
+
* return reads as "no rejection" and switches the host's authentication off.
|
|
47
|
+
*
|
|
48
|
+
* Exported so the tests exercise the real view rather than a replica that can
|
|
49
|
+
* drift from it.
|
|
50
|
+
*
|
|
51
|
+
* @param {object|(() => object|undefined)} service - the harness `connection`
|
|
52
|
+
* service, or a thunk resolving it per request (the composition may publish
|
|
53
|
+
* it long after plugins have loaded)
|
|
54
|
+
* @returns {{admit: (req: object) => ({rejection: number}|undefined)|undefined}|{admit: undefined}}
|
|
55
|
+
*/
|
|
56
|
+
export function connectionAdmissionView(service) {
|
|
57
|
+
return {
|
|
58
|
+
get admit() {
|
|
59
|
+
const current = typeof service === 'function' ? service() : service
|
|
60
|
+
if (current === undefined) return undefined
|
|
61
|
+
if (typeof current.admit !== 'function' && typeof current.requestRejection === 'function') {
|
|
62
|
+
return req => {
|
|
63
|
+
const status = current.requestRejection(req)
|
|
64
|
+
return status === undefined ? undefined : { rejection: status }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return req => current.admit(req)
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decide one request. Returns an HTTP status to reject with, or `undefined` to
|
|
74
|
+
* let the handler run.
|
|
75
|
+
*
|
|
76
|
+
* @param {import('node:http').IncomingMessage} req
|
|
77
|
+
* @param {object|undefined} connection - the harness `connection` service, when the composition mounts one
|
|
78
|
+
* @param {(decision: {status: number, source: string, reason: string}) => void} [onRejection]
|
|
79
|
+
* fixed diagnostic fields only; never request headers or error text
|
|
80
|
+
* @returns {number|undefined}
|
|
81
|
+
*/
|
|
82
|
+
export function rejectionFor(req, connection, onRejection) {
|
|
83
|
+
let decision
|
|
84
|
+
try {
|
|
85
|
+
// Hosts before dsh 0.1.7 publish the same decision under the older name
|
|
86
|
+
// `requestRejection` (issue #89): `admit` is undefined there, and the
|
|
87
|
+
// service *exists*, so an empty read must fall through to that method —
|
|
88
|
+
// treating it as a fault turned every request into 503 admission
|
|
89
|
+
// unavailable. The return value is a bare status on that spelling and an
|
|
90
|
+
// `{rejection}` envelope on `admit`.
|
|
91
|
+
const admit = connection?.admit
|
|
92
|
+
if (typeof admit === 'function') {
|
|
93
|
+
const admission = admit.call(connection, req)
|
|
94
|
+
const status = admission && typeof admission === 'object' ? admission.rejection : undefined
|
|
95
|
+
if (status === undefined) return undefined
|
|
96
|
+
decision = { status, source: 'connection', reason: 'host-rejected' }
|
|
97
|
+
} else {
|
|
98
|
+
const reject = connection?.requestRejection
|
|
99
|
+
if (typeof reject === 'function') {
|
|
100
|
+
const status = reject.call(connection, req)
|
|
101
|
+
if (status === undefined) return undefined
|
|
102
|
+
decision = { status, source: 'connection', reason: 'host-rejected' }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Never bypass browser authentication when a mounted Host service fails.
|
|
107
|
+
decision = { status: 503, source: 'connection', reason: 'admission-error' }
|
|
108
|
+
}
|
|
109
|
+
if (decision === undefined) {
|
|
110
|
+
const reason = structuralReason(req)
|
|
111
|
+
if (reason === undefined) return undefined
|
|
112
|
+
decision = { status: 403, source: 'structural', reason }
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
onRejection?.(decision)
|
|
116
|
+
} catch {
|
|
117
|
+
// Diagnostics are observational; a logger cannot change the HTTP decision.
|
|
118
|
+
}
|
|
119
|
+
return decision.status
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The replica fence. Same shape as the kernel's `isTrustedApiRequest`: DNS
|
|
124
|
+
* rebinding defence via the Host header, cross-site fetch refusal, and an
|
|
125
|
+
* Origin/Referer authority match.
|
|
126
|
+
*/
|
|
127
|
+
export function structuralRejection(req) {
|
|
128
|
+
return structuralReason(req) === undefined ? undefined : 403
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Return a fixed reason without exposing caller-supplied strings. */
|
|
132
|
+
function structuralReason(req) {
|
|
133
|
+
const host = authorityOf(req.headers.host, 'http')
|
|
134
|
+
if (host === null || !LOOPBACK_NAMES.has(host.hostname)) return 'host-not-loopback'
|
|
135
|
+
const site = String(req.headers['sec-fetch-site'] ?? '').toLowerCase()
|
|
136
|
+
if (site === 'cross-site') return 'cross-site'
|
|
137
|
+
for (const header of ['origin', 'referer']) {
|
|
138
|
+
const raw = req.headers[header]
|
|
139
|
+
if (typeof raw !== 'string' || raw.trim() === '') continue
|
|
140
|
+
if (header === 'referer' && isDesktopRelayReferer(raw)) {
|
|
141
|
+
// Desktop forwardWebRequest strips both browser markers before relaying
|
|
142
|
+
// to HTTP loopback, but retains this Referer. It is not an Origin grant
|
|
143
|
+
// and never overrides a mounted Host's authentication decision.
|
|
144
|
+
if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined) return 'desktop-relay-markers'
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
const authority = authorityOf(raw.trim())
|
|
148
|
+
if (authority === null) return `${header}-invalid`
|
|
149
|
+
// The web server speaks plain http on a loopback bind, so an Origin that
|
|
150
|
+
// claims https — or any other scheme — is not this page.
|
|
151
|
+
if (authority.scheme !== host.scheme || authority.hostname !== host.hostname || authority.port !== host.port) return `${header}-mismatch`
|
|
152
|
+
}
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Only the owned application page, never arbitrary custom-protocol pages. */
|
|
157
|
+
function isDesktopRelayReferer(value) {
|
|
158
|
+
try {
|
|
159
|
+
const url = new URL(value)
|
|
160
|
+
return url.protocol === 'dsh-app:' && url.hostname === 'app' && url.port === ''
|
|
161
|
+
&& url.username === '' && url.password === ''
|
|
162
|
+
} catch {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Split a Host/Origin/Referer value into {scheme, hostname, port}, defaulting the port. */
|
|
168
|
+
function authorityOf(value, defaultScheme) {
|
|
169
|
+
if (typeof value !== 'string' || value.trim() === '') return null
|
|
170
|
+
let url
|
|
171
|
+
try {
|
|
172
|
+
url = new URL(value.includes('://') ? value.trim() : `${defaultScheme ?? 'http'}://${value.trim()}`)
|
|
173
|
+
} catch {
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
177
|
+
const port = url.port === '' ? (url.protocol === 'https:' ? '443' : '80') : url.port
|
|
178
|
+
return { scheme: url.protocol.replace(':', ''), hostname: url.hostname.toLowerCase(), port }
|
|
179
|
+
}
|