@demigodmode/pi-web-agent 1.10.0 → 1.12.0
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 +27 -0
- package/README.md +19 -4
- package/dist/backends/config.d.ts +40 -0
- package/dist/backends/config.js +140 -1
- package/dist/backends/factory.d.ts +17 -1
- package/dist/backends/factory.js +170 -85
- package/dist/backends/failure.d.ts +11 -0
- package/dist/backends/failure.js +34 -0
- package/dist/backends/fallback-policy.d.ts +33 -0
- package/dist/backends/fallback-policy.js +239 -0
- package/dist/backends/provider-failure.d.ts +21 -0
- package/dist/backends/provider-failure.js +111 -0
- package/dist/backends/provider-health.d.ts +29 -0
- package/dist/backends/provider-health.js +49 -0
- package/dist/commands/web-agent-config.d.ts +17 -1
- package/dist/commands/web-agent-config.js +131 -7
- package/dist/extension.d.ts +1 -0
- package/dist/extension.js +49 -3
- package/dist/fetch/destination-policy.d.ts +32 -0
- package/dist/fetch/destination-policy.js +24 -0
- package/dist/fetch/firecrawl-fetch.js +64 -45
- package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
- package/dist/fetch/guard-proxy-fetch.js +82 -0
- package/dist/fetch/guard-proxy.d.ts +58 -0
- package/dist/fetch/guard-proxy.js +420 -0
- package/dist/fetch/guarded-fetch.d.ts +7 -0
- package/dist/fetch/guarded-fetch.js +75 -0
- package/dist/fetch/headless-fetch.d.ts +17 -2
- package/dist/fetch/headless-fetch.js +181 -9
- package/dist/fetch/http-fetch.js +16 -1
- package/dist/fetch/network-guard.d.ts +82 -0
- package/dist/fetch/network-guard.js +275 -0
- package/dist/fetch/proxy-fetch.d.ts +22 -0
- package/dist/fetch/proxy-fetch.js +46 -0
- package/dist/jiti-compat-run.d.ts +1 -0
- package/dist/jiti-compat-run.js +9 -0
- package/dist/jiti-compat.d.ts +32 -0
- package/dist/jiti-compat.js +215 -0
- package/dist/orchestration/answer-synthesizer.js +2 -0
- package/dist/orchestration/evidence-quality.d.ts +3 -2
- package/dist/orchestration/evidence-quality.js +2 -1
- package/dist/orchestration/index.d.ts +23 -0
- package/dist/orchestration/index.js +9 -2
- package/dist/orchestration/research-orchestrator.d.ts +21 -1
- package/dist/orchestration/research-orchestrator.js +40 -7
- package/dist/orchestration/research-types.d.ts +13 -1
- package/dist/orchestration/research-worker.js +38 -3
- package/dist/orchestration/stop-decider.js +3 -1
- package/dist/presentation/config-store.js +10 -0
- package/dist/presentation/explore-presentation.js +3 -1
- package/dist/presentation/fetch-presentation.js +16 -9
- package/dist/presentation/search-presentation.d.ts +2 -1
- package/dist/presentation/search-presentation.js +13 -1
- package/dist/readers/youtube-reader.d.ts +3 -1
- package/dist/readers/youtube-reader.js +11 -3
- package/dist/search/brave.d.ts +1 -2
- package/dist/search/brave.js +23 -80
- package/dist/search/duckduckgo.d.ts +7 -3
- package/dist/search/duckduckgo.js +17 -18
- package/dist/search/exa.d.ts +1 -2
- package/dist/search/exa.js +15 -76
- package/dist/search/fanout.d.ts +12 -0
- package/dist/search/fanout.js +86 -47
- package/dist/search/json-provider.d.ts +32 -0
- package/dist/search/json-provider.js +76 -0
- package/dist/search/searxng.d.ts +1 -2
- package/dist/search/searxng.js +15 -57
- package/dist/search/tavily.d.ts +1 -2
- package/dist/search/tavily.js +17 -74
- package/dist/search/youcom.d.ts +1 -2
- package/dist/search/youcom.js +15 -76
- package/dist/tools/web-explore.d.ts +9 -0
- package/dist/tools/web-explore.js +16 -2
- package/dist/tools/web-search.js +41 -103
- package/dist/types.d.ts +40 -0
- package/package.json +4 -3
- package/scripts/patch-jiti-compat.mjs +52 -8
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
2
|
import { extractReadableContentSafely } from '../extract/readability.js';
|
|
3
3
|
import { resolveBrowserExecutable } from './browser-resolution.js';
|
|
4
|
+
import { BLOCKED_HEADER } from './guard-proxy.js';
|
|
5
|
+
import { BLOCKED_PRIVATE_ADDRESS, BlockedAddressError, UPSTREAM_PROXY_REFUSED } from './network-guard.js';
|
|
4
6
|
function cleanupRenderedText(text) {
|
|
5
7
|
let cleaned = text.replace(/(Show more)(\s+\1){1,}/gi, '$1');
|
|
6
8
|
cleaned = cleaned.replace(/(Privacy Terms)(\s+\1){1,}/gi, '$1');
|
|
7
9
|
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
8
10
|
return cleaned;
|
|
9
11
|
}
|
|
10
|
-
|
|
12
|
+
function errorResult(url, code, message) {
|
|
13
|
+
const guard = code === BLOCKED_PRIVATE_ADDRESS || code === UPSTREAM_PROXY_REFUSED;
|
|
14
|
+
return {
|
|
15
|
+
status: 'error',
|
|
16
|
+
url,
|
|
17
|
+
metadata: { method: 'headless', cacheHit: false },
|
|
18
|
+
error: { code, message, ...(guard ? { failure: { kind: 'guard_refused' } } : {}) }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function hostnameOf(url) {
|
|
22
|
+
try {
|
|
23
|
+
return new URL(url).hostname;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Same normalization the guard applies, so refusal hosts and navigation hosts compare equal. */
|
|
30
|
+
function normalizeHost(host) {
|
|
31
|
+
return host.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.+$/, '');
|
|
32
|
+
}
|
|
33
|
+
export async function headlessFetch(url, { configuredPath, proxy, guard, guardProxy, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless, proxy }) => chromium.launch(executablePath ? { executablePath, headless, ...(proxy ? { proxy } : {}) } : { headless, ...(proxy ? { proxy } : {}) }), now = () => Date.now() } = {}) {
|
|
34
|
+
if (guard) {
|
|
35
|
+
const hostname = hostnameOf(url);
|
|
36
|
+
if (!guardProxy) {
|
|
37
|
+
// Enforcement lives in the guard proxy. Without it, loading the page would be unguarded.
|
|
38
|
+
return errorResult(url, BLOCKED_PRIVATE_ADDRESS, `Blocked ${hostname ?? url}: the browser could not enforce the private address guard.`);
|
|
39
|
+
}
|
|
40
|
+
if (hostname) {
|
|
41
|
+
// Early, clearer refusal for an obviously blocked main url. The guard
|
|
42
|
+
// proxy is what actually enforces the policy for every connection.
|
|
43
|
+
const verdict = await guard.checkHost(hostname);
|
|
44
|
+
if (!verdict.allowed) {
|
|
45
|
+
const error = new BlockedAddressError(verdict.host, verdict.address);
|
|
46
|
+
return errorResult(url, error.code, error.message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
11
50
|
const resolved = await resolveBrowser({ configuredPath });
|
|
12
51
|
if (!resolved.ok && resolved.error.code === 'CONFIGURED_BROWSER_NOT_FOUND') {
|
|
13
52
|
return {
|
|
@@ -17,23 +56,149 @@ export async function headlessFetch(url, { configuredPath, resolveBrowser = (opt
|
|
|
17
56
|
error: resolved.error
|
|
18
57
|
};
|
|
19
58
|
}
|
|
59
|
+
let enforcement;
|
|
60
|
+
if (guard && guardProxy) {
|
|
61
|
+
let activeProxy;
|
|
62
|
+
try {
|
|
63
|
+
activeProxy = await guardProxy();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// The guard proxy could not start (backend set closed, or the listener
|
|
67
|
+
// failed). Enforcement lives there, so the browser must not launch.
|
|
68
|
+
return errorResult(url, BLOCKED_PRIVATE_ADDRESS, `Blocked ${hostnameOf(url) ?? url}: the private address guard is not available, so the browser was not started.`);
|
|
69
|
+
}
|
|
70
|
+
const client = activeProxy.client('headless');
|
|
71
|
+
enforcement = {
|
|
72
|
+
proxy: activeProxy,
|
|
73
|
+
username: client.username,
|
|
74
|
+
since: activeProxy.sequence(),
|
|
75
|
+
// `<-loopback>` removes Chromium's implicit loopback bypass, so localhost
|
|
76
|
+
// and link-local connections go through the guard proxy too.
|
|
77
|
+
launchProxy: { server: client.server, username: client.username, password: client.password, bypass: '<-loopback>' }
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const effectiveProxy = enforcement ? enforcement.launchProxy : proxy;
|
|
20
81
|
const browserName = resolved.ok ? resolved.browser : 'chromium';
|
|
21
82
|
const launchOptions = resolved.ok
|
|
22
|
-
? { executablePath: resolved.executablePath, headless: true }
|
|
23
|
-
: { headless: true };
|
|
83
|
+
? { executablePath: resolved.executablePath, headless: true, ...(effectiveProxy ? { proxy: effectiveProxy } : {}) }
|
|
84
|
+
: { headless: true, ...(effectiveProxy ? { proxy: effectiveProxy } : {}) };
|
|
85
|
+
// Browser requests that failed or were refused by the proxy, from passive
|
|
86
|
+
// page events only. Navigation hosts decide whether a refusal caused the
|
|
87
|
+
// navigation error; subresource failures are counted one per request.
|
|
88
|
+
const failedNavigationHosts = new Set();
|
|
89
|
+
const failedSubresourceHosts = [];
|
|
90
|
+
const seenRequests = new WeakSet();
|
|
91
|
+
const refusals = () => (enforcement ? enforcement.proxy.refusalsSince(enforcement.username, enforcement.since) : []);
|
|
92
|
+
const navigationRefusal = () => refusals().find((entry) => failedNavigationHosts.has(entry.host));
|
|
93
|
+
const subresourceRefusals = () => {
|
|
94
|
+
const refusedHosts = new Set(refusals().map((entry) => entry.host));
|
|
95
|
+
return failedSubresourceHosts.filter((host) => refusedHosts.has(host)).length;
|
|
96
|
+
};
|
|
24
97
|
let browser;
|
|
25
98
|
let context;
|
|
26
99
|
let page;
|
|
27
100
|
try {
|
|
28
101
|
browser = await launchBrowser(launchOptions);
|
|
29
|
-
|
|
30
|
-
|
|
102
|
+
// Service workers can fetch on a page's behalf; blocking them keeps the page's traffic simple to account for.
|
|
103
|
+
context = await browser.newContext(enforcement ? { serviceWorkers: 'block' } : undefined);
|
|
104
|
+
if (enforcement) {
|
|
105
|
+
// Watch every page in the context: popups opened by the page can hit blocked
|
|
106
|
+
// hosts too. Only the primary page's main-frame navigation is "the navigation".
|
|
107
|
+
let primaryPage;
|
|
108
|
+
const recordRequest = (request) => {
|
|
109
|
+
try {
|
|
110
|
+
if (!request || seenRequests.has(request))
|
|
111
|
+
return;
|
|
112
|
+
seenRequests.add(request);
|
|
113
|
+
const host = normalizeHost(new URL(request.url()).hostname);
|
|
114
|
+
if (primaryPage && request.isNavigationRequest() && request.frame() === primaryPage.mainFrame()) {
|
|
115
|
+
failedNavigationHosts.add(host);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
failedSubresourceHosts.push(host);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Unparseable URL or a detached frame: nothing to attribute.
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const watchPage = (watched) => {
|
|
126
|
+
watched?.on?.('requestfailed', recordRequest);
|
|
127
|
+
watched?.on?.('response', (response) => {
|
|
128
|
+
try {
|
|
129
|
+
if (response.headers()[BLOCKED_HEADER])
|
|
130
|
+
recordRequest(response.request());
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// ignore
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
// Best effort: Playwright doesn't say why a WebSocket died, so an error or a
|
|
137
|
+
// close before any frame counts as failed. Only refused hosts get counted.
|
|
138
|
+
watched?.on?.('websocket', (ws) => {
|
|
139
|
+
let host;
|
|
140
|
+
try {
|
|
141
|
+
host = normalizeHost(new URL(ws.url()).hostname);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
let framed = false;
|
|
147
|
+
let recorded = false;
|
|
148
|
+
const fail = () => {
|
|
149
|
+
if (recorded)
|
|
150
|
+
return;
|
|
151
|
+
recorded = true;
|
|
152
|
+
failedSubresourceHosts.push(host);
|
|
153
|
+
};
|
|
154
|
+
ws.on?.('framereceived', () => (framed = true));
|
|
155
|
+
ws.on?.('framesent', () => (framed = true));
|
|
156
|
+
ws.on?.('socketerror', fail);
|
|
157
|
+
ws.on?.('close', () => {
|
|
158
|
+
if (!framed)
|
|
159
|
+
fail();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
const watchedPages = new WeakSet();
|
|
164
|
+
const watchOnce = (candidate) => {
|
|
165
|
+
if (!candidate || watchedPages.has(candidate))
|
|
166
|
+
return;
|
|
167
|
+
watchedPages.add(candidate);
|
|
168
|
+
watchPage(candidate);
|
|
169
|
+
};
|
|
170
|
+
// Registered before newPage(), so the primary page's own 'page' event is covered too.
|
|
171
|
+
context.on?.('page', watchOnce);
|
|
172
|
+
page = await context.newPage();
|
|
173
|
+
primaryPage = page;
|
|
174
|
+
watchOnce(page);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
page = await context.newPage();
|
|
178
|
+
}
|
|
31
179
|
const startedAt = now();
|
|
32
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
180
|
+
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
181
|
+
if (enforcement && response?.headers?.()[BLOCKED_HEADER]) {
|
|
182
|
+
// Normally already recorded by the 'response' event; this covers it if not.
|
|
183
|
+
try {
|
|
184
|
+
const request = response.request?.();
|
|
185
|
+
if (request && !seenRequests.has(request)) {
|
|
186
|
+
seenRequests.add(request);
|
|
187
|
+
failedNavigationHosts.add(normalizeHost(new URL(request.url()).hostname));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// ignore
|
|
192
|
+
}
|
|
193
|
+
const cause = navigationRefusal();
|
|
194
|
+
if (cause)
|
|
195
|
+
return errorResult(url, cause.error.code, cause.error.message);
|
|
196
|
+
}
|
|
33
197
|
await page.waitForLoadState('load', { timeout: 10000 });
|
|
34
198
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => undefined);
|
|
35
199
|
const html = await page.content();
|
|
36
200
|
const finishedAt = now();
|
|
201
|
+
const blockedSubresources = subresourceRefusals();
|
|
37
202
|
const extraction = extractReadableContentSafely(html);
|
|
38
203
|
const cleanedContent = {
|
|
39
204
|
...extraction.content,
|
|
@@ -47,7 +212,8 @@ export async function headlessFetch(url, { configuredPath, resolveBrowser = (opt
|
|
|
47
212
|
method: 'headless',
|
|
48
213
|
cacheHit: false,
|
|
49
214
|
browser: browserName,
|
|
50
|
-
navigationMs: finishedAt - startedAt
|
|
215
|
+
navigationMs: finishedAt - startedAt,
|
|
216
|
+
...(blockedSubresources > 0 ? { blockedSubresources } : {})
|
|
51
217
|
},
|
|
52
218
|
error: {
|
|
53
219
|
code: 'HEADLESS_EXTRACTION_WEAK',
|
|
@@ -64,18 +230,24 @@ export async function headlessFetch(url, { configuredPath, resolveBrowser = (opt
|
|
|
64
230
|
cacheHit: false,
|
|
65
231
|
browser: browserName,
|
|
66
232
|
navigationMs: finishedAt - startedAt,
|
|
67
|
-
truncated: cleanedContent.text.length >= 4000
|
|
233
|
+
truncated: cleanedContent.text.length >= 4000,
|
|
234
|
+
...(blockedSubresources > 0 ? { blockedSubresources } : {})
|
|
68
235
|
}
|
|
69
236
|
};
|
|
70
237
|
}
|
|
71
238
|
catch (error) {
|
|
239
|
+
const cause = navigationRefusal();
|
|
240
|
+
if (cause)
|
|
241
|
+
return errorResult(url, cause.error.code, cause.error.message);
|
|
242
|
+
const blockedSubresources = subresourceRefusals();
|
|
72
243
|
return {
|
|
73
244
|
status: 'error',
|
|
74
245
|
url,
|
|
75
246
|
metadata: {
|
|
76
247
|
method: 'headless',
|
|
77
248
|
cacheHit: false,
|
|
78
|
-
browser: browserName
|
|
249
|
+
browser: browserName,
|
|
250
|
+
...(blockedSubresources > 0 ? { blockedSubresources } : {})
|
|
79
251
|
},
|
|
80
252
|
error: {
|
|
81
253
|
code: 'HEADLESS_NAVIGATION_FAILED',
|
package/dist/fetch/http-fetch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { extractReadableContentSafely } from '../extract/readability.js';
|
|
2
|
+
import { findGuardError } from './network-guard.js';
|
|
2
3
|
function looksLikeScriptShell(html) {
|
|
3
4
|
const lower = html.toLowerCase();
|
|
4
5
|
return lower.includes('<script') && (lower.includes('id="app"') || lower.includes('id="root"'));
|
|
@@ -15,7 +16,21 @@ function isWeakHttpContent(options) {
|
|
|
15
16
|
}
|
|
16
17
|
export function createHttpFetcher({ fetchImpl = fetch } = {}) {
|
|
17
18
|
return async function httpFetch(url) {
|
|
18
|
-
|
|
19
|
+
let response;
|
|
20
|
+
try {
|
|
21
|
+
response = await fetchImpl(url);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const blocked = findGuardError(error);
|
|
25
|
+
if (!blocked)
|
|
26
|
+
throw error;
|
|
27
|
+
return {
|
|
28
|
+
status: 'error',
|
|
29
|
+
url,
|
|
30
|
+
metadata: { method: 'http', cacheHit: false },
|
|
31
|
+
error: { code: blocked.code, message: blocked.message, failure: { kind: 'guard_refused' } }
|
|
32
|
+
};
|
|
33
|
+
}
|
|
19
34
|
const contentType = response.headers.get('content-type') ?? '';
|
|
20
35
|
if (!contentType.includes('text/html')) {
|
|
21
36
|
return {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides whether a destination the *model* chose is safe to fetch (#53).
|
|
3
|
+
*
|
|
4
|
+
* web_explore fetches URLs the model picks, partly from pages it just read, so
|
|
5
|
+
* a hostile page can point it at cloud metadata (169.254.169.254), services on
|
|
6
|
+
* localhost, or the user's LAN. This module only answers "is this address
|
|
7
|
+
* allowed"; guarded-fetch.ts and headless-fetch.ts enforce it. User-configured
|
|
8
|
+
* endpoints (search APIs, SearXNG, Firecrawl, the proxy) never go through it.
|
|
9
|
+
*/
|
|
10
|
+
export type Cidr = {
|
|
11
|
+
family: 4 | 6;
|
|
12
|
+
network: bigint;
|
|
13
|
+
prefix: number;
|
|
14
|
+
};
|
|
15
|
+
export type LookupFn = (host: string) => Promise<Array<{
|
|
16
|
+
address: string;
|
|
17
|
+
family: number;
|
|
18
|
+
}>>;
|
|
19
|
+
export type NetworkGuardConfig = {
|
|
20
|
+
allowRanges?: string[];
|
|
21
|
+
};
|
|
22
|
+
export type GuardVerdict = {
|
|
23
|
+
allowed: true;
|
|
24
|
+
unresolved?: true;
|
|
25
|
+
} | {
|
|
26
|
+
allowed: false;
|
|
27
|
+
host: string;
|
|
28
|
+
address: string;
|
|
29
|
+
};
|
|
30
|
+
export type HostResolution = {
|
|
31
|
+
status: 'allowed';
|
|
32
|
+
host: string;
|
|
33
|
+
addresses: string[];
|
|
34
|
+
} | {
|
|
35
|
+
status: 'blocked';
|
|
36
|
+
host: string;
|
|
37
|
+
address: string;
|
|
38
|
+
} | {
|
|
39
|
+
status: 'unresolved';
|
|
40
|
+
host: string;
|
|
41
|
+
};
|
|
42
|
+
export type NetworkGuard = {
|
|
43
|
+
isBlockedAddress(address: string): boolean;
|
|
44
|
+
/** Resolves once and checks every answer. The guard proxy connects to one of `addresses`, never re-resolving. */
|
|
45
|
+
resolveHost(host: string): Promise<HostResolution>;
|
|
46
|
+
checkHost(host: string): Promise<GuardVerdict>;
|
|
47
|
+
assertUrlAllowed(url: string): Promise<void>;
|
|
48
|
+
};
|
|
49
|
+
export declare const BLOCKED_PRIVATE_ADDRESS = "BLOCKED_PRIVATE_ADDRESS";
|
|
50
|
+
export declare class BlockedAddressError extends Error {
|
|
51
|
+
readonly host: string;
|
|
52
|
+
readonly address: string;
|
|
53
|
+
readonly code = "BLOCKED_PRIVATE_ADDRESS";
|
|
54
|
+
constructor(host: string, address: string);
|
|
55
|
+
}
|
|
56
|
+
/** No address could be verified, so we refuse rather than let something else resolve it. */
|
|
57
|
+
export declare class UnverifiedDestinationError extends Error {
|
|
58
|
+
readonly host: string;
|
|
59
|
+
readonly code = "BLOCKED_PRIVATE_ADDRESS";
|
|
60
|
+
constructor(host: string);
|
|
61
|
+
}
|
|
62
|
+
export declare const UPSTREAM_PROXY_REFUSED = "UPSTREAM_PROXY_REFUSED";
|
|
63
|
+
/** The user's upstream proxy would not accept the approved IP. We never retry by hostname. */
|
|
64
|
+
export declare class UpstreamProxyRefusedError extends Error {
|
|
65
|
+
readonly host: string;
|
|
66
|
+
readonly target: string;
|
|
67
|
+
readonly status: number | string;
|
|
68
|
+
readonly code = "UPSTREAM_PROXY_REFUSED";
|
|
69
|
+
constructor(host: string, target: string, status: number | string);
|
|
70
|
+
}
|
|
71
|
+
export type GuardError = BlockedAddressError | UnverifiedDestinationError | UpstreamProxyRefusedError;
|
|
72
|
+
export declare function findGuardError(error: unknown): GuardError | undefined;
|
|
73
|
+
/** undici wraps connect errors as `TypeError: fetch failed` with the real error in `cause`. */
|
|
74
|
+
export declare function findBlockedAddressError(error: unknown): BlockedAddressError | undefined;
|
|
75
|
+
export declare function parseCidr(text: string): Cidr | undefined;
|
|
76
|
+
/** Parses an allow list, dropping invalid entries and anything that allows every address. */
|
|
77
|
+
export declare function usableAllowRanges(allowRanges?: string[]): Cidr[];
|
|
78
|
+
export declare function createNetworkGuard(config?: NetworkGuardConfig, { lookup, lookupTimeoutMs }?: {
|
|
79
|
+
lookup?: LookupFn;
|
|
80
|
+
/** A lookup that hasn't answered by then counts as unresolved, so no caller waits on DNS forever. */
|
|
81
|
+
lookupTimeoutMs?: number;
|
|
82
|
+
}): NetworkGuard;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
export const BLOCKED_PRIVATE_ADDRESS = 'BLOCKED_PRIVATE_ADDRESS';
|
|
4
|
+
export class BlockedAddressError extends Error {
|
|
5
|
+
host;
|
|
6
|
+
address;
|
|
7
|
+
code = BLOCKED_PRIVATE_ADDRESS;
|
|
8
|
+
constructor(host, address) {
|
|
9
|
+
super(`Blocked ${host}: resolves to private address ${address}. ` +
|
|
10
|
+
'Add it to backends.network.allowRanges if this is intended.');
|
|
11
|
+
this.host = host;
|
|
12
|
+
this.address = address;
|
|
13
|
+
this.name = 'BlockedAddressError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** No address could be verified, so we refuse rather than let something else resolve it. */
|
|
17
|
+
export class UnverifiedDestinationError extends Error {
|
|
18
|
+
host;
|
|
19
|
+
code = BLOCKED_PRIVATE_ADDRESS;
|
|
20
|
+
constructor(host) {
|
|
21
|
+
super(`Blocked ${host}: could not verify its address before connecting.`);
|
|
22
|
+
this.host = host;
|
|
23
|
+
this.name = 'UnverifiedDestinationError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const UPSTREAM_PROXY_REFUSED = 'UPSTREAM_PROXY_REFUSED';
|
|
27
|
+
/** The user's upstream proxy would not accept the approved IP. We never retry by hostname. */
|
|
28
|
+
export class UpstreamProxyRefusedError extends Error {
|
|
29
|
+
host;
|
|
30
|
+
target;
|
|
31
|
+
status;
|
|
32
|
+
code = UPSTREAM_PROXY_REFUSED;
|
|
33
|
+
constructor(host, target, status) {
|
|
34
|
+
super(`Upstream proxy refused ${target} for ${host} (HTTP ${status}). ` +
|
|
35
|
+
'If it only accepts hostnames, set backends.network.trustProxyDns to trust it to enforce private-address restrictions.');
|
|
36
|
+
this.host = host;
|
|
37
|
+
this.target = target;
|
|
38
|
+
this.status = status;
|
|
39
|
+
this.name = 'UpstreamProxyRefusedError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function findGuardError(error) {
|
|
43
|
+
let current = error;
|
|
44
|
+
for (let depth = 0; depth < 5 && current; depth += 1) {
|
|
45
|
+
if (current instanceof BlockedAddressError ||
|
|
46
|
+
current instanceof UnverifiedDestinationError ||
|
|
47
|
+
current instanceof UpstreamProxyRefusedError) {
|
|
48
|
+
return current;
|
|
49
|
+
}
|
|
50
|
+
current = current.cause;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
/** undici wraps connect errors as `TypeError: fetch failed` with the real error in `cause`. */
|
|
55
|
+
export function findBlockedAddressError(error) {
|
|
56
|
+
let current = error;
|
|
57
|
+
for (let depth = 0; depth < 5 && current; depth += 1) {
|
|
58
|
+
if (current instanceof BlockedAddressError)
|
|
59
|
+
return current;
|
|
60
|
+
current = current.cause;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const IPV4_WIDTH = 32;
|
|
65
|
+
const IPV6_WIDTH = 128;
|
|
66
|
+
function ipv4ToBigInt(address) {
|
|
67
|
+
const parts = address.split('.');
|
|
68
|
+
if (parts.length !== 4)
|
|
69
|
+
return undefined;
|
|
70
|
+
let value = 0n;
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
if (!/^\d{1,3}$/.test(part))
|
|
73
|
+
return undefined;
|
|
74
|
+
const octet = Number(part);
|
|
75
|
+
if (octet > 255)
|
|
76
|
+
return undefined;
|
|
77
|
+
value = (value << 8n) | BigInt(octet);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
function ipv6ToBigInt(address) {
|
|
82
|
+
let text = address.split('%')[0];
|
|
83
|
+
// Embedded IPv4 tail, e.g. ::ffff:127.0.0.1
|
|
84
|
+
if (text.includes('.')) {
|
|
85
|
+
const lastColon = text.lastIndexOf(':');
|
|
86
|
+
const v4 = ipv4ToBigInt(text.slice(lastColon + 1));
|
|
87
|
+
if (v4 === undefined)
|
|
88
|
+
return undefined;
|
|
89
|
+
const high = ((v4 >> 16n) & 0xffffn).toString(16);
|
|
90
|
+
const low = (v4 & 0xffffn).toString(16);
|
|
91
|
+
text = `${text.slice(0, lastColon + 1)}${high}:${low}`;
|
|
92
|
+
}
|
|
93
|
+
const halves = text.split('::');
|
|
94
|
+
if (halves.length > 2)
|
|
95
|
+
return undefined;
|
|
96
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
97
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
98
|
+
const missing = 8 - head.length - tail.length;
|
|
99
|
+
if (halves.length === 1 && missing !== 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
if (halves.length === 2 && missing < 1)
|
|
102
|
+
return undefined;
|
|
103
|
+
const groups = [...head, ...new Array(halves.length === 2 ? missing : 0).fill('0'), ...tail];
|
|
104
|
+
let value = 0n;
|
|
105
|
+
for (const group of groups) {
|
|
106
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group))
|
|
107
|
+
return undefined;
|
|
108
|
+
value = (value << 16n) | BigInt(parseInt(group, 16));
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
const NAT64_WELL_KNOWN_NET = ipv6ToBigInt('64:ff9b::') >> 32n;
|
|
113
|
+
const NAT64_LOCAL_NET = ipv6ToBigInt('64:ff9b:1::') >> 80n;
|
|
114
|
+
const SIXTOFOUR_NET = ipv6ToBigInt('2002::') >> 112n;
|
|
115
|
+
function parseAddress(address) {
|
|
116
|
+
const family = isIP(address.split('%')[0]);
|
|
117
|
+
if (family === 4) {
|
|
118
|
+
const value = ipv4ToBigInt(address);
|
|
119
|
+
return value === undefined ? undefined : { family: 4, value };
|
|
120
|
+
}
|
|
121
|
+
if (family === 6) {
|
|
122
|
+
const value = ipv6ToBigInt(address);
|
|
123
|
+
if (value === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
// IPv4-mapped (::ffff:a.b.c.d): judge it as the IPv4 address it really is.
|
|
126
|
+
if (value >> 32n === 0xffffn)
|
|
127
|
+
return { family: 4, value: value & 0xffffffffn };
|
|
128
|
+
// IPv4-compatible (deprecated ::a.b.c.d), but not the special addresses :: and ::1.
|
|
129
|
+
if (value >> 32n === 0n && value > 1n)
|
|
130
|
+
return { family: 4, value };
|
|
131
|
+
// NAT64 (64:ff9b::/96 well-known, 64:ff9b:1::/48 local-use): IPv4 is the low 32 bits.
|
|
132
|
+
// DNS64 networks translate every IPv4-only host into these prefixes, so we cannot
|
|
133
|
+
// block the prefix outright -- judge the embedded address on its own merits instead.
|
|
134
|
+
if (value >> 32n === NAT64_WELL_KNOWN_NET)
|
|
135
|
+
return { family: 4, value: value & 0xffffffffn };
|
|
136
|
+
// Local-use NAT64 lets the operator pick a prefix shorter than /96; only the /96
|
|
137
|
+
// case has the IPv4 address in the low 32 bits, so only unwrap when bits 48-95
|
|
138
|
+
// (between the /48 prefix and the embedded address) are all zero.
|
|
139
|
+
if (value >> 80n === NAT64_LOCAL_NET && ((value >> 32n) & 0xffffffffffffn) === 0n) {
|
|
140
|
+
return { family: 4, value: value & 0xffffffffn };
|
|
141
|
+
}
|
|
142
|
+
// 6to4 (2002::/16): IPv4 is bits 16-48 from the top.
|
|
143
|
+
if (value >> 112n === SIXTOFOUR_NET)
|
|
144
|
+
return { family: 4, value: (value >> 80n) & 0xffffffffn };
|
|
145
|
+
return { family: 6, value };
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
export function parseCidr(text) {
|
|
150
|
+
const trimmed = text.trim();
|
|
151
|
+
const slash = trimmed.indexOf('/');
|
|
152
|
+
if (slash <= 0)
|
|
153
|
+
return undefined;
|
|
154
|
+
const prefixText = trimmed.slice(slash + 1);
|
|
155
|
+
if (!/^\d{1,3}$/.test(prefixText))
|
|
156
|
+
return undefined;
|
|
157
|
+
const prefix = Number(prefixText);
|
|
158
|
+
const address = parseAddress(trimmed.slice(0, slash));
|
|
159
|
+
if (!address)
|
|
160
|
+
return undefined;
|
|
161
|
+
// A mapped IPv6 CIDR would be ambiguous; require the plain family.
|
|
162
|
+
const family = isIP(trimmed.slice(0, slash));
|
|
163
|
+
const width = family === 4 ? IPV4_WIDTH : IPV6_WIDTH;
|
|
164
|
+
if (prefix > width)
|
|
165
|
+
return undefined;
|
|
166
|
+
const value = family === 4 ? ipv4ToBigInt(trimmed.slice(0, slash)) : ipv6ToBigInt(trimmed.slice(0, slash));
|
|
167
|
+
const shift = BigInt(width - prefix);
|
|
168
|
+
return { family, network: (value >> shift) << shift, prefix };
|
|
169
|
+
}
|
|
170
|
+
function cidrContains(cidr, address) {
|
|
171
|
+
if (cidr.family !== address.family)
|
|
172
|
+
return false;
|
|
173
|
+
const width = cidr.family === 4 ? IPV4_WIDTH : IPV6_WIDTH;
|
|
174
|
+
const shift = BigInt(width - cidr.prefix);
|
|
175
|
+
return address.value >> shift === cidr.network >> shift;
|
|
176
|
+
}
|
|
177
|
+
const BLOCKED_RANGES = [
|
|
178
|
+
'0.0.0.0/8',
|
|
179
|
+
'10.0.0.0/8',
|
|
180
|
+
'100.64.0.0/10',
|
|
181
|
+
'127.0.0.0/8',
|
|
182
|
+
'169.254.0.0/16',
|
|
183
|
+
'172.16.0.0/12',
|
|
184
|
+
'192.0.0.0/24',
|
|
185
|
+
'192.168.0.0/16',
|
|
186
|
+
'198.18.0.0/15',
|
|
187
|
+
'224.0.0.0/4',
|
|
188
|
+
'240.0.0.0/4',
|
|
189
|
+
'::1/128',
|
|
190
|
+
'::/128',
|
|
191
|
+
'64:ff9b:1::/48',
|
|
192
|
+
'fc00::/7',
|
|
193
|
+
'fe80::/10',
|
|
194
|
+
'fec0::/10',
|
|
195
|
+
'ff00::/8'
|
|
196
|
+
].map((range) => parseCidr(range));
|
|
197
|
+
/** Parses an allow list, dropping invalid entries and anything that allows every address. */
|
|
198
|
+
export function usableAllowRanges(allowRanges = []) {
|
|
199
|
+
return allowRanges
|
|
200
|
+
.map((range) => parseCidr(range))
|
|
201
|
+
.filter((cidr) => cidr !== undefined && cidr.prefix > 0);
|
|
202
|
+
}
|
|
203
|
+
const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000;
|
|
204
|
+
const defaultLookup = (host) => dnsLookup(host, { all: true, verbatim: true });
|
|
205
|
+
function stripBrackets(host) {
|
|
206
|
+
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
207
|
+
}
|
|
208
|
+
export function createNetworkGuard(config = {}, { lookup = defaultLookup, lookupTimeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS } = {}) {
|
|
209
|
+
const allow = usableAllowRanges(config.allowRanges);
|
|
210
|
+
function isBlockedAddress(address) {
|
|
211
|
+
const parsed = parseAddress(address);
|
|
212
|
+
if (!parsed)
|
|
213
|
+
return true; // fail closed
|
|
214
|
+
if (allow.some((cidr) => cidrContains(cidr, parsed)))
|
|
215
|
+
return false;
|
|
216
|
+
return BLOCKED_RANGES.some((cidr) => cidrContains(cidr, parsed));
|
|
217
|
+
}
|
|
218
|
+
async function resolveHost(rawHost) {
|
|
219
|
+
const host = stripBrackets(rawHost.toLowerCase()).replace(/\.+$/, '');
|
|
220
|
+
if (host === 'localhost' || host.endsWith('.localhost')) {
|
|
221
|
+
return isBlockedAddress('127.0.0.1')
|
|
222
|
+
? { status: 'blocked', host, address: '127.0.0.1' }
|
|
223
|
+
: { status: 'allowed', host, addresses: ['127.0.0.1'] };
|
|
224
|
+
}
|
|
225
|
+
if (isIP(host)) {
|
|
226
|
+
return isBlockedAddress(host)
|
|
227
|
+
? { status: 'blocked', host, address: host }
|
|
228
|
+
: { status: 'allowed', host, addresses: [host] };
|
|
229
|
+
}
|
|
230
|
+
let answers;
|
|
231
|
+
let timer;
|
|
232
|
+
try {
|
|
233
|
+
answers = await Promise.race([
|
|
234
|
+
lookup(host),
|
|
235
|
+
new Promise((resolve) => {
|
|
236
|
+
timer = setTimeout(() => resolve(undefined), lookupTimeoutMs);
|
|
237
|
+
timer.unref?.();
|
|
238
|
+
})
|
|
239
|
+
]);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return { status: 'unresolved', host };
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
}
|
|
247
|
+
if (!answers || answers.length === 0)
|
|
248
|
+
return { status: 'unresolved', host };
|
|
249
|
+
// Any private answer blocks: which address a connection would pick is not ours to control.
|
|
250
|
+
const blocked = answers.find((entry) => isBlockedAddress(entry.address));
|
|
251
|
+
if (blocked)
|
|
252
|
+
return { status: 'blocked', host, address: blocked.address };
|
|
253
|
+
return { status: 'allowed', host, addresses: answers.map((entry) => entry.address) };
|
|
254
|
+
}
|
|
255
|
+
async function checkHost(rawHost) {
|
|
256
|
+
const resolution = await resolveHost(rawHost);
|
|
257
|
+
if (resolution.status === 'blocked') {
|
|
258
|
+
return { allowed: false, host: resolution.host, address: resolution.address };
|
|
259
|
+
}
|
|
260
|
+
return resolution.status === 'unresolved' ? { allowed: true, unresolved: true } : { allowed: true };
|
|
261
|
+
}
|
|
262
|
+
async function assertUrlAllowed(url) {
|
|
263
|
+
let parsed;
|
|
264
|
+
try {
|
|
265
|
+
parsed = new URL(url);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return; // not a URL; the tools reject it with UNSUPPORTED_URL
|
|
269
|
+
}
|
|
270
|
+
const verdict = await checkHost(parsed.hostname);
|
|
271
|
+
if (!verdict.allowed)
|
|
272
|
+
throw new BlockedAddressError(verdict.host, verdict.address);
|
|
273
|
+
}
|
|
274
|
+
return { isBlockedAddress, resolveHost, checkHost, assertUrlAllowed };
|
|
275
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ProxyConfig } from '../backends/config.js';
|
|
2
|
+
export type ProxyCredentials = {
|
|
3
|
+
username?: string;
|
|
4
|
+
password?: string;
|
|
5
|
+
};
|
|
6
|
+
export type ProxyFetchOptions = {
|
|
7
|
+
/** TLS options for the tunneled connection to the origin (https targets). */
|
|
8
|
+
tls?: {
|
|
9
|
+
rejectUnauthorized?: boolean;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Resolve proxy credentials from the config, falling back to environment
|
|
14
|
+
* variables so secrets can stay out of config files (same policy as API keys).
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveProxyCredentials(proxy: ProxyConfig, env?: NodeJS.ProcessEnv): ProxyCredentials;
|
|
17
|
+
/**
|
|
18
|
+
* Build a fetch implementation that routes all traffic through the configured
|
|
19
|
+
* HTTP/HTTPS proxy. Uses undici (the engine behind Node's global fetch) with a
|
|
20
|
+
* ProxyAgent dispatcher while keeping the standard fetch API surface.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createProxyFetch(proxy: ProxyConfig, options?: ProxyFetchOptions): typeof fetch;
|