dsh-web-fetch-enhanced 0.0.1
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/LICENSE +21 -0
- package/NOTICE +14 -0
- package/README.en.md +235 -0
- package/README.md +235 -0
- package/cordis.patch.yml +11 -0
- package/docs/design.zh-CN.md +140 -0
- package/docs/security.zh-CN.md +82 -0
- package/examples/coexist.cordis.yml +10 -0
- package/examples/drop-in.cordis.yml +16 -0
- package/examples/manual.cordis.patch.yml +13 -0
- package/lib/client.js +341 -0
- package/lib/index.js +645 -0
- package/lib/types/address-policy.d.ts +24 -0
- package/lib/types/client/AllowlistCard.d.ts +16 -0
- package/lib/types/client/index.d.ts +13 -0
- package/lib/types/client/locales.d.ts +26 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/policy.d.ts +16 -0
- package/lib/types/provider.d.ts +24 -0
- package/lib/types/resolver.d.ts +31 -0
- package/package.json +122 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import ipaddr from "ipaddr.js";
|
|
5
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
6
|
+
import { deadline, timeoutOf } from "@deepseek-ai/dsh-timeout";
|
|
7
|
+
import { lookup } from "node:dns/promises";
|
|
8
|
+
//#region lib/types/address-policy.js
|
|
9
|
+
/**
|
|
10
|
+
* Immutable address policy. Public unicast remains allowed. A non-public address
|
|
11
|
+
* must match one configured CIDR and, when hostname rules exist, one hostname rule.
|
|
12
|
+
*/
|
|
13
|
+
var AddressPolicy = class {
|
|
14
|
+
#cidrs;
|
|
15
|
+
#hostnames;
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.#cidrs = (options.allowCidrs ?? []).map(compileCidr);
|
|
18
|
+
this.#hostnames = (options.allowHostnames ?? []).map(compileHostname);
|
|
19
|
+
}
|
|
20
|
+
/** Return true when an address is public or explicitly exempted for this hostname. */
|
|
21
|
+
allows(hostname, address) {
|
|
22
|
+
const parsed = parseAddress(address);
|
|
23
|
+
if (parsed === void 0) return false;
|
|
24
|
+
if (isPublicAddress(parsed)) return true;
|
|
25
|
+
if (!this.#matchesHostname(hostname)) return false;
|
|
26
|
+
return this.#cidrs.some((cidr) => matchesCidr(parsed, cidr));
|
|
27
|
+
}
|
|
28
|
+
/** Return true only when a non-public address is covered by the configured exception. */
|
|
29
|
+
allowsNonPublic(hostname, address) {
|
|
30
|
+
const parsed = parseAddress(address);
|
|
31
|
+
if (parsed === void 0 || isPublicAddress(parsed)) return false;
|
|
32
|
+
return this.#matchesHostname(hostname) && this.#cidrs.some((cidr) => matchesCidr(parsed, cidr));
|
|
33
|
+
}
|
|
34
|
+
#matchesHostname(hostname) {
|
|
35
|
+
if (this.#hostnames.length === 0) return true;
|
|
36
|
+
const normalized = normalizeHostname(hostname);
|
|
37
|
+
return this.#hostnames.some((rule) => {
|
|
38
|
+
if (rule.kind === "exact") return normalized === rule.hostname;
|
|
39
|
+
return normalized.endsWith(rule.suffix) && normalized.length > rule.suffix.length;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function isPublicAddress(address) {
|
|
44
|
+
return address.range() === "unicast";
|
|
45
|
+
}
|
|
46
|
+
function parseAddress(input) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = ipaddr.parse(stripIpv6Brackets(input));
|
|
49
|
+
if (parsed instanceof ipaddr.IPv6) {
|
|
50
|
+
if (parsed.isIPv4MappedAddress()) return parsed.toIPv4Address();
|
|
51
|
+
const bytes = parsed.toByteArray();
|
|
52
|
+
if (bytes.slice(0, 12).every((byte) => byte === 0)) return new ipaddr.IPv4(bytes.slice(12));
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function compileCidr(input) {
|
|
60
|
+
const source = input.trim();
|
|
61
|
+
if (source.length === 0) throw new Error("web-fetch-enhanced: allowCidrs must not contain an empty value");
|
|
62
|
+
const parts = source.split("/");
|
|
63
|
+
const addressText = parts[0];
|
|
64
|
+
const prefixText = parts[1];
|
|
65
|
+
if (parts.length !== 2 || addressText === void 0 || prefixText === void 0 || !/^(0|[1-9]\d*)$/u.test(prefixText)) throw new Error(`web-fetch-enhanced: invalid CIDR ${JSON.stringify(input)}`);
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = ipaddr.parseCIDR(source);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw new Error(`web-fetch-enhanced: invalid CIDR ${JSON.stringify(input)}`, { cause: error });
|
|
71
|
+
}
|
|
72
|
+
const [network, prefixLength] = parsed;
|
|
73
|
+
if (network instanceof ipaddr.IPv4 && !isStrictIpv4Literal(addressText)) throw new Error(`web-fetch-enhanced: IPv4 CIDR must use four decimal octets: ${JSON.stringify(input)}`);
|
|
74
|
+
if (network instanceof ipaddr.IPv6 && addressText.includes("%")) throw new Error(`web-fetch-enhanced: IPv6 CIDR must not contain a zone id: ${JSON.stringify(input)}`);
|
|
75
|
+
if (network instanceof ipaddr.IPv6 && network.isIPv4MappedAddress()) throw new Error(`web-fetch-enhanced: IPv4-mapped CIDR ${JSON.stringify(input)} is ambiguous; use its IPv4 CIDR`);
|
|
76
|
+
const base = network instanceof ipaddr.IPv4 ? ipaddr.IPv4.networkAddressFromCIDR(source) : ipaddr.IPv6.networkAddressFromCIDR(source);
|
|
77
|
+
if (!network.toByteArray().every((byte, index) => base.toByteArray()[index] === byte)) throw new Error(`web-fetch-enhanced: CIDR must use its network base address: ${JSON.stringify(input)}`);
|
|
78
|
+
return {
|
|
79
|
+
network,
|
|
80
|
+
prefixLength
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function isStrictIpv4Literal(input) {
|
|
84
|
+
const octets = input.split(".");
|
|
85
|
+
return octets.length === 4 && octets.every((octet) => {
|
|
86
|
+
if (!/^(0|[1-9]\d{0,2})$/u.test(octet)) return false;
|
|
87
|
+
return Number(octet) <= 255;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function matchesCidr(address, cidr) {
|
|
91
|
+
if (address.kind() !== cidr.network.kind()) return false;
|
|
92
|
+
return address.match(cidr.network, cidr.prefixLength);
|
|
93
|
+
}
|
|
94
|
+
function compileHostname(input) {
|
|
95
|
+
const source = input.trim();
|
|
96
|
+
if (source.startsWith("*.")) {
|
|
97
|
+
const base = source.slice(2);
|
|
98
|
+
if (base.length === 0 || isIP(stripIpv6Brackets(base)) !== 0) throw new Error(`web-fetch-enhanced: invalid wildcard hostname ${JSON.stringify(input)}`);
|
|
99
|
+
return {
|
|
100
|
+
kind: "suffix",
|
|
101
|
+
suffix: `.${normalizeHostname(base)}`
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (source.includes("*")) throw new Error(`web-fetch-enhanced: hostname wildcard is only allowed as the left-most "*." label: ${JSON.stringify(input)}`);
|
|
105
|
+
return {
|
|
106
|
+
kind: "exact",
|
|
107
|
+
hostname: normalizeHostname(source)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function normalizeHostname(input) {
|
|
111
|
+
const source = stripIpv6Brackets(input.trim()).replace(/\.$/u, "");
|
|
112
|
+
if (source.length === 0 || /[\\\s/?#@]/u.test(source)) throw new Error(`web-fetch-enhanced: invalid hostname ${JSON.stringify(input)}`);
|
|
113
|
+
if (isIP(source) !== 0) return ipaddr.parse(source).toString().toLowerCase();
|
|
114
|
+
if (source.includes(":")) throw new Error(`web-fetch-enhanced: hostname rules must not include a port: ${JSON.stringify(input)}`);
|
|
115
|
+
const parsed = new URL(`http://${source}/`);
|
|
116
|
+
if (parsed.hostname === "") throw new Error(`web-fetch-enhanced: invalid hostname ${JSON.stringify(input)}`);
|
|
117
|
+
return parsed.hostname.replace(/\.$/u, "").toLowerCase();
|
|
118
|
+
}
|
|
119
|
+
/** WHATWG URL retains brackets around IPv6 hostnames; IP parsers do not. */
|
|
120
|
+
function stripIpv6Brackets(hostname) {
|
|
121
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region lib/types/resolver.js
|
|
125
|
+
const RFC6052_PREFIX_LENGTHS = [
|
|
126
|
+
32,
|
|
127
|
+
40,
|
|
128
|
+
48,
|
|
129
|
+
56,
|
|
130
|
+
64,
|
|
131
|
+
96
|
|
132
|
+
];
|
|
133
|
+
const IPV4ONLY_DISCOVERY_HOST = "ipv4only.arpa";
|
|
134
|
+
const IPV4ONLY_SENTINELS = new Set(["192.0.0.170", "192.0.0.171"]);
|
|
135
|
+
/** Build the resolver injected into the address-pinned HTTP provider. */
|
|
136
|
+
function createAllowlistResolver(policy, resolver = lookup) {
|
|
137
|
+
return (hostname, signal) => resolveAllowedAddresses(hostname, signal, policy, resolver);
|
|
138
|
+
}
|
|
139
|
+
/** Resolve once and reject the complete answer set when any entry is disallowed. */
|
|
140
|
+
async function resolveAllowedAddresses(hostname, signal, policy, resolver = lookup) {
|
|
141
|
+
const unbracketed = stripIpv6Brackets(hostname);
|
|
142
|
+
const literalFamily = isIP(unbracketed);
|
|
143
|
+
const resolved = literalFamily === 0 ? await raceWithSignal(resolver(unbracketed, {
|
|
144
|
+
all: true,
|
|
145
|
+
order: "verbatim"
|
|
146
|
+
}), signal) : [{
|
|
147
|
+
address: unbracketed,
|
|
148
|
+
family: literalFamily
|
|
149
|
+
}];
|
|
150
|
+
if (resolved.length === 0) throw new WebError(`hostname "${hostname}" resolved to no addresses`, "WEB_PROVIDER_ERROR");
|
|
151
|
+
const nat64Prefixes = resolved.some((entry) => entry.family === 6 && isIP(entry.address) === 6) ? await discoverNat64Prefixes(signal, resolver) : [];
|
|
152
|
+
const addresses = [];
|
|
153
|
+
for (const entry of resolved) {
|
|
154
|
+
if (entry.family !== 4 && entry.family !== 6 || isIP(entry.address) !== entry.family) throw new WebError(`hostname "${hostname}" resolved to an invalid IP address`, "WEB_PROVIDER_ERROR");
|
|
155
|
+
if (!policy.allows(hostname, entry.address)) throw new WebError(`URL hostname "${hostname}" resolves to non-public IP address "${entry.address}" outside the configured allowlist`, "WEB_BLOCKED_URL");
|
|
156
|
+
for (const translatedIpv4 of translatedIpv4Addresses(entry.address, nat64Prefixes)) if (!policy.allows(hostname, translatedIpv4)) throw new WebError(`URL hostname "${hostname}" resolves through NAT64 to non-public IPv4 address "${translatedIpv4}" outside the configured allowlist`, "WEB_BLOCKED_URL");
|
|
157
|
+
addresses.push({
|
|
158
|
+
address: entry.address,
|
|
159
|
+
family: entry.family
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return addresses;
|
|
163
|
+
}
|
|
164
|
+
async function discoverNat64Prefixes(signal, resolver) {
|
|
165
|
+
const discovered = await raceWithSignal(resolver(IPV4ONLY_DISCOVERY_HOST, {
|
|
166
|
+
all: true,
|
|
167
|
+
order: "verbatim"
|
|
168
|
+
}), signal);
|
|
169
|
+
const prefixes = [];
|
|
170
|
+
const seen = /* @__PURE__ */ new Set();
|
|
171
|
+
for (const entry of discovered) {
|
|
172
|
+
if (entry.family !== 6 || isIP(entry.address) !== 6) continue;
|
|
173
|
+
const bytes = ipaddr.parse(entry.address).toByteArray();
|
|
174
|
+
for (const length of RFC6052_PREFIX_LENGTHS) {
|
|
175
|
+
const embedded = embeddedIpv4Address(bytes, length);
|
|
176
|
+
if (embedded === void 0 || !IPV4ONLY_SENTINELS.has(embedded)) continue;
|
|
177
|
+
const prefixBytes = bytes.slice(0, length / 8);
|
|
178
|
+
const key = `${String(length)}:${prefixBytes.join(".")}`;
|
|
179
|
+
if (seen.has(key)) continue;
|
|
180
|
+
seen.add(key);
|
|
181
|
+
prefixes.push({
|
|
182
|
+
bytes: prefixBytes,
|
|
183
|
+
length
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return prefixes;
|
|
188
|
+
}
|
|
189
|
+
function translatedIpv4Addresses(input, prefixes) {
|
|
190
|
+
if (isIP(input) !== 6) return [];
|
|
191
|
+
const bytes = ipaddr.parse(input).toByteArray();
|
|
192
|
+
const translated = [];
|
|
193
|
+
const seen = /* @__PURE__ */ new Set();
|
|
194
|
+
for (const prefix of prefixes) {
|
|
195
|
+
if (!prefix.bytes.every((byte, index) => bytes[index] === byte)) continue;
|
|
196
|
+
const embedded = embeddedIpv4Address(bytes, prefix.length);
|
|
197
|
+
if (embedded === void 0 || seen.has(embedded)) continue;
|
|
198
|
+
seen.add(embedded);
|
|
199
|
+
translated.push(embedded);
|
|
200
|
+
}
|
|
201
|
+
return translated;
|
|
202
|
+
}
|
|
203
|
+
function embeddedIpv4Address(bytes, prefixLength) {
|
|
204
|
+
if (prefixLength === 96) return bytes.slice(12, 16).join(".");
|
|
205
|
+
if (bytes[8] !== 0) return void 0;
|
|
206
|
+
const prefixBytes = prefixLength / 8;
|
|
207
|
+
const beforeReservedOctet = 8 - prefixBytes;
|
|
208
|
+
return [...bytes.slice(prefixBytes, prefixBytes + beforeReservedOctet), ...bytes.slice(9, 13 - beforeReservedOctet)].join(".");
|
|
209
|
+
}
|
|
210
|
+
/** Fetch while preserving the URL hostname for Host and TLS SNI. */
|
|
211
|
+
async function requestPinned(url, addresses, headers, signal) {
|
|
212
|
+
const { Agent, fetch } = await import("undici");
|
|
213
|
+
const dispatcher = new Agent({
|
|
214
|
+
autoSelectFamily: true,
|
|
215
|
+
connect: { lookup: createPinnedLookup(addresses) }
|
|
216
|
+
});
|
|
217
|
+
try {
|
|
218
|
+
return {
|
|
219
|
+
response: await fetch(url, {
|
|
220
|
+
method: "GET",
|
|
221
|
+
redirect: "manual",
|
|
222
|
+
headers,
|
|
223
|
+
signal,
|
|
224
|
+
dispatcher
|
|
225
|
+
}),
|
|
226
|
+
close: async () => {
|
|
227
|
+
await dispatcher.close();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
} catch (error) {
|
|
231
|
+
await dispatcher.close();
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Create a Node lookup callback that returns only the prevalidated answer set. */
|
|
236
|
+
function createPinnedLookup(addresses) {
|
|
237
|
+
return (hostname, options, callback) => {
|
|
238
|
+
const family = typeof options.family === "number" ? options.family : options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : 0;
|
|
239
|
+
const eligible = family === 0 ? addresses : addresses.filter((address) => address.family === family);
|
|
240
|
+
const selected = eligible[0];
|
|
241
|
+
if (selected === void 0) {
|
|
242
|
+
callback(Object.assign(/* @__PURE__ */ new Error(`no validated address for ${hostname} in family ${family}`), {
|
|
243
|
+
code: "ENOTFOUND",
|
|
244
|
+
hostname
|
|
245
|
+
}), options.all === true ? [] : "", family);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (options.all === true) {
|
|
249
|
+
callback(null, eligible.map((address) => ({ ...address })));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
callback(null, selected.address, selected.family);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function raceWithSignal(promise, signal) {
|
|
256
|
+
const abortError = () => new Error("web fetch aborted during hostname resolution", { cause: signal.reason });
|
|
257
|
+
if (signal.aborted) return Promise.reject(abortError());
|
|
258
|
+
return new Promise((resolve, reject) => {
|
|
259
|
+
const abort = () => {
|
|
260
|
+
reject(abortError());
|
|
261
|
+
};
|
|
262
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
263
|
+
promise.then(resolve, reject).finally(() => {
|
|
264
|
+
signal.removeEventListener("abort", abort);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region lib/types/policy.js
|
|
270
|
+
/** Maximum accepted request URL length. */
|
|
271
|
+
const WEB_FETCH_MAX_URL_LENGTH = 2048;
|
|
272
|
+
/** Parse a URL and enforce HTTP(S)-only, anonymous requests. */
|
|
273
|
+
function parseFetchUrl(input) {
|
|
274
|
+
let url;
|
|
275
|
+
try {
|
|
276
|
+
url = new URL(input);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw new WebError(`invalid URL: ${input}`, "WEB_INVALID_URL", { cause: error });
|
|
279
|
+
}
|
|
280
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new WebError(`unsupported URL scheme "${url.protocol}" (only http and https are allowed)`, "WEB_INVALID_URL");
|
|
281
|
+
if (url.username.length > 0 || url.password.length > 0) throw new WebError("credentials in URLs are not allowed", "WEB_BLOCKED_URL");
|
|
282
|
+
return url;
|
|
283
|
+
}
|
|
284
|
+
/** Validate URL length before parsing. */
|
|
285
|
+
function validateFetchUrl(input) {
|
|
286
|
+
if (input.length > 2048) throw new WebError(`URL exceeds the maximum length of ${WEB_FETCH_MAX_URL_LENGTH}`, "WEB_INVALID_URL");
|
|
287
|
+
return parseFetchUrl(input);
|
|
288
|
+
}
|
|
289
|
+
/** Same origin means the scheme, hostname, and port are all equal. */
|
|
290
|
+
function isSameOrigin(a, b) {
|
|
291
|
+
return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port;
|
|
292
|
+
}
|
|
293
|
+
/** Classify supported textual response types. */
|
|
294
|
+
function classifyContentType(contentType) {
|
|
295
|
+
const mime = (contentType ?? "").replace(/;.*$/s, "").trim().toLowerCase();
|
|
296
|
+
if (mime === "text/html" || mime === "application/xhtml+xml") return "html";
|
|
297
|
+
if (mime.startsWith("text/")) return "text";
|
|
298
|
+
if (mime === "application/json" || mime === "application/xml" || mime.endsWith("+json") || mime.endsWith("+xml")) return "text";
|
|
299
|
+
}
|
|
300
|
+
/** Return the declared charset, normalized to lower case. */
|
|
301
|
+
function parseCharset(contentType) {
|
|
302
|
+
return /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? "")?.[1]?.trim().toLowerCase();
|
|
303
|
+
}
|
|
304
|
+
/** Create a decoder, rejecting unsupported charset labels. */
|
|
305
|
+
function decoderForCharset(charset) {
|
|
306
|
+
if (charset === void 0) return new TextDecoder("utf-8");
|
|
307
|
+
try {
|
|
308
|
+
return new TextDecoder(charset);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw new WebError(`unsupported charset "${charset}"`, "WEB_UNSUPPORTED_CONTENT_TYPE", { cause: error });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region lib/types/provider.js
|
|
315
|
+
var __addDisposableResource = function(env, value, async) {
|
|
316
|
+
if (value !== null && value !== void 0) {
|
|
317
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
318
|
+
var dispose, inner;
|
|
319
|
+
if (async) {
|
|
320
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
321
|
+
dispose = value[Symbol.asyncDispose];
|
|
322
|
+
}
|
|
323
|
+
if (dispose === void 0) {
|
|
324
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
325
|
+
dispose = value[Symbol.dispose];
|
|
326
|
+
if (async) inner = dispose;
|
|
327
|
+
}
|
|
328
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
329
|
+
if (inner) dispose = function() {
|
|
330
|
+
try {
|
|
331
|
+
inner.call(this);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
return Promise.reject(e);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
env.stack.push({
|
|
337
|
+
value,
|
|
338
|
+
dispose,
|
|
339
|
+
async
|
|
340
|
+
});
|
|
341
|
+
} else if (async) env.stack.push({ async: true });
|
|
342
|
+
return value;
|
|
343
|
+
};
|
|
344
|
+
var __disposeResources = (function(SuppressedError) {
|
|
345
|
+
return function(env) {
|
|
346
|
+
function fail(e) {
|
|
347
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
348
|
+
env.hasError = true;
|
|
349
|
+
}
|
|
350
|
+
var r, s = 0;
|
|
351
|
+
function next() {
|
|
352
|
+
while (r = env.stack.pop()) try {
|
|
353
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
354
|
+
if (r.dispose) {
|
|
355
|
+
var result = r.dispose.call(r.value);
|
|
356
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
|
|
357
|
+
fail(e);
|
|
358
|
+
return next();
|
|
359
|
+
});
|
|
360
|
+
} else s |= 1;
|
|
361
|
+
} catch (e) {
|
|
362
|
+
fail(e);
|
|
363
|
+
}
|
|
364
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
365
|
+
if (env.hasError) throw env.error;
|
|
366
|
+
}
|
|
367
|
+
return next();
|
|
368
|
+
};
|
|
369
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
|
|
370
|
+
var e = new Error(message);
|
|
371
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
372
|
+
});
|
|
373
|
+
/** Anonymous HTTP(S) provider with allowlist-aware address validation. */
|
|
374
|
+
var EnhancedHttpFetchProvider = class {
|
|
375
|
+
id;
|
|
376
|
+
limits;
|
|
377
|
+
resolveAddresses;
|
|
378
|
+
constructor(id, limits, resolveAddresses) {
|
|
379
|
+
this.id = id;
|
|
380
|
+
this.limits = limits;
|
|
381
|
+
this.resolveAddresses = resolveAddresses;
|
|
382
|
+
}
|
|
383
|
+
available() {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
async fetch(request, signal) {
|
|
387
|
+
const env_1 = {
|
|
388
|
+
stack: [],
|
|
389
|
+
error: void 0,
|
|
390
|
+
hasError: false
|
|
391
|
+
};
|
|
392
|
+
try {
|
|
393
|
+
if (signal?.aborted) throw new WebError("web fetch aborted", "WEB_ABORTED");
|
|
394
|
+
const d = __addDisposableResource(env_1, deadline(signal, this.limits.timeoutMs, "WEB_FETCH_TIMEOUT"), false);
|
|
395
|
+
return await this.followAndRead(request.url, d.signal);
|
|
396
|
+
} catch (e_1) {
|
|
397
|
+
env_1.error = e_1;
|
|
398
|
+
env_1.hasError = true;
|
|
399
|
+
} finally {
|
|
400
|
+
__disposeResources(env_1);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async followAndRead(initialUrl, signal) {
|
|
404
|
+
let currentUrl = validateFetchUrl(initialUrl);
|
|
405
|
+
let redirectsFollowed = 0;
|
|
406
|
+
for (;;) {
|
|
407
|
+
const request = await this.requestOnce(currentUrl, signal);
|
|
408
|
+
const { response } = request;
|
|
409
|
+
try {
|
|
410
|
+
if (isRedirectStatus(response.status)) {
|
|
411
|
+
if (redirectsFollowed >= this.limits.maxRedirects) {
|
|
412
|
+
await response.body?.cancel();
|
|
413
|
+
throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, "WEB_REDIRECT_BLOCKED");
|
|
414
|
+
}
|
|
415
|
+
const location = response.headers.get("location");
|
|
416
|
+
if (location === null) {
|
|
417
|
+
await response.body?.cancel();
|
|
418
|
+
throw new WebError(`redirect response (HTTP ${response.status}) without a Location header`, "WEB_PROVIDER_ERROR");
|
|
419
|
+
}
|
|
420
|
+
let validatedTarget;
|
|
421
|
+
try {
|
|
422
|
+
validatedTarget = validateFetchUrl(resolveRedirect(location, currentUrl).toString());
|
|
423
|
+
if (!isSameOrigin(validatedTarget, currentUrl)) throw new WebError(`cross-origin redirect to ${validatedTarget.origin} is not followed automatically; retry against that URL directly`, "WEB_REDIRECT_BLOCKED");
|
|
424
|
+
} catch (error) {
|
|
425
|
+
await response.body?.cancel();
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
await response.body?.cancel();
|
|
429
|
+
currentUrl = validatedTarget;
|
|
430
|
+
redirectsFollowed++;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
return await this.readBody(response, currentUrl, signal);
|
|
434
|
+
} finally {
|
|
435
|
+
await request.close();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async requestOnce(url, signal) {
|
|
440
|
+
try {
|
|
441
|
+
return await requestPinned(url, await this.resolveAddresses(url.hostname, signal), {
|
|
442
|
+
"user-agent": this.limits.userAgent,
|
|
443
|
+
"accept": "text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8"
|
|
444
|
+
}, signal);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (error instanceof WebError) throw error;
|
|
447
|
+
throw translateAbortOrNetwork(error, signal);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async readBody(response, finalUrl, signal) {
|
|
451
|
+
const contentType = response.headers.get("content-type");
|
|
452
|
+
const kind = classifyContentType(contentType);
|
|
453
|
+
if (kind === void 0) {
|
|
454
|
+
await response.body?.cancel();
|
|
455
|
+
throw new WebError(`unsupported content type "${contentType ?? "unknown"}"`, "WEB_UNSUPPORTED_CONTENT_TYPE");
|
|
456
|
+
}
|
|
457
|
+
let decoder;
|
|
458
|
+
try {
|
|
459
|
+
decoder = decoderForCharset(parseCharset(contentType));
|
|
460
|
+
} catch (error) {
|
|
461
|
+
await response.body?.cancel();
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
const { bytes, truncatedByBytes } = await this.readCapped(response, signal);
|
|
465
|
+
const decoded = decoder.decode(bytes);
|
|
466
|
+
const truncatedByChars = decoded.length > this.limits.maxBodyChars;
|
|
467
|
+
const content = truncatedByChars ? decoded.slice(0, this.limits.maxBodyChars) : decoded;
|
|
468
|
+
const body = kind === "html" ? {
|
|
469
|
+
kind: "html",
|
|
470
|
+
content
|
|
471
|
+
} : {
|
|
472
|
+
kind: "text",
|
|
473
|
+
content
|
|
474
|
+
};
|
|
475
|
+
return {
|
|
476
|
+
url: finalUrl.toString(),
|
|
477
|
+
statusCode: response.status,
|
|
478
|
+
body,
|
|
479
|
+
truncated: truncatedByBytes || truncatedByChars
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
async readCapped(response, signal) {
|
|
483
|
+
const declared = response.headers.get("content-length");
|
|
484
|
+
if (declared !== null) {
|
|
485
|
+
const length = Number(declared);
|
|
486
|
+
if (Number.isFinite(length) && length > this.limits.maxResponseBytes) {
|
|
487
|
+
await response.body?.cancel();
|
|
488
|
+
throw new WebError(`response exceeds the maximum of ${this.limits.maxResponseBytes} bytes`, "WEB_FETCH_TOO_LARGE");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (response.body === null) return {
|
|
492
|
+
bytes: new Uint8Array(0),
|
|
493
|
+
truncatedByBytes: false
|
|
494
|
+
};
|
|
495
|
+
const chunks = [];
|
|
496
|
+
let total = 0;
|
|
497
|
+
let truncatedByBytes = false;
|
|
498
|
+
const reader = response.body.getReader();
|
|
499
|
+
try {
|
|
500
|
+
for (;;) {
|
|
501
|
+
const { done, value } = await reader.read();
|
|
502
|
+
if (done) break;
|
|
503
|
+
const remaining = this.limits.maxResponseBytes - total;
|
|
504
|
+
if (value.byteLength > remaining) {
|
|
505
|
+
chunks.push(value.subarray(0, remaining));
|
|
506
|
+
total += remaining;
|
|
507
|
+
truncatedByBytes = true;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
chunks.push(value);
|
|
511
|
+
total += value.byteLength;
|
|
512
|
+
}
|
|
513
|
+
} catch (error) {
|
|
514
|
+
throw translateAbortOrNetwork(error, signal);
|
|
515
|
+
} finally {
|
|
516
|
+
await reader.cancel().catch(() => {});
|
|
517
|
+
}
|
|
518
|
+
const bytes = new Uint8Array(total);
|
|
519
|
+
let offset = 0;
|
|
520
|
+
for (const chunk of chunks) {
|
|
521
|
+
bytes.set(chunk, offset);
|
|
522
|
+
offset += chunk.byteLength;
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
bytes,
|
|
526
|
+
truncatedByBytes
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
function isRedirectStatus(status) {
|
|
531
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
532
|
+
}
|
|
533
|
+
function resolveRedirect(location, base) {
|
|
534
|
+
try {
|
|
535
|
+
return new URL(location, base);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
throw new WebError(`invalid redirect Location "${location}"`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function translateAbortOrNetwork(error, signal) {
|
|
541
|
+
const timeout = timeoutOf(signal, "WEB_FETCH_TIMEOUT");
|
|
542
|
+
if (timeout !== void 0) return new WebError("web fetch timed out", "WEB_FETCH_TIMEOUT", { cause: timeout });
|
|
543
|
+
if (signal.aborted) return new WebError("web fetch aborted", "WEB_ABORTED", { cause: error });
|
|
544
|
+
return new WebError(`web fetch failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
545
|
+
}
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region lib/types/index.js
|
|
548
|
+
/**
|
|
549
|
+
* DeepSeek Harness HTTP fetch provider with explicit non-public CIDR exceptions.
|
|
550
|
+
* It preserves the native security model while making non-public exceptions explicit.
|
|
551
|
+
*
|
|
552
|
+
* @module dsh-web-fetch-enhanced
|
|
553
|
+
*/
|
|
554
|
+
const MAX_NODE_TIMER_DELAY_MS = 2147483647;
|
|
555
|
+
/** Explicit product User-Agent used by default. */
|
|
556
|
+
const DEFAULT_USER_AGENT = "dsh-web-fetch-enhanced/0.1.0";
|
|
557
|
+
/** Default provider id; select it in the dsh-web row with fetchProvider. */
|
|
558
|
+
const DEFAULT_PROVIDER_ID = "http-enhanced";
|
|
559
|
+
/** Settings namespace paired with the Web Profile configuration card. */
|
|
560
|
+
const SETTINGS_NAMESPACE = settingsNamespace("web-fetch-enhanced");
|
|
561
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
562
|
+
const name = "web-fetch-enhanced";
|
|
563
|
+
/** The web seam this provider contributes to. */
|
|
564
|
+
const inject = ["web"];
|
|
565
|
+
const Config = z.object({
|
|
566
|
+
providerId: z.string().default(DEFAULT_PROVIDER_ID),
|
|
567
|
+
allowCidrs: z.array(z.string()).default([]),
|
|
568
|
+
allowHostnames: z.array(z.string()).default([]),
|
|
569
|
+
maxResponseBytes: z.number().default(5e6),
|
|
570
|
+
maxBodyChars: z.number().default(1e5),
|
|
571
|
+
timeoutMs: z.number().default(3e4),
|
|
572
|
+
maxRedirects: z.number().default(5),
|
|
573
|
+
userAgent: z.string().default(DEFAULT_USER_AGENT)
|
|
574
|
+
});
|
|
575
|
+
/** Construct the provider without mounting it, useful for tests and custom compositions. */
|
|
576
|
+
function createProvider(config = {}) {
|
|
577
|
+
const resolved = resolveConfig(config);
|
|
578
|
+
assertProviderId(resolved.providerId);
|
|
579
|
+
assertPositiveFinite("maxResponseBytes", resolved.maxResponseBytes);
|
|
580
|
+
assertPositiveFinite("maxBodyChars", resolved.maxBodyChars);
|
|
581
|
+
assertTimeoutMs(resolved.timeoutMs);
|
|
582
|
+
assertNonNegativeInteger("maxRedirects", resolved.maxRedirects);
|
|
583
|
+
const policy = new AddressPolicy({
|
|
584
|
+
allowCidrs: resolved.allowCidrs,
|
|
585
|
+
allowHostnames: resolved.allowHostnames
|
|
586
|
+
});
|
|
587
|
+
const limits = {
|
|
588
|
+
maxResponseBytes: resolved.maxResponseBytes,
|
|
589
|
+
maxBodyChars: resolved.maxBodyChars,
|
|
590
|
+
timeoutMs: resolved.timeoutMs,
|
|
591
|
+
maxRedirects: resolved.maxRedirects,
|
|
592
|
+
userAgent: resolved.userAgent
|
|
593
|
+
};
|
|
594
|
+
return new EnhancedHttpFetchProvider(resolved.providerId, limits, createAllowlistResolver(policy));
|
|
595
|
+
}
|
|
596
|
+
/** Register the enhanced fetch provider and its live Web Profile settings section. */
|
|
597
|
+
function apply(ctx, config) {
|
|
598
|
+
const providerId = resolveConfig(config).providerId;
|
|
599
|
+
let current = () => config;
|
|
600
|
+
installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
601
|
+
setSource: (source) => {
|
|
602
|
+
current = source;
|
|
603
|
+
},
|
|
604
|
+
onChange: () => {},
|
|
605
|
+
validate: (value) => {
|
|
606
|
+
if (resolveConfig(value).providerId !== providerId) throw new Error("web-fetch-enhanced: providerId cannot be changed through live settings");
|
|
607
|
+
createProvider(value);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
const dynamicProvider = {
|
|
611
|
+
id: providerId,
|
|
612
|
+
available: () => true,
|
|
613
|
+
fetch: async (request, signal) => {
|
|
614
|
+
return await createProvider(current()).fetch(request, signal);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
ctx.web.registerFetchProvider(dynamicProvider);
|
|
618
|
+
}
|
|
619
|
+
function resolveConfig(config) {
|
|
620
|
+
return {
|
|
621
|
+
providerId: config.providerId ?? "http-enhanced",
|
|
622
|
+
allowCidrs: config.allowCidrs ?? [],
|
|
623
|
+
allowHostnames: config.allowHostnames ?? [],
|
|
624
|
+
maxResponseBytes: config.maxResponseBytes ?? 5e6,
|
|
625
|
+
maxBodyChars: config.maxBodyChars ?? 1e5,
|
|
626
|
+
timeoutMs: config.timeoutMs ?? 3e4,
|
|
627
|
+
maxRedirects: config.maxRedirects ?? 5,
|
|
628
|
+
userAgent: config.userAgent ?? "dsh-web-fetch-enhanced/0.1.0"
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function assertProviderId(value) {
|
|
632
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value)) throw new Error("web-fetch-enhanced: providerId must be 1-128 letters, digits, dots, underscores, or hyphens");
|
|
633
|
+
}
|
|
634
|
+
function assertPositiveFinite(field, value) {
|
|
635
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`web-fetch-enhanced: ${field} must be a positive finite number`);
|
|
636
|
+
}
|
|
637
|
+
function assertTimeoutMs(value) {
|
|
638
|
+
assertPositiveFinite("timeoutMs", value);
|
|
639
|
+
if (value > MAX_NODE_TIMER_DELAY_MS) throw new Error(`web-fetch-enhanced: timeoutMs must be no greater than ${MAX_NODE_TIMER_DELAY_MS}`);
|
|
640
|
+
}
|
|
641
|
+
function assertNonNegativeInteger(field, value) {
|
|
642
|
+
if (!Number.isInteger(value) || value < 0) throw new Error(`web-fetch-enhanced: ${field} must be a non-negative integer`);
|
|
643
|
+
}
|
|
644
|
+
//#endregion
|
|
645
|
+
export { Config, DEFAULT_PROVIDER_ID, DEFAULT_USER_AGENT, SETTINGS_NAMESPACE, apply, createProvider, inject, name };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Inputs used to compile the non-public destination allowlist. */
|
|
2
|
+
export interface AddressPolicyOptions {
|
|
3
|
+
/** IPv4 and IPv6 CIDRs allowed as exceptions to the public-address rule. */
|
|
4
|
+
readonly allowCidrs?: readonly string[];
|
|
5
|
+
/** Optional exact hostnames or left-most wildcards that must also match an exception. */
|
|
6
|
+
readonly allowHostnames?: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Immutable address policy. Public unicast remains allowed. A non-public address
|
|
10
|
+
* must match one configured CIDR and, when hostname rules exist, one hostname rule.
|
|
11
|
+
*/
|
|
12
|
+
export declare class AddressPolicy {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(options?: AddressPolicyOptions);
|
|
15
|
+
/** Return true when an address is public or explicitly exempted for this hostname. */
|
|
16
|
+
allows(hostname: string, address: string): boolean;
|
|
17
|
+
/** Return true only when a non-public address is covered by the configured exception. */
|
|
18
|
+
allowsNonPublic(hostname: string, address: string): boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Return true only for globally reachable unicast, matching the native provider. */
|
|
21
|
+
export declare function isPublicIpAddress(input: string): boolean;
|
|
22
|
+
/** WHATWG URL retains brackets around IPv6 hostnames; IP parsers do not. */
|
|
23
|
+
export declare function stripIpv6Brackets(hostname: string): string;
|
|
24
|
+
//# sourceMappingURL=address-policy.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client';
|
|
2
|
+
import type { LocaleKey } from './locales.ts';
|
|
3
|
+
export interface AllowlistSettings {
|
|
4
|
+
allowCidrs?: string[];
|
|
5
|
+
allowHostnames?: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface AllowlistCardProps {
|
|
8
|
+
scope: SettingsScope<AllowlistSettings>;
|
|
9
|
+
t: (key: LocaleKey) => string;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseLines(text: string): {
|
|
12
|
+
values: string[];
|
|
13
|
+
duplicate: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare function AllowlistCard({ scope, t }: AllowlistCardProps): import("react").JSX.Element | null;
|
|
16
|
+
//# sourceMappingURL=AllowlistCard.d.ts.map
|