@moxxy/plugin-browser 0.27.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/LICENSE +21 -0
- package/dist/browser-session.d.ts +43 -0
- package/dist/browser-session.d.ts.map +1 -0
- package/dist/browser-session.js +500 -0
- package/dist/browser-session.js.map +1 -0
- package/dist/browser-surface.d.ts +3 -0
- package/dist/browser-surface.d.ts.map +1 -0
- package/dist/browser-surface.js +255 -0
- package/dist/browser-surface.js.map +1 -0
- package/dist/html-extract.d.ts +20 -0
- package/dist/html-extract.d.ts.map +1 -0
- package/dist/html-extract.js +122 -0
- package/dist/html-extract.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/sidecar/dispatch.d.ts +18 -0
- package/dist/sidecar/dispatch.d.ts.map +1 -0
- package/dist/sidecar/dispatch.js +294 -0
- package/dist/sidecar/dispatch.js.map +1 -0
- package/dist/sidecar/install.d.ts +37 -0
- package/dist/sidecar/install.d.ts.map +1 -0
- package/dist/sidecar/install.js +242 -0
- package/dist/sidecar/install.js.map +1 -0
- package/dist/sidecar/types.d.ts +97 -0
- package/dist/sidecar/types.d.ts.map +1 -0
- package/dist/sidecar/types.js +20 -0
- package/dist/sidecar/types.js.map +1 -0
- package/dist/sidecar.d.ts +31 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +165 -0
- package/dist/sidecar.js.map +1 -0
- package/dist/ssrf-guard.d.ts +43 -0
- package/dist/ssrf-guard.d.ts.map +1 -0
- package/dist/ssrf-guard.js +164 -0
- package/dist/ssrf-guard.js.map +1 -0
- package/dist/web-fetch.d.ts +23 -0
- package/dist/web-fetch.d.ts.map +1 -0
- package/dist/web-fetch.js +253 -0
- package/dist/web-fetch.js.map +1 -0
- package/package.json +74 -0
- package/src/browser-session.test.ts +333 -0
- package/src/browser-session.ts +567 -0
- package/src/browser-surface.test.ts +367 -0
- package/src/browser-surface.ts +275 -0
- package/src/html-extract.ts +152 -0
- package/src/index.ts +35 -0
- package/src/sidecar/dispatch.test.ts +313 -0
- package/src/sidecar/dispatch.ts +314 -0
- package/src/sidecar/install.ts +283 -0
- package/src/sidecar/types.test.ts +26 -0
- package/src/sidecar/types.ts +114 -0
- package/src/sidecar.test.ts +57 -0
- package/src/sidecar.ts +167 -0
- package/src/ssrf-guard.test.ts +109 -0
- package/src/ssrf-guard.ts +165 -0
- package/src/web-fetch.test.ts +305 -0
- package/src/web-fetch.ts +311 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createPinnedLookup, htmlToMarkdown, htmlToPlainText, setWebFetchDnsResolver, webFetchTool } from './web-fetch.js';
|
|
3
|
+
import { asSessionId, asToolCallId, asTurnId } from '@moxxy/sdk';
|
|
4
|
+
import type { ToolContext } from '@moxxy/sdk';
|
|
5
|
+
|
|
6
|
+
const baseCtx = (): ToolContext => ({
|
|
7
|
+
sessionId: asSessionId('s'),
|
|
8
|
+
turnId: asTurnId('t'),
|
|
9
|
+
callId: asToolCallId('c'),
|
|
10
|
+
cwd: '/tmp',
|
|
11
|
+
signal: new AbortController().signal,
|
|
12
|
+
log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
|
|
13
|
+
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('htmlToPlainText', () => {
|
|
17
|
+
it('strips tags + scripts + styles', () => {
|
|
18
|
+
const html = `
|
|
19
|
+
<html><head><style>body{color:red}</style></head>
|
|
20
|
+
<body>
|
|
21
|
+
<script>alert('xss')</script>
|
|
22
|
+
<h1>Title</h1>
|
|
23
|
+
<p>Para one.</p>
|
|
24
|
+
<p>Para <b>two</b>.</p>
|
|
25
|
+
</body></html>`;
|
|
26
|
+
const text = htmlToPlainText(html);
|
|
27
|
+
expect(text).toContain('Title');
|
|
28
|
+
expect(text).toContain('Para one.');
|
|
29
|
+
expect(text).toContain('Para two.');
|
|
30
|
+
expect(text).not.toContain('alert');
|
|
31
|
+
expect(text).not.toContain('color:red');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('decodes HTML entities', () => {
|
|
35
|
+
expect(htmlToPlainText('<p>a&b < c > d "e"</p>')).toBe('a&b < c > d "e"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('extracts a selector block when provided', () => {
|
|
39
|
+
const html = '<div>outside</div><main>inside main</main><footer>footer</footer>';
|
|
40
|
+
expect(htmlToPlainText(html, { selector: 'main' })).toBe('inside main');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('selector by id', () => {
|
|
44
|
+
const html = '<p>noise</p><div id="content">payload</div><p>noise2</p>';
|
|
45
|
+
expect(htmlToPlainText(html, { selector: '#content' })).toBe('payload');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('htmlToMarkdown', () => {
|
|
50
|
+
it('emits markdown headings, lists, links', () => {
|
|
51
|
+
const html = `
|
|
52
|
+
<h1>Title</h1>
|
|
53
|
+
<p>Intro.</p>
|
|
54
|
+
<ul><li>one</li><li>two</li></ul>
|
|
55
|
+
<p>See <a href="https://example.com">example</a>.</p>`;
|
|
56
|
+
const md = htmlToMarkdown(html);
|
|
57
|
+
expect(md).toContain('# Title');
|
|
58
|
+
expect(md).toContain('- one');
|
|
59
|
+
expect(md).toContain('- two');
|
|
60
|
+
expect(md).toContain('[example](https://example.com)');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('keeps single-quoted and unquoted href URLs', () => {
|
|
64
|
+
const html = `
|
|
65
|
+
<p>See <a href='https://single.example'>single</a>.</p>
|
|
66
|
+
<p>And <a href=https://unquoted.example>unquoted</a>.</p>`;
|
|
67
|
+
const md = htmlToMarkdown(html);
|
|
68
|
+
expect(md).toContain('[single](https://single.example)');
|
|
69
|
+
expect(md).toContain('[unquoted](https://unquoted.example)');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('web_fetch handler', () => {
|
|
74
|
+
let origFetch: typeof globalThis.fetch;
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
origFetch = globalThis.fetch;
|
|
77
|
+
// Keep tests hermetic: never hit real DNS — example.com "resolves" public.
|
|
78
|
+
setWebFetchDnsResolver(async () => ['93.184.216.34']);
|
|
79
|
+
});
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
globalThis.fetch = origFetch;
|
|
82
|
+
setWebFetchDnsResolver(null);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns text body for an HTML page', async () => {
|
|
86
|
+
globalThis.fetch = vi.fn(async () =>
|
|
87
|
+
mkResponse('<html><body><h1>Hello</h1></body></html>', { 'content-type': 'text/html' }),
|
|
88
|
+
) as never;
|
|
89
|
+
|
|
90
|
+
const out = (await webFetchTool.handler(
|
|
91
|
+
{ url: 'https://example.com', format: 'text', method: 'GET' },
|
|
92
|
+
baseCtx(),
|
|
93
|
+
)) as string;
|
|
94
|
+
expect(out).toContain('HTTP 200');
|
|
95
|
+
expect(out).toContain('Hello');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns raw body for non-HTML content-type', async () => {
|
|
99
|
+
const body = '{"ok":true}';
|
|
100
|
+
globalThis.fetch = vi.fn(async () =>
|
|
101
|
+
mkResponse(body, { 'content-type': 'application/json' }),
|
|
102
|
+
) as never;
|
|
103
|
+
|
|
104
|
+
const out = (await webFetchTool.handler(
|
|
105
|
+
{ url: 'https://example.com/api.json', format: 'text', method: 'GET' },
|
|
106
|
+
baseCtx(),
|
|
107
|
+
)) as string;
|
|
108
|
+
expect(out).toContain('{"ok":true}');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('truncates to maxBytes', async () => {
|
|
112
|
+
const huge = 'x'.repeat(2_000_000);
|
|
113
|
+
globalThis.fetch = vi.fn(async () =>
|
|
114
|
+
mkResponse(huge, { 'content-type': 'text/plain' }),
|
|
115
|
+
) as never;
|
|
116
|
+
|
|
117
|
+
const out = (await webFetchTool.handler(
|
|
118
|
+
{ url: 'https://example.com', format: 'raw', method: 'GET', maxBytes: 1024 },
|
|
119
|
+
baseCtx(),
|
|
120
|
+
)) as string;
|
|
121
|
+
expect(out).toContain('[response truncated]');
|
|
122
|
+
expect(out.length).toBeLessThan(20_000);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('follows redirects', async () => {
|
|
126
|
+
const calls: string[] = [];
|
|
127
|
+
globalThis.fetch = vi.fn(async (url: string) => {
|
|
128
|
+
calls.push(url);
|
|
129
|
+
if (url === 'https://example.com/a') {
|
|
130
|
+
return mkResponse('', { location: 'https://example.com/b' }, 302);
|
|
131
|
+
}
|
|
132
|
+
return mkResponse('arrived', { 'content-type': 'text/plain' });
|
|
133
|
+
}) as never;
|
|
134
|
+
|
|
135
|
+
const out = (await webFetchTool.handler(
|
|
136
|
+
{ url: 'https://example.com/a', format: 'raw', method: 'GET' },
|
|
137
|
+
baseCtx(),
|
|
138
|
+
)) as string;
|
|
139
|
+
expect(out).toContain('arrived');
|
|
140
|
+
expect(calls).toEqual(['https://example.com/a', 'https://example.com/b']);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('web_fetch SSRF guard', () => {
|
|
145
|
+
let origFetch: typeof globalThis.fetch;
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
origFetch = globalThis.fetch;
|
|
148
|
+
// Default fetch must NOT be reached for blocked URLs; make it loud if it is.
|
|
149
|
+
globalThis.fetch = vi.fn(async () => {
|
|
150
|
+
throw new Error('fetch should not be called for a blocked URL');
|
|
151
|
+
}) as never;
|
|
152
|
+
});
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
globalThis.fetch = origFetch;
|
|
155
|
+
setWebFetchDnsResolver(null);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
async function run(url: string): Promise<string> {
|
|
159
|
+
return (await webFetchTool.handler({ url, format: 'raw', method: 'GET' }, baseCtx())) as string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
it('blocks the loopback hostname without any DNS or fetch', async () => {
|
|
163
|
+
setWebFetchDnsResolver(async () => {
|
|
164
|
+
throw new Error('resolver should not be consulted for localhost');
|
|
165
|
+
});
|
|
166
|
+
await expect(run('http://localhost:8080/admin')).rejects.toThrow(/loopback/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('blocks the cloud metadata IP literal', async () => {
|
|
170
|
+
await expect(run('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/private|loopback/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('blocks a public hostname that resolves to a private address', async () => {
|
|
174
|
+
setWebFetchDnsResolver(async () => ['10.0.0.5']);
|
|
175
|
+
await expect(run('https://intranet.example.com/')).rejects.toThrow(/private|loopback/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('blocks non-HTTP schemes', async () => {
|
|
179
|
+
await expect(run('file:///etc/passwd')).rejects.toThrow(/scheme/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('blocks a redirect from a public host into the internal network', async () => {
|
|
183
|
+
setWebFetchDnsResolver(async (host: string) =>
|
|
184
|
+
host === 'evil.example.com' ? ['93.184.216.34'] : ['127.0.0.1'],
|
|
185
|
+
);
|
|
186
|
+
globalThis.fetch = vi.fn(async () =>
|
|
187
|
+
mkResponse('', { location: 'http://127.0.0.1:6379/' }, 302),
|
|
188
|
+
) as never;
|
|
189
|
+
await expect(run('https://evil.example.com/start')).rejects.toThrow(/private|loopback/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('allows a genuinely public URL', async () => {
|
|
193
|
+
setWebFetchDnsResolver(async () => ['93.184.216.34']);
|
|
194
|
+
globalThis.fetch = vi.fn(async () =>
|
|
195
|
+
mkResponse('ok', { 'content-type': 'text/plain' }),
|
|
196
|
+
) as never;
|
|
197
|
+
const out = await run('https://example.com/');
|
|
198
|
+
expect(out).toContain('ok');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('web_fetch DNS pinning (rebinding TOCTOU)', () => {
|
|
203
|
+
let origFetch: typeof globalThis.fetch;
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
origFetch = globalThis.fetch;
|
|
206
|
+
});
|
|
207
|
+
afterEach(() => {
|
|
208
|
+
globalThis.fetch = origFetch;
|
|
209
|
+
setWebFetchDnsResolver(null);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const invokeLookup = (
|
|
213
|
+
lookup: ReturnType<typeof createPinnedLookup>,
|
|
214
|
+
options: { all?: boolean },
|
|
215
|
+
): Promise<unknown> =>
|
|
216
|
+
new Promise((resolve, reject) => {
|
|
217
|
+
(lookup as unknown as (h: string, o: unknown, cb: (err: Error | null, addr?: unknown, fam?: unknown) => void) => void)(
|
|
218
|
+
'example.com',
|
|
219
|
+
options,
|
|
220
|
+
(err, addr, fam) => (err ? reject(err) : resolve({ addr, fam })),
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('createPinnedLookup answers ONLY with the vetted addresses — it never re-resolves, so a rebinding second answer is unreachable', async () => {
|
|
225
|
+
// Simulated rebinding: by the time the connection is made, "DNS" now
|
|
226
|
+
// says 127.0.0.1. The pinned lookup must keep answering the vetted IP.
|
|
227
|
+
setWebFetchDnsResolver(async () => ['127.0.0.1']);
|
|
228
|
+
const lookup = createPinnedLookup(['93.184.216.34']);
|
|
229
|
+
expect(await invokeLookup(lookup, {})).toEqual({ addr: '93.184.216.34', fam: 4 });
|
|
230
|
+
expect(await invokeLookup(lookup, { all: true })).toEqual({
|
|
231
|
+
addr: [{ address: '93.184.216.34', family: 4 }],
|
|
232
|
+
fam: undefined,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('createPinnedLookup fails closed when there is nothing pinned', async () => {
|
|
237
|
+
const lookup = createPinnedLookup([]);
|
|
238
|
+
await expect(invokeLookup(lookup, {})).rejects.toThrow(/no pinned address/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('resolves each hostname exactly ONCE per hop and pins the fetch to it (dispatcher attached)', async () => {
|
|
242
|
+
const resolutions: string[] = [];
|
|
243
|
+
// A rebinding resolver: first answer public, every later answer private.
|
|
244
|
+
// With pinning the fetch must still succeed (it never asks again) and
|
|
245
|
+
// the request must carry a pinned dispatcher.
|
|
246
|
+
let calls = 0;
|
|
247
|
+
setWebFetchDnsResolver(async (host: string) => {
|
|
248
|
+
resolutions.push(host);
|
|
249
|
+
calls++;
|
|
250
|
+
return calls === 1 ? ['93.184.216.34'] : ['127.0.0.1'];
|
|
251
|
+
});
|
|
252
|
+
const inits: Array<{ dispatcher?: unknown }> = [];
|
|
253
|
+
globalThis.fetch = vi.fn(async (_url: string, init: { dispatcher?: unknown }) => {
|
|
254
|
+
inits.push(init);
|
|
255
|
+
return mkResponse('ok', { 'content-type': 'text/plain' });
|
|
256
|
+
}) as never;
|
|
257
|
+
|
|
258
|
+
const out = (await webFetchTool.handler(
|
|
259
|
+
{ url: 'https://example.com/', format: 'raw', method: 'GET' },
|
|
260
|
+
baseCtx(),
|
|
261
|
+
)) as string;
|
|
262
|
+
expect(out).toContain('ok');
|
|
263
|
+
// One resolution for the single hop — the connection itself consumes the
|
|
264
|
+
// pinned answer, not a fresh (rebindable) one.
|
|
265
|
+
expect(resolutions).toEqual(['example.com']);
|
|
266
|
+
expect(inits).toHaveLength(1);
|
|
267
|
+
expect(inits[0]!.dispatcher).toBeDefined();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('re-pins every redirect hop with that hop\'s own vetted address', async () => {
|
|
271
|
+
const resolutions: string[] = [];
|
|
272
|
+
setWebFetchDnsResolver(async (host: string) => {
|
|
273
|
+
resolutions.push(host);
|
|
274
|
+
return ['93.184.216.34'];
|
|
275
|
+
});
|
|
276
|
+
const inits: Array<{ dispatcher?: unknown }> = [];
|
|
277
|
+
globalThis.fetch = vi.fn(async (url: string, init: { dispatcher?: unknown }) => {
|
|
278
|
+
inits.push(init);
|
|
279
|
+
if (url === 'https://a.example.com/start') {
|
|
280
|
+
return mkResponse('', { location: 'https://b.example.com/end' }, 302);
|
|
281
|
+
}
|
|
282
|
+
return mkResponse('arrived', { 'content-type': 'text/plain' });
|
|
283
|
+
}) as never;
|
|
284
|
+
|
|
285
|
+
const out = (await webFetchTool.handler(
|
|
286
|
+
{ url: 'https://a.example.com/start', format: 'raw', method: 'GET' },
|
|
287
|
+
baseCtx(),
|
|
288
|
+
)) as string;
|
|
289
|
+
expect(out).toContain('arrived');
|
|
290
|
+
expect(resolutions).toEqual(['a.example.com', 'b.example.com']);
|
|
291
|
+
expect(inits).toHaveLength(2);
|
|
292
|
+
expect(inits[0]!.dispatcher).toBeDefined();
|
|
293
|
+
expect(inits[1]!.dispatcher).toBeDefined();
|
|
294
|
+
// Each hop gets its OWN pinned dispatcher.
|
|
295
|
+
expect(inits[0]!.dispatcher).not.toBe(inits[1]!.dispatcher);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
function mkResponse(
|
|
300
|
+
body: string,
|
|
301
|
+
headers: Record<string, string> = {},
|
|
302
|
+
status = 200,
|
|
303
|
+
): Response {
|
|
304
|
+
return new Response(body, { status, headers });
|
|
305
|
+
}
|
package/src/web-fetch.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import type { LookupFunction } from 'node:net';
|
|
3
|
+
import { Agent } from 'undici';
|
|
4
|
+
import { MoxxyError, defineTool, z } from '@moxxy/sdk';
|
|
5
|
+
import { htmlToMarkdown, htmlToPlainText } from './html-extract.js';
|
|
6
|
+
import {
|
|
7
|
+
assertPublicUrl,
|
|
8
|
+
setSsrfDnsResolver,
|
|
9
|
+
SsrfBlockedError,
|
|
10
|
+
type DnsResolver,
|
|
11
|
+
} from './ssrf-guard.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Light-tier web fetch: a single HTTP GET (or HEAD) with HTML→text/markdown
|
|
15
|
+
* post-processing for the common case of "read this page". Zero new
|
|
16
|
+
* dependencies — uses Node's built-in `fetch`.
|
|
17
|
+
*
|
|
18
|
+
* For JS-heavy / interactive pages, use `browser_session` (Playwright
|
|
19
|
+
* sidecar) instead. The web-research skill picks the tier.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export { htmlToMarkdown, htmlToPlainText } from './html-extract.js';
|
|
23
|
+
|
|
24
|
+
const MAX_BYTES_DEFAULT = 2 * 1024 * 1024; // 2 MB
|
|
25
|
+
const MAX_REDIRECTS_DEFAULT = 5;
|
|
26
|
+
const FETCH_TIMEOUT_MS_DEFAULT = 20_000;
|
|
27
|
+
|
|
28
|
+
// --- SSRF guard -------------------------------------------------------------
|
|
29
|
+
// web_fetch can be driven by the model, so it must not be a confused-deputy
|
|
30
|
+
// that reaches loopback, RFC-1918, link-local (incl. the 169.254.169.254 cloud
|
|
31
|
+
// metadata endpoint), or non-HTTP schemes. The guard itself lives in
|
|
32
|
+
// `./ssrf-guard.js` (shared with browser_session and the Playwright sidecar);
|
|
33
|
+
// here we validate the initial URL AND every redirect hop (a public URL can
|
|
34
|
+
// 302 to an internal one).
|
|
35
|
+
|
|
36
|
+
export type { DnsResolver } from './ssrf-guard.js';
|
|
37
|
+
|
|
38
|
+
/** Test seam: override DNS resolution so SSRF tests stay hermetic. Pass null to reset. */
|
|
39
|
+
export function setWebFetchDnsResolver(fn: DnsResolver | null): void {
|
|
40
|
+
setSsrfDnsResolver(fn);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Shared guard, with rejections re-thrown as MoxxyError for the tool surface.
|
|
45
|
+
* Returns the vetted resolved addresses (null when the host is an IP literal
|
|
46
|
+
* or resolution failed open) so the fetch can be pinned to exactly what was
|
|
47
|
+
* checked.
|
|
48
|
+
*/
|
|
49
|
+
async function assertPublicUrlForWebFetch(raw: string): Promise<ReadonlyArray<string> | null> {
|
|
50
|
+
try {
|
|
51
|
+
return await assertPublicUrl(raw, 'web_fetch');
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err instanceof SsrfBlockedError) {
|
|
54
|
+
throw new MoxxyError({ code: 'INTERNAL', message: err.message });
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* DNS-rebinding (TOCTOU) defense: a lookup function that always answers with
|
|
62
|
+
* the addresses the SSRF guard just vetted, so the connection provably goes
|
|
63
|
+
* to the IP that was checked — a second, attacker-controlled DNS answer
|
|
64
|
+
* between check and connect can never be consulted. Exported for tests.
|
|
65
|
+
*/
|
|
66
|
+
export function createPinnedLookup(addresses: ReadonlyArray<string>): LookupFunction {
|
|
67
|
+
// Implemented against the runtime contract (net.connect may ask for one
|
|
68
|
+
// address or, with autoSelectFamily/happy-eyeballs, `all: true`), which is
|
|
69
|
+
// wider than the single-answer `LookupFunction` type — hence the cast.
|
|
70
|
+
const lookup = (
|
|
71
|
+
hostname: string,
|
|
72
|
+
options: { all?: boolean } | undefined,
|
|
73
|
+
callback: (
|
|
74
|
+
err: NodeJS.ErrnoException | null,
|
|
75
|
+
address?: string | Array<{ address: string; family: number }>,
|
|
76
|
+
family?: number,
|
|
77
|
+
) => void,
|
|
78
|
+
): void => {
|
|
79
|
+
if (addresses.length === 0) {
|
|
80
|
+
const err: NodeJS.ErrnoException = new Error(`web_fetch: no pinned address for "${hostname}"`);
|
|
81
|
+
err.code = 'ENOTFOUND';
|
|
82
|
+
callback(err);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const all = addresses.map((address) => ({ address, family: isIP(address) }));
|
|
86
|
+
if (options?.all) callback(null, all);
|
|
87
|
+
else callback(null, all[0]!.address, all[0]!.family);
|
|
88
|
+
};
|
|
89
|
+
return lookup as unknown as LookupFunction;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Per-hop undici dispatcher whose connections resolve via the pinned lookup. */
|
|
93
|
+
function createPinnedDispatcher(addresses: ReadonlyArray<string>): Agent {
|
|
94
|
+
return new Agent({ connect: { lookup: createPinnedLookup(addresses) } });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const webFetchTool = defineTool({
|
|
98
|
+
name: 'web_fetch',
|
|
99
|
+
description:
|
|
100
|
+
'Fetch a URL over HTTP(S) and return the page content. HTML is post-processed to readable text (or markdown). Use for simple GETs; if the page needs JS execution, clicks, or form fills, use browser_session instead.',
|
|
101
|
+
inputSchema: z.object({
|
|
102
|
+
url: z.string().url().describe('Absolute http:// or https:// URL.'),
|
|
103
|
+
format: z
|
|
104
|
+
.enum(['text', 'markdown', 'raw'])
|
|
105
|
+
.optional()
|
|
106
|
+
.default('text')
|
|
107
|
+
.describe(
|
|
108
|
+
'How to render the response. `text` strips HTML to readable plain text. `markdown` keeps headings + lists + links. `raw` returns the body as-is (HTML, JSON, etc).',
|
|
109
|
+
),
|
|
110
|
+
method: z.enum(['GET', 'HEAD']).optional().default('GET'),
|
|
111
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
112
|
+
maxBytes: z.number().int().positive().max(20_000_000).optional(),
|
|
113
|
+
timeoutMs: z.number().int().positive().max(120_000).optional(),
|
|
114
|
+
selector: z
|
|
115
|
+
.string()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe(
|
|
118
|
+
'Optional CSS-like selector for the readability extractor (e.g. "main", "article"). Falls back to whole-body extraction.',
|
|
119
|
+
),
|
|
120
|
+
}),
|
|
121
|
+
permission: { action: 'prompt' },
|
|
122
|
+
isolation: {
|
|
123
|
+
capabilities: {
|
|
124
|
+
net: { mode: 'any' },
|
|
125
|
+
timeMs: 120_000,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
async handler({ url, format, method, headers, maxBytes, timeoutMs, selector }, ctx) {
|
|
129
|
+
const cap = maxBytes ?? MAX_BYTES_DEFAULT;
|
|
130
|
+
const timeout = timeoutMs ?? FETCH_TIMEOUT_MS_DEFAULT;
|
|
131
|
+
|
|
132
|
+
const aborter = new AbortController();
|
|
133
|
+
const onParentAbort = (): void => aborter.abort('parent signal');
|
|
134
|
+
ctx.signal.addEventListener('abort', onParentAbort, { once: true });
|
|
135
|
+
const timer = setTimeout(() => aborter.abort('fetch timeout'), timeout);
|
|
136
|
+
|
|
137
|
+
let fetched: FetchResult | null = null;
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetchFollowRedirects(url, {
|
|
140
|
+
method,
|
|
141
|
+
headers: { 'user-agent': 'moxxy/0.0', ...(headers ?? {}) },
|
|
142
|
+
signal: aborter.signal,
|
|
143
|
+
maxRedirects: MAX_REDIRECTS_DEFAULT,
|
|
144
|
+
});
|
|
145
|
+
fetched = res;
|
|
146
|
+
|
|
147
|
+
if (method === 'HEAD') {
|
|
148
|
+
return formatHeadResult(res);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const body = await readCapped(res, cap);
|
|
152
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
153
|
+
const isHtml = contentType.includes('text/html') || contentType.includes('application/xhtml');
|
|
154
|
+
|
|
155
|
+
if (format === 'raw' || !isHtml) {
|
|
156
|
+
return formatBodyResult({
|
|
157
|
+
status: res.status,
|
|
158
|
+
url: res.url,
|
|
159
|
+
contentType,
|
|
160
|
+
body,
|
|
161
|
+
truncated: body.truncated,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const extracted =
|
|
166
|
+
format === 'markdown'
|
|
167
|
+
? htmlToMarkdown(body.text, { selector })
|
|
168
|
+
: htmlToPlainText(body.text, { selector });
|
|
169
|
+
return formatBodyResult({
|
|
170
|
+
status: res.status,
|
|
171
|
+
url: res.url,
|
|
172
|
+
contentType,
|
|
173
|
+
body: { text: extracted, truncated: body.truncated },
|
|
174
|
+
truncated: body.truncated,
|
|
175
|
+
});
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
ctx.signal.removeEventListener('abort', onParentAbort);
|
|
179
|
+
// Close the final hop's pinned dispatcher (body is consumed by now).
|
|
180
|
+
if (fetched) await fetched.dispose();
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
interface FetchResult {
|
|
186
|
+
readonly status: number;
|
|
187
|
+
readonly url: string;
|
|
188
|
+
readonly headers: Headers;
|
|
189
|
+
readonly body: ReadableStream<Uint8Array> | null;
|
|
190
|
+
/** Close the pinned dispatcher (if any) once the body has been consumed. */
|
|
191
|
+
dispose(): Promise<void>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function fetchFollowRedirects(
|
|
195
|
+
initialUrl: string,
|
|
196
|
+
opts: {
|
|
197
|
+
method: 'GET' | 'HEAD';
|
|
198
|
+
headers: Record<string, string>;
|
|
199
|
+
signal: AbortSignal;
|
|
200
|
+
maxRedirects: number;
|
|
201
|
+
},
|
|
202
|
+
): Promise<FetchResult> {
|
|
203
|
+
let current = initialUrl;
|
|
204
|
+
for (let hop = 0; hop <= opts.maxRedirects; hop++) {
|
|
205
|
+
// Re-validate on every hop — the initial URL AND each redirect target —
|
|
206
|
+
// so a public URL can't 302 us into the internal network.
|
|
207
|
+
const vettedAddrs = await assertPublicUrlForWebFetch(current);
|
|
208
|
+
// DNS-rebinding TOCTOU defense: pin THIS hop's connection to the
|
|
209
|
+
// addresses the guard just vetted. The hostname (and so TLS SNI/cert
|
|
210
|
+
// validation) is untouched — only name resolution is overridden. When
|
|
211
|
+
// there is nothing to pin (IP-literal host already vetted, or fail-open
|
|
212
|
+
// resolution error where the real fetch fails identically), plain fetch.
|
|
213
|
+
const dispatcher = vettedAddrs && vettedAddrs.length > 0 ? createPinnedDispatcher(vettedAddrs) : null;
|
|
214
|
+
const closeDispatcher = async (): Promise<void> => {
|
|
215
|
+
if (dispatcher) {
|
|
216
|
+
try { await dispatcher.close(); } catch { /* ignore */ }
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
let res: Response;
|
|
220
|
+
try {
|
|
221
|
+
res = await fetch(current, {
|
|
222
|
+
method: opts.method,
|
|
223
|
+
headers: opts.headers,
|
|
224
|
+
signal: opts.signal,
|
|
225
|
+
redirect: 'manual',
|
|
226
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
227
|
+
} as RequestInit);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
await closeDispatcher();
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
233
|
+
const next = new URL(res.headers.get('location')!, current).toString();
|
|
234
|
+
current = next;
|
|
235
|
+
// drain to avoid the connection leaking
|
|
236
|
+
try { await res.body?.cancel(); } catch { /* ignore */ }
|
|
237
|
+
await closeDispatcher();
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
status: res.status,
|
|
242
|
+
url: current,
|
|
243
|
+
headers: res.headers,
|
|
244
|
+
body: res.body,
|
|
245
|
+
dispose: closeDispatcher,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
throw new MoxxyError({
|
|
249
|
+
code: 'INTERNAL',
|
|
250
|
+
message: `Too many redirects (>${opts.maxRedirects}) starting at ${initialUrl}`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface CappedBody {
|
|
255
|
+
readonly text: string;
|
|
256
|
+
readonly truncated: boolean;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function readCapped(res: FetchResult, cap: number): Promise<CappedBody> {
|
|
260
|
+
if (!res.body) return { text: '', truncated: false };
|
|
261
|
+
const reader = res.body.getReader();
|
|
262
|
+
// Streaming UTF-8 decode: a multi-byte sequence straddling a chunk boundary —
|
|
263
|
+
// or the hard byte cut at `cap` — is buffered across `decode({stream:true})`
|
|
264
|
+
// calls instead of being sliced mid-codepoint into a replacement char. Avoids
|
|
265
|
+
// the Buffer.concat re-copy too.
|
|
266
|
+
const decoder = new TextDecoder('utf-8');
|
|
267
|
+
let text = '';
|
|
268
|
+
let total = 0;
|
|
269
|
+
let truncated = false;
|
|
270
|
+
try {
|
|
271
|
+
while (true) {
|
|
272
|
+
const { done, value } = await reader.read();
|
|
273
|
+
if (done) break;
|
|
274
|
+
if (total + value.byteLength > cap) {
|
|
275
|
+
text += decoder.decode(value.subarray(0, cap - total), { stream: true });
|
|
276
|
+
total = cap;
|
|
277
|
+
truncated = true;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
text += decoder.decode(value, { stream: true });
|
|
281
|
+
total += value.byteLength;
|
|
282
|
+
}
|
|
283
|
+
text += decoder.decode(); // flush any buffered partial sequence
|
|
284
|
+
} finally {
|
|
285
|
+
try { reader.releaseLock(); } catch { /* ignore */ }
|
|
286
|
+
try { await res.body.cancel(); } catch { /* ignore */ }
|
|
287
|
+
}
|
|
288
|
+
return { text, truncated };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function formatHeadResult(res: FetchResult): string {
|
|
292
|
+
const lines = [`HTTP ${res.status} ${res.url}`];
|
|
293
|
+
for (const [k, v] of res.headers.entries()) lines.push(`${k}: ${v}`);
|
|
294
|
+
return lines.join('\n');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
interface BodyResult {
|
|
298
|
+
status: number;
|
|
299
|
+
url: string;
|
|
300
|
+
contentType: string;
|
|
301
|
+
body: CappedBody;
|
|
302
|
+
truncated: boolean;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatBodyResult(r: BodyResult): string {
|
|
306
|
+
const header = `HTTP ${r.status} ${r.url}\ncontent-type: ${r.contentType}${
|
|
307
|
+
r.truncated ? '\n[response truncated]' : ''
|
|
308
|
+
}`;
|
|
309
|
+
return `${header}\n\n${r.body.text}`;
|
|
310
|
+
}
|
|
311
|
+
|