@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,97 @@
|
|
|
1
|
+
export type BrowserKind = 'chromium' | 'firefox' | 'webkit';
|
|
2
|
+
export type ErrorKind = 'init' | 'needs-install' | 'navigation' | 'runtime' | 'timeout' | 'unknown';
|
|
3
|
+
export interface Req {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly method: string;
|
|
6
|
+
readonly params?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface Ok {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly result?: unknown;
|
|
12
|
+
/** One-shot human-readable note (e.g. "Auto-installed Chromium"). */
|
|
13
|
+
readonly notice?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface Err {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly ok: false;
|
|
18
|
+
readonly error: {
|
|
19
|
+
message: string;
|
|
20
|
+
kind: ErrorKind;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export type Reply = Ok | Err;
|
|
24
|
+
export interface BrowserType {
|
|
25
|
+
launch(opts: {
|
|
26
|
+
headless: boolean;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
newContext(opts?: unknown): Promise<unknown>;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
export interface PageHandle {
|
|
33
|
+
goto(url: string, opts?: unknown): Promise<unknown>;
|
|
34
|
+
click(selector: string, opts?: unknown): Promise<void>;
|
|
35
|
+
fill(selector: string, value: string, opts?: unknown): Promise<void>;
|
|
36
|
+
textContent(selector: string): Promise<string | null>;
|
|
37
|
+
content(): Promise<string>;
|
|
38
|
+
screenshot(opts?: unknown): Promise<Buffer>;
|
|
39
|
+
evaluate(fn: string): Promise<unknown>;
|
|
40
|
+
url(): string;
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
viewportSize(): {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
} | null;
|
|
46
|
+
setViewportSize(size: {
|
|
47
|
+
width: number;
|
|
48
|
+
height: number;
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
goBack(opts?: unknown): Promise<unknown>;
|
|
51
|
+
goForward(opts?: unknown): Promise<unknown>;
|
|
52
|
+
reload(opts?: unknown): Promise<unknown>;
|
|
53
|
+
readonly mouse: {
|
|
54
|
+
move(x: number, y: number, opts?: unknown): Promise<void>;
|
|
55
|
+
click(x: number, y: number, opts?: unknown): Promise<void>;
|
|
56
|
+
wheel(dx: number, dy: number): Promise<void>;
|
|
57
|
+
};
|
|
58
|
+
readonly keyboard: {
|
|
59
|
+
press(key: string): Promise<void>;
|
|
60
|
+
type(text: string): Promise<void>;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Minimal slice of Playwright's `Request` used by the navigation SSRF guard. */
|
|
64
|
+
export interface RouteRequest {
|
|
65
|
+
url(): string;
|
|
66
|
+
isNavigationRequest(): boolean;
|
|
67
|
+
}
|
|
68
|
+
/** Minimal slice of Playwright's `Route` used by the navigation SSRF guard. */
|
|
69
|
+
export interface RouteHandle {
|
|
70
|
+
request(): RouteRequest;
|
|
71
|
+
abort(errorCode?: string): Promise<void>;
|
|
72
|
+
continue(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
export interface PlaywrightHandle {
|
|
75
|
+
readonly browser: {
|
|
76
|
+
close(): Promise<void>;
|
|
77
|
+
};
|
|
78
|
+
readonly context: {
|
|
79
|
+
newPage(): Promise<unknown>;
|
|
80
|
+
close(): Promise<void>;
|
|
81
|
+
/** Optional because the type is a loose projection; real Playwright contexts always have it. */
|
|
82
|
+
route?(pattern: string, handler: (route: RouteHandle) => Promise<void> | void): Promise<void>;
|
|
83
|
+
};
|
|
84
|
+
readonly page: PageHandle;
|
|
85
|
+
}
|
|
86
|
+
export declare function errMsg(err: unknown): string;
|
|
87
|
+
/**
|
|
88
|
+
* An error carrying a typed {@link ErrorKind} so the dispatch layer can map it
|
|
89
|
+
* onto the wire reply's `error.kind` without poking an untyped `kind` property
|
|
90
|
+
* onto a bare `Error`.
|
|
91
|
+
*/
|
|
92
|
+
export declare class SidecarError extends Error {
|
|
93
|
+
readonly kind: ErrorKind;
|
|
94
|
+
constructor(message: string, kind: ErrorKind);
|
|
95
|
+
}
|
|
96
|
+
export declare function badParams(msg: string): SidecarError;
|
|
97
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/sidecar/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function errMsg(err) {
|
|
2
|
+
return err instanceof Error ? err.message : String(err);
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* An error carrying a typed {@link ErrorKind} so the dispatch layer can map it
|
|
6
|
+
* onto the wire reply's `error.kind` without poking an untyped `kind` property
|
|
7
|
+
* onto a bare `Error`.
|
|
8
|
+
*/
|
|
9
|
+
export class SidecarError extends Error {
|
|
10
|
+
kind;
|
|
11
|
+
constructor(message, kind) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.kind = kind;
|
|
14
|
+
this.name = 'SidecarError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function badParams(msg) {
|
|
18
|
+
return new SidecarError(msg, 'runtime');
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/sidecar/types.ts"],"names":[],"mappings":"AA4FA,MAAM,UAAU,MAAM,CAAC,GAAY;IACjC,OAAO,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAC1D,CAAC;AAED;;;;GAIG;AACH,MAAM,OAAO,YAAa,SAAQ,KAAK;IAG1B;IAFX,YACE,OAAe,EACN,IAAe;QAExB,KAAK,CAAC,OAAO,CAAC,CAAC;QAFN,SAAI,GAAJ,IAAI,CAAW;QAGxB,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;IAC7B,CAAC;CACF;AAED,MAAM,UAAU,SAAS,CAAC,GAAW;IACnC,OAAO,IAAI,YAAY,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;AAC1C,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Playwright sidecar — owns a single browser context. Speaks newline-delimited
|
|
4
|
+
* JSON-RPC over stdio so the parent (`browser_session` tool) doesn't load
|
|
5
|
+
* Playwright into its own process. Crash isolation, lazy install: the parent
|
|
6
|
+
* only spawns this when the heavy tier is actually needed.
|
|
7
|
+
*
|
|
8
|
+
* Wire format (one line per message):
|
|
9
|
+
* { "id": "uuid", "method": "goto"|"click"|"fill"|"text"|"html"|"screenshot"|"eval"|"close", "params": {...} }
|
|
10
|
+
* { "id": "uuid", "ok": true, "result": ... }
|
|
11
|
+
* { "id": "uuid", "ok": false, "error": { "message": "...", "kind": "init"|"navigation"|"runtime"|"timeout" } }
|
|
12
|
+
*
|
|
13
|
+
* Run with `node dist/sidecar.js` (the package's `bin` entry is
|
|
14
|
+
* `moxxy-browser-sidecar`). Parent terminates by closing stdin or sending
|
|
15
|
+
* `{method:'close'}`.
|
|
16
|
+
*/
|
|
17
|
+
import { type Reply } from './sidecar/types.js';
|
|
18
|
+
/**
|
|
19
|
+
* Parse one input line and chain its dispatch onto the serial request queue.
|
|
20
|
+
* `out` is the sink for replies (real run: {@link write}; tests inject a stub).
|
|
21
|
+
* Exported so a test can drive the queue without spinning up the whole stdio
|
|
22
|
+
* loop. Returns the queue tail so callers can await it.
|
|
23
|
+
*
|
|
24
|
+
* The `.catch` on the chained link is load-bearing: a throw from `out` (e.g. a
|
|
25
|
+
* broken stdout pipe) would otherwise reject `queue` permanently and strand
|
|
26
|
+
* EVERY subsequent request, since each link chains off the previous one.
|
|
27
|
+
* Swallow it here — after emitting a best-effort error reply — so the next
|
|
28
|
+
* request still serves.
|
|
29
|
+
*/
|
|
30
|
+
export declare function enqueueLine(line: string, out: (reply: Reply) => void): Promise<void>;
|
|
31
|
+
//# sourceMappingURL=sidecar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sidecar.d.ts","sourceRoot":"","sources":["../src/sidecar.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;GAcG;AAKH,OAAO,EAAU,KAAK,KAAK,EAAY,MAAM,oBAAoB,CAAC;AAoElE;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCpF"}
|
package/dist/sidecar.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Playwright sidecar — owns a single browser context. Speaks newline-delimited
|
|
4
|
+
* JSON-RPC over stdio so the parent (`browser_session` tool) doesn't load
|
|
5
|
+
* Playwright into its own process. Crash isolation, lazy install: the parent
|
|
6
|
+
* only spawns this when the heavy tier is actually needed.
|
|
7
|
+
*
|
|
8
|
+
* Wire format (one line per message):
|
|
9
|
+
* { "id": "uuid", "method": "goto"|"click"|"fill"|"text"|"html"|"screenshot"|"eval"|"close", "params": {...} }
|
|
10
|
+
* { "id": "uuid", "ok": true, "result": ... }
|
|
11
|
+
* { "id": "uuid", "ok": false, "error": { "message": "...", "kind": "init"|"navigation"|"runtime"|"timeout" } }
|
|
12
|
+
*
|
|
13
|
+
* Run with `node dist/sidecar.js` (the package's `bin` entry is
|
|
14
|
+
* `moxxy-browser-sidecar`). Parent terminates by closing stdin or sending
|
|
15
|
+
* `{method:'close'}`.
|
|
16
|
+
*/
|
|
17
|
+
import * as readline from 'node:readline';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dispatch, teardown } from './sidecar/dispatch.js';
|
|
20
|
+
import { errMsg } from './sidecar/types.js';
|
|
21
|
+
const state = { handle: null, pendingInstallNotice: null };
|
|
22
|
+
function write(reply) {
|
|
23
|
+
// Drain the install-notice flag into the first reply that goes out
|
|
24
|
+
// after the install completed, then clear it. Errors get the notice
|
|
25
|
+
// too — sometimes the launch retry surfaces a different problem and
|
|
26
|
+
// the user still wants to know we tried to install.
|
|
27
|
+
if (state.pendingInstallNotice) {
|
|
28
|
+
if (reply.ok) {
|
|
29
|
+
reply = { ...reply, notice: state.pendingInstallNotice };
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
reply = {
|
|
33
|
+
...reply,
|
|
34
|
+
error: {
|
|
35
|
+
...reply.error,
|
|
36
|
+
message: `${state.pendingInstallNotice} Then: ${reply.error.message}`,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
state.pendingInstallNotice = null;
|
|
41
|
+
}
|
|
42
|
+
process.stdout.write(JSON.stringify(reply) + '\n');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Tears down the browser context AND exits the process. Used both by
|
|
46
|
+
* the explicit `close` RPC path and the parent-loss / stdin-close
|
|
47
|
+
* paths so all cleanup goes through one routine.
|
|
48
|
+
*/
|
|
49
|
+
async function shutdownAndExit(code) {
|
|
50
|
+
await teardown(state);
|
|
51
|
+
process.exit(code);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Parent watchdog: if the moxxy process that spawned this sidecar
|
|
55
|
+
* disappears (crash, SIGKILL, terminal hangup), there's no stdin EOF
|
|
56
|
+
* to rely on — orphan Chromium would keep running and chew CPU/memory
|
|
57
|
+
* until the user notices. Poll the parent PID every few seconds and
|
|
58
|
+
* self-terminate when it goes away.
|
|
59
|
+
*
|
|
60
|
+
* `process.kill(pid, 0)` is the POSIX trick for "does this PID
|
|
61
|
+
* exist?" — no signal is actually delivered. Throws ESRCH when the
|
|
62
|
+
* process is gone (or EPERM when it exists but we can't signal it —
|
|
63
|
+
* still alive, so don't treat as gone).
|
|
64
|
+
*/
|
|
65
|
+
function startParentWatchdog() {
|
|
66
|
+
const parentPid = process.ppid;
|
|
67
|
+
if (!parentPid || parentPid <= 1)
|
|
68
|
+
return; // already orphaned (init), nothing to watch
|
|
69
|
+
const interval = setInterval(() => {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(parentPid, 0);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
const code = err.code;
|
|
75
|
+
if (code === 'ESRCH') {
|
|
76
|
+
clearInterval(interval);
|
|
77
|
+
void shutdownAndExit(0);
|
|
78
|
+
}
|
|
79
|
+
// EPERM means the process exists but we can't signal it — still alive.
|
|
80
|
+
}
|
|
81
|
+
}, 2000);
|
|
82
|
+
interval.unref?.(); // never block the event loop from exiting
|
|
83
|
+
}
|
|
84
|
+
let queue = Promise.resolve();
|
|
85
|
+
/**
|
|
86
|
+
* Parse one input line and chain its dispatch onto the serial request queue.
|
|
87
|
+
* `out` is the sink for replies (real run: {@link write}; tests inject a stub).
|
|
88
|
+
* Exported so a test can drive the queue without spinning up the whole stdio
|
|
89
|
+
* loop. Returns the queue tail so callers can await it.
|
|
90
|
+
*
|
|
91
|
+
* The `.catch` on the chained link is load-bearing: a throw from `out` (e.g. a
|
|
92
|
+
* broken stdout pipe) would otherwise reject `queue` permanently and strand
|
|
93
|
+
* EVERY subsequent request, since each link chains off the previous one.
|
|
94
|
+
* Swallow it here — after emitting a best-effort error reply — so the next
|
|
95
|
+
* request still serves.
|
|
96
|
+
*/
|
|
97
|
+
export function enqueueLine(line, out) {
|
|
98
|
+
if (!line.trim())
|
|
99
|
+
return queue;
|
|
100
|
+
let req;
|
|
101
|
+
try {
|
|
102
|
+
req = JSON.parse(line);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Wholly unparseable — there's no id to recover, so the parent can't match
|
|
106
|
+
// this reply; it's best-effort diagnostic output only.
|
|
107
|
+
out({ id: 'unknown', ok: false, error: { message: 'invalid JSON', kind: 'runtime' } });
|
|
108
|
+
return queue;
|
|
109
|
+
}
|
|
110
|
+
if (!req.id || !req.method) {
|
|
111
|
+
// Echo the request's real id whenever it round-tripped (only `method` was
|
|
112
|
+
// missing), so the parent can SETTLE the matching pending call instead of
|
|
113
|
+
// dropping the reply and stranding the caller until its timeout. Fall back
|
|
114
|
+
// to 'unknown' only when the id genuinely can't be recovered.
|
|
115
|
+
out({
|
|
116
|
+
id: typeof req.id === 'string' && req.id ? req.id : 'unknown',
|
|
117
|
+
ok: false,
|
|
118
|
+
error: { message: 'request requires { id, method }', kind: 'runtime' },
|
|
119
|
+
});
|
|
120
|
+
return queue;
|
|
121
|
+
}
|
|
122
|
+
// Sequentially serve requests on the single page. Parent can pipeline by
|
|
123
|
+
// sending more requests; we serialize them inside the sidecar so a goto
|
|
124
|
+
// doesn't race a click.
|
|
125
|
+
queue = queue
|
|
126
|
+
.then(async () => {
|
|
127
|
+
const reply = await dispatch(state, req);
|
|
128
|
+
out(reply);
|
|
129
|
+
})
|
|
130
|
+
.catch((err) => {
|
|
131
|
+
try {
|
|
132
|
+
out({ id: req.id, ok: false, error: { message: errMsg(err), kind: 'runtime' } });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* stdout is gone too — nothing left to do but keep the queue alive */
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return queue;
|
|
139
|
+
}
|
|
140
|
+
async function main() {
|
|
141
|
+
startParentWatchdog();
|
|
142
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
143
|
+
rl.on('line', (line) => {
|
|
144
|
+
void enqueueLine(line, write);
|
|
145
|
+
});
|
|
146
|
+
rl.once('close', () => {
|
|
147
|
+
void shutdownAndExit(0);
|
|
148
|
+
});
|
|
149
|
+
// Defensive: if our stdout pipe breaks (parent died mid-write), Node
|
|
150
|
+
// would otherwise throw an uncaught EPIPE on the next write. Treat
|
|
151
|
+
// it as a signal to clean up gracefully instead.
|
|
152
|
+
process.stdout.on('error', (err) => {
|
|
153
|
+
if (err.code === 'EPIPE') {
|
|
154
|
+
void shutdownAndExit(0);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Auto-run only when executed as the bin script — not when imported (tests).
|
|
159
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
160
|
+
main().catch((err) => {
|
|
161
|
+
process.stderr.write(`sidecar fatal: ${errMsg(err)}\n`);
|
|
162
|
+
void shutdownAndExit(1);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=sidecar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sidecar.js","sourceRoot":"","sources":["../src/sidecar.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSRF guard for every model-drivable navigation/fetch path in this
|
|
3
|
+
* plugin: `web_fetch` (initial URL + every redirect hop), `browser_session`'s
|
|
4
|
+
* goto (parent side), the sidecar's goto dispatch (child-process side), and
|
|
5
|
+
* the sidecar's in-page navigation interceptor.
|
|
6
|
+
*
|
|
7
|
+
* Blocks non-HTTP(S) schemes, loopback (incl. `localhost`/`*.localhost`),
|
|
8
|
+
* 0.0.0.0/8, RFC-1918 private ranges, link-local 169.254/16 (incl. the
|
|
9
|
+
* 169.254.169.254 cloud metadata endpoint), CGNAT 100.64/10, multicast/
|
|
10
|
+
* reserved, and IPv6 loopback/link-local/unique-local (+ v4-mapped). Hostnames
|
|
11
|
+
* are resolved so an internal DNS name (or rebinding) can't smuggle a private
|
|
12
|
+
* target past the literal-IP checks.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: deliberately NO `@moxxy/sdk` import. The Playwright sidecar — a
|
|
15
|
+
* separate child process whose bundle must stay free of non-builtin deps —
|
|
16
|
+
* imports this module too. Parent-side callers wrap `SsrfBlockedError` into
|
|
17
|
+
* `MoxxyError` themselves.
|
|
18
|
+
*/
|
|
19
|
+
/** Thrown for every guard rejection (bad scheme, private/loopback target, unparseable URL). */
|
|
20
|
+
export declare class SsrfBlockedError extends Error {
|
|
21
|
+
constructor(message: string);
|
|
22
|
+
}
|
|
23
|
+
export type DnsResolver = (host: string) => Promise<ReadonlyArray<string>>;
|
|
24
|
+
/** Test seam: override DNS resolution so SSRF tests stay hermetic. Pass null to reset. */
|
|
25
|
+
export declare function setSsrfDnsResolver(fn: DnsResolver | null): void;
|
|
26
|
+
export declare function isBlockedIp(ip: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Reject `raw` unless it is an http(s) URL whose host is (and resolves to)
|
|
29
|
+
* a public address. `label` prefixes the error message so each caller's
|
|
30
|
+
* rejections stay attributable (e.g. "web_fetch", "browser_session").
|
|
31
|
+
*
|
|
32
|
+
* Returns the vetted resolved addresses so callers can PIN the subsequent
|
|
33
|
+
* connection to exactly what was checked (closing the DNS-rebinding TOCTOU
|
|
34
|
+
* where the guard resolves one answer and the fetch independently resolves
|
|
35
|
+
* another — see web-fetch's pinned undici dispatcher). Returns `null` when
|
|
36
|
+
* there is nothing to pin: the host is already an IP literal (vetted above,
|
|
37
|
+
* no DNS involved) or resolution failed (fail-open — a name the guard can't
|
|
38
|
+
* resolve, the fetch's identical system resolver can't reach either).
|
|
39
|
+
*/
|
|
40
|
+
export declare function assertPublicUrl(raw: string, label?: string, opts?: {
|
|
41
|
+
failClosed?: boolean;
|
|
42
|
+
}): Promise<ReadonlyArray<string> | null>;
|
|
43
|
+
//# sourceMappingURL=ssrf-guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssrf-guard.d.ts","sourceRoot":"","sources":["../src/ssrf-guard.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;GAiBG;AAEH,+FAA+F;AAC/F,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC;AAK3E,0FAA0F;AAC1F,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAE/D;AA0DD,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAK/C;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,KAAK,SAAY,EACjB,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,OAAO,CAAA;CAAO,GAClC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC,CA4CvC"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
3
|
+
/**
|
|
4
|
+
* Shared SSRF guard for every model-drivable navigation/fetch path in this
|
|
5
|
+
* plugin: `web_fetch` (initial URL + every redirect hop), `browser_session`'s
|
|
6
|
+
* goto (parent side), the sidecar's goto dispatch (child-process side), and
|
|
7
|
+
* the sidecar's in-page navigation interceptor.
|
|
8
|
+
*
|
|
9
|
+
* Blocks non-HTTP(S) schemes, loopback (incl. `localhost`/`*.localhost`),
|
|
10
|
+
* 0.0.0.0/8, RFC-1918 private ranges, link-local 169.254/16 (incl. the
|
|
11
|
+
* 169.254.169.254 cloud metadata endpoint), CGNAT 100.64/10, multicast/
|
|
12
|
+
* reserved, and IPv6 loopback/link-local/unique-local (+ v4-mapped). Hostnames
|
|
13
|
+
* are resolved so an internal DNS name (or rebinding) can't smuggle a private
|
|
14
|
+
* target past the literal-IP checks.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: deliberately NO `@moxxy/sdk` import. The Playwright sidecar — a
|
|
17
|
+
* separate child process whose bundle must stay free of non-builtin deps —
|
|
18
|
+
* imports this module too. Parent-side callers wrap `SsrfBlockedError` into
|
|
19
|
+
* `MoxxyError` themselves.
|
|
20
|
+
*/
|
|
21
|
+
/** Thrown for every guard rejection (bad scheme, private/loopback target, unparseable URL). */
|
|
22
|
+
export class SsrfBlockedError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'SsrfBlockedError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const defaultResolver = async (host) => (await dnsLookup(host, { all: true })).map((a) => a.address);
|
|
29
|
+
let resolver = defaultResolver;
|
|
30
|
+
/** Test seam: override DNS resolution so SSRF tests stay hermetic. Pass null to reset. */
|
|
31
|
+
export function setSsrfDnsResolver(fn) {
|
|
32
|
+
resolver = fn ?? defaultResolver;
|
|
33
|
+
}
|
|
34
|
+
function isBlockedIpv4(ip) {
|
|
35
|
+
const parts = ip.split('.').map((p) => Number(p));
|
|
36
|
+
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255))
|
|
37
|
+
return true;
|
|
38
|
+
const [a, b] = parts;
|
|
39
|
+
if (a === 0)
|
|
40
|
+
return true; // 0.0.0.0/8 "this host"
|
|
41
|
+
if (a === 127)
|
|
42
|
+
return true; // loopback
|
|
43
|
+
if (a === 10)
|
|
44
|
+
return true; // private
|
|
45
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
46
|
+
return true; // private
|
|
47
|
+
if (a === 192 && b === 168)
|
|
48
|
+
return true; // private
|
|
49
|
+
if (a === 169 && b === 254)
|
|
50
|
+
return true; // link-local incl. 169.254.169.254 metadata
|
|
51
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
52
|
+
return true; // CGNAT 100.64/10
|
|
53
|
+
if (a >= 224)
|
|
54
|
+
return true; // multicast / reserved
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
function isBlockedIpv6(ip) {
|
|
58
|
+
const addr = ip.toLowerCase().replace(/^\[|\]$/g, '').split('%')[0]; // strip zone id
|
|
59
|
+
if (addr === '::1' || addr === '::')
|
|
60
|
+
return true; // loopback / unspecified
|
|
61
|
+
if (addr.startsWith('fe80'))
|
|
62
|
+
return true; // link-local
|
|
63
|
+
if (addr.startsWith('fc') || addr.startsWith('fd'))
|
|
64
|
+
return true; // unique-local fc00::/7
|
|
65
|
+
// Embedded-v4 forms: re-run the v4 policy against the embedded address.
|
|
66
|
+
// The WHATWG URL parser canonicalizes [::ffff:127.0.0.1] to its HEX form
|
|
67
|
+
// ::ffff:7f00:1, so the dotted regex alone NEVER matches a real URL — we must
|
|
68
|
+
// also reconstruct the dotted quad from the trailing hex groups. Covers
|
|
69
|
+
// v4-mapped (::ffff:a.b.c.d / ::ffff:0:a.b.c.d), v4-compatible (::a.b.c.d),
|
|
70
|
+
// and NAT64 (64:ff9b::/96) — any of which could otherwise smuggle a private
|
|
71
|
+
// v4 target (loopback / 169.254.169.254 metadata) past the guard.
|
|
72
|
+
const embeddedV4 = extractEmbeddedV4(addr);
|
|
73
|
+
if (embeddedV4)
|
|
74
|
+
return isBlockedIpv4(embeddedV4);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Pull the embedded IPv4 out of any v4-mapped / v4-compatible / NAT64 IPv6
|
|
79
|
+
* address, returning it as a dotted quad, or null if there is none. Handles
|
|
80
|
+
* both the dotted spelling (`::ffff:127.0.0.1`) and — critically — the HEX
|
|
81
|
+
* spelling the URL parser actually produces (`::ffff:7f00:1`).
|
|
82
|
+
*/
|
|
83
|
+
function extractEmbeddedV4(addr) {
|
|
84
|
+
// Dotted spelling: ::ffff:a.b.c.d, ::ffff:0:a.b.c.d, ::a.b.c.d, 64:ff9b::a.b.c.d
|
|
85
|
+
const dotted = addr.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
86
|
+
if (dotted && (addr.startsWith('::ffff:') || addr.startsWith('::') || addr.startsWith('64:ff9b:'))) {
|
|
87
|
+
return dotted[1];
|
|
88
|
+
}
|
|
89
|
+
// Hex spelling: the embedded v4 lives in the two trailing 16-bit groups.
|
|
90
|
+
const hex = addr.match(/^(?:::ffff:|::ffff:0:|64:ff9b:(?::|.*:)|::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
91
|
+
if (hex) {
|
|
92
|
+
const hi = parseInt(hex[1], 16);
|
|
93
|
+
const lo = parseInt(hex[2], 16);
|
|
94
|
+
if (Number.isInteger(hi) && Number.isInteger(lo) && hi <= 0xffff && lo <= 0xffff) {
|
|
95
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
export function isBlockedIp(ip) {
|
|
101
|
+
const kind = isIP(ip);
|
|
102
|
+
if (kind === 4)
|
|
103
|
+
return isBlockedIpv4(ip);
|
|
104
|
+
if (kind === 6)
|
|
105
|
+
return isBlockedIpv6(ip);
|
|
106
|
+
return true; // not a parseable IP → block
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Reject `raw` unless it is an http(s) URL whose host is (and resolves to)
|
|
110
|
+
* a public address. `label` prefixes the error message so each caller's
|
|
111
|
+
* rejections stay attributable (e.g. "web_fetch", "browser_session").
|
|
112
|
+
*
|
|
113
|
+
* Returns the vetted resolved addresses so callers can PIN the subsequent
|
|
114
|
+
* connection to exactly what was checked (closing the DNS-rebinding TOCTOU
|
|
115
|
+
* where the guard resolves one answer and the fetch independently resolves
|
|
116
|
+
* another — see web-fetch's pinned undici dispatcher). Returns `null` when
|
|
117
|
+
* there is nothing to pin: the host is already an IP literal (vetted above,
|
|
118
|
+
* no DNS involved) or resolution failed (fail-open — a name the guard can't
|
|
119
|
+
* resolve, the fetch's identical system resolver can't reach either).
|
|
120
|
+
*/
|
|
121
|
+
export async function assertPublicUrl(raw, label = 'request', opts = {}) {
|
|
122
|
+
let u;
|
|
123
|
+
try {
|
|
124
|
+
u = new URL(raw);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new SsrfBlockedError(`${label}: invalid URL: ${raw}`);
|
|
128
|
+
}
|
|
129
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
130
|
+
throw new SsrfBlockedError(`${label}: refusing non-HTTP(S) scheme "${u.protocol}"`);
|
|
131
|
+
}
|
|
132
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
133
|
+
if (host === 'localhost' || host.endsWith('.localhost')) {
|
|
134
|
+
throw new SsrfBlockedError(`${label}: refusing to fetch loopback host "${host}"`);
|
|
135
|
+
}
|
|
136
|
+
if (isIP(host)) {
|
|
137
|
+
if (isBlockedIp(host))
|
|
138
|
+
throw new SsrfBlockedError(`${label}: refusing private/loopback address "${host}"`);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
// Resolve the name and block if it maps to a private range (internal name or
|
|
142
|
+
// DNS rebinding). web_fetch fails OPEN on resolution error (its identical
|
|
143
|
+
// system resolver + pinned dispatcher mean an unresolvable name is no vector),
|
|
144
|
+
// but the BROWSER paths hand the URL to Chromium's own resolver/cache — which
|
|
145
|
+
// may resolve a name node:dns just failed on — so they pass `failClosed` to
|
|
146
|
+
// SOFT-BLOCK instead of allowing an un-vetted name through to the browser.
|
|
147
|
+
let addrs;
|
|
148
|
+
try {
|
|
149
|
+
addrs = await resolver(host);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
if (opts.failClosed) {
|
|
153
|
+
throw new SsrfBlockedError(`${label}: refusing host "${host}" — DNS resolution failed (cannot vet against private ranges)`);
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
for (const addr of addrs) {
|
|
158
|
+
if (isBlockedIp(addr)) {
|
|
159
|
+
throw new SsrfBlockedError(`${label}: host "${host}" resolves to a private/loopback address (${addr})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return addrs;
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=ssrf-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssrf-guard.js","sourceRoot":"","sources":["../src/ssrf-guard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAChC,OAAO,EAAE,MAAM,IAAI,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAExD;;;;;;;;;;;;;;;;;GAiBG;AAEH,+FAA+F;AAC/F,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;IACjC,CAAC;CACF;AAGD,MAAM,eAAe,GAAgB,KAAK,EAAE,IAAI,EAAE,EAAE,CAClD,CAAC,MAAM,SAAS,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;AAC/D,IAAI,QAAQ,GAAgB,eAAe,CAAC;AAE5C,0FAA0F;AAC1F,MAAM,UAAU,kBAAkB,CAAC,EAAsB;IACvD,QAAQ,GAAG,EAAE,IAAI,eAAe,CAAC;AACnC,CAAC;AAED,SAAS,aAAa,CAAC,EAAU;IAC/B,MAAM,KAAK,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IAClD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACnG,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,KAAyC,CAAC;IACzD,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,wBAAwB;IAClD,IAAI,CAAC,KAAK,GAAG;QAAE,OAAO,IAAI,CAAC,CAAC,WAAW;IACvC,IAAI,CAAC,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC,CAAC,UAAU;IACrC,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE;QAAE,OAAO,IAAI,CAAC,CAAC,UAAU;IAC5D,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,GAAG;QAAE,OAAO,IAAI,CAAC,CAAC,UAAU;IACnD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,GAAG;QAAE,OAAO,IAAI,CAAC,CAAC,4CAA4C;IACrF,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC,CAAC,kBAAkB;IACrE,IAAI,CAAC,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC,CAAC,uBAAuB;IAClD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,aAAa,CAAC,EAAU;IAC/B,MAAM,IAAI,GAAG,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC,CAAC,gBAAgB;IACtF,IAAI,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC,CAAC,yBAAyB;IAC3E,IAAI,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,aAAa;IACvD,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,wBAAwB;IACzF,wEAAwE;IACxE,yEAAyE;IACzE,8EAA8E;IAC9E,wEAAwE;IACxE,4EAA4E;IAC5E,4EAA4E;IAC5E,kEAAkE;IAClE,MAAM,UAAU,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,UAAU;QAAE,OAAO,aAAa,CAAC,UAAU,CAAC,CAAC;IACjD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;GAKG;AACH,SAAS,iBAAiB,CAAC,IAAY;IACrC,iFAAiF;IACjF,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,uCAAuC,CAAC,CAAC;IACnE,IAAI,MAAM,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC;QACnG,OAAO,MAAM,CAAC,CAAC,CAAE,CAAC;IACpB,CAAC;IACD,yEAAyE;IACzE,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,6EAA6E,CAAC,CAAC;IACtG,IAAI,GAAG,EAAE,CAAC;QACR,MAAM,EAAE,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAE,EAAE,EAAE,CAAC,CAAC;QACjC,MAAM,EAAE,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAE,EAAE,EAAE,CAAC,CAAC;QACjC,IAAI,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC,IAAI,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,MAAM,IAAI,EAAE,IAAI,MAAM,EAAE,CAAC;YACjF,OAAO,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,IAAI,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,IAAI,EAAE,CAAC;QAC3D,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,EAAU;IACpC,MAAM,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;IACtB,IAAI,IAAI,KAAK,CAAC;QAAE,OAAO,aAAa,CAAC,EAAE,CAAC,CAAC;IACzC,IAAI,IAAI,KAAK,CAAC;QAAE,OAAO,aAAa,CAAC,EAAE,CAAC,CAAC;IACzC,OAAO,IAAI,CAAC,CAAC,6BAA6B;AAC5C,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,GAAW,EACX,KAAK,GAAG,SAAS,EACjB,OAAiC,EAAE;IAEnC,IAAI,CAAM,CAAC;IACX,IAAI,CAAC;QACH,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACnB,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,gBAAgB,CAAC,GAAG,KAAK,kBAAkB,GAAG,EAAE,CAAC,CAAC;IAC9D,CAAC;IACD,IAAI,CAAC,CAAC,QAAQ,KAAK,OAAO,IAAI,CAAC,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QACtD,MAAM,IAAI,gBAAgB,CAAC,GAAG,KAAK,kCAAkC,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC;IACtF,CAAC;IACD,MAAM,IAAI,GAAG,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IAChD,IAAI,IAAI,KAAK,WAAW,IAAI,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC;QACxD,MAAM,IAAI,gBAAgB,CAAC,GAAG,KAAK,sCAAsC,IAAI,GAAG,CAAC,CAAC;IACpF,CAAC;IACD,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACf,IAAI,WAAW,CAAC,IAAI,CAAC;YACnB,MAAM,IAAI,gBAAgB,CAAC,GAAG,KAAK,wCAAwC,IAAI,GAAG,CAAC,CAAC;QACtF,OAAO,IAAI,CAAC;IACd,CAAC;IACD,6EAA6E;IAC7E,0EAA0E;IAC1E,+EAA+E;IAC/E,8EAA8E;IAC9E,4EAA4E;IAC5E,2EAA2E;IAC3E,IAAI,KAA4B,CAAC;IACjC,IAAI,CAAC;QACH,KAAK,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACpB,MAAM,IAAI,gBAAgB,CACxB,GAAG,KAAK,oBAAoB,IAAI,+DAA+D,CAChG,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;YACtB,MAAM,IAAI,gBAAgB,CACxB,GAAG,KAAK,WAAW,IAAI,6CAA6C,IAAI,GAAG,CAC5E,CAAC;QACJ,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { LookupFunction } from 'node:net';
|
|
2
|
+
import { type DnsResolver } from './ssrf-guard.js';
|
|
3
|
+
/**
|
|
4
|
+
* Light-tier web fetch: a single HTTP GET (or HEAD) with HTML→text/markdown
|
|
5
|
+
* post-processing for the common case of "read this page". Zero new
|
|
6
|
+
* dependencies — uses Node's built-in `fetch`.
|
|
7
|
+
*
|
|
8
|
+
* For JS-heavy / interactive pages, use `browser_session` (Playwright
|
|
9
|
+
* sidecar) instead. The web-research skill picks the tier.
|
|
10
|
+
*/
|
|
11
|
+
export { htmlToMarkdown, htmlToPlainText } from './html-extract.js';
|
|
12
|
+
export type { DnsResolver } from './ssrf-guard.js';
|
|
13
|
+
/** Test seam: override DNS resolution so SSRF tests stay hermetic. Pass null to reset. */
|
|
14
|
+
export declare function setWebFetchDnsResolver(fn: DnsResolver | null): void;
|
|
15
|
+
/**
|
|
16
|
+
* DNS-rebinding (TOCTOU) defense: a lookup function that always answers with
|
|
17
|
+
* the addresses the SSRF guard just vetted, so the connection provably goes
|
|
18
|
+
* to the IP that was checked — a second, attacker-controlled DNS answer
|
|
19
|
+
* between check and connect can never be consulted. Exported for tests.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createPinnedLookup(addresses: ReadonlyArray<string>): LookupFunction;
|
|
22
|
+
export declare const webFetchTool: import("@moxxy/sdk").ToolDef;
|
|
23
|
+
//# sourceMappingURL=web-fetch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web-fetch.d.ts","sourceRoot":"","sources":["../src/web-fetch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAI/C,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;GAOG;AAEH,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAcpE,YAAY,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,0FAA0F;AAC1F,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAEnE;AAmBD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,cAAc,CAwBnF;AAOD,eAAO,MAAM,YAAY,8BAsFvB,CAAC"}
|