@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,114 @@
|
|
|
1
|
+
export type BrowserKind = 'chromium' | 'firefox' | 'webkit';
|
|
2
|
+
|
|
3
|
+
export type ErrorKind =
|
|
4
|
+
| 'init'
|
|
5
|
+
| 'needs-install' // the `playwright` npm package itself isn't installed yet
|
|
6
|
+
| 'navigation'
|
|
7
|
+
| 'runtime'
|
|
8
|
+
| 'timeout'
|
|
9
|
+
| 'unknown';
|
|
10
|
+
|
|
11
|
+
export interface Req {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly method: string;
|
|
14
|
+
readonly params?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Ok {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly ok: true;
|
|
20
|
+
readonly result?: unknown;
|
|
21
|
+
/** One-shot human-readable note (e.g. "Auto-installed Chromium"). */
|
|
22
|
+
readonly notice?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Err {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly ok: false;
|
|
28
|
+
readonly error: { message: string; kind: ErrorKind };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Reply = Ok | Err;
|
|
32
|
+
|
|
33
|
+
export interface BrowserType {
|
|
34
|
+
launch(opts: {
|
|
35
|
+
headless: boolean;
|
|
36
|
+
}): Promise<{ close(): Promise<void>; newContext(opts?: unknown): Promise<unknown> }>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PageHandle {
|
|
40
|
+
goto(url: string, opts?: unknown): Promise<unknown>;
|
|
41
|
+
click(selector: string, opts?: unknown): Promise<void>;
|
|
42
|
+
fill(selector: string, value: string, opts?: unknown): Promise<void>;
|
|
43
|
+
textContent(selector: string): Promise<string | null>;
|
|
44
|
+
content(): Promise<string>;
|
|
45
|
+
screenshot(opts?: unknown): Promise<Buffer>;
|
|
46
|
+
evaluate(fn: string): Promise<unknown>;
|
|
47
|
+
url(): string;
|
|
48
|
+
close(): Promise<void>;
|
|
49
|
+
// Coordinate-based input + viewport control for the live browser surface
|
|
50
|
+
// (loosely typed — the real Playwright Page provides all of these).
|
|
51
|
+
viewportSize(): { width: number; height: number } | null;
|
|
52
|
+
setViewportSize(size: { width: number; height: number }): Promise<void>;
|
|
53
|
+
goBack(opts?: unknown): Promise<unknown>;
|
|
54
|
+
goForward(opts?: unknown): Promise<unknown>;
|
|
55
|
+
reload(opts?: unknown): Promise<unknown>;
|
|
56
|
+
readonly mouse: {
|
|
57
|
+
move(x: number, y: number, opts?: unknown): Promise<void>;
|
|
58
|
+
click(x: number, y: number, opts?: unknown): Promise<void>;
|
|
59
|
+
wheel(dx: number, dy: number): Promise<void>;
|
|
60
|
+
};
|
|
61
|
+
readonly keyboard: {
|
|
62
|
+
press(key: string): Promise<void>;
|
|
63
|
+
type(text: string): Promise<void>;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Minimal slice of Playwright's `Request` used by the navigation SSRF guard. */
|
|
68
|
+
export interface RouteRequest {
|
|
69
|
+
url(): string;
|
|
70
|
+
isNavigationRequest(): boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Minimal slice of Playwright's `Route` used by the navigation SSRF guard. */
|
|
74
|
+
export interface RouteHandle {
|
|
75
|
+
request(): RouteRequest;
|
|
76
|
+
abort(errorCode?: string): Promise<void>;
|
|
77
|
+
continue(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PlaywrightHandle {
|
|
81
|
+
// Loosely typed so we can avoid importing the playwright types at compile time —
|
|
82
|
+
// they're an optional peer dependency.
|
|
83
|
+
readonly browser: { close(): Promise<void> };
|
|
84
|
+
readonly context: {
|
|
85
|
+
newPage(): Promise<unknown>;
|
|
86
|
+
close(): Promise<void>;
|
|
87
|
+
/** Optional because the type is a loose projection; real Playwright contexts always have it. */
|
|
88
|
+
route?(pattern: string, handler: (route: RouteHandle) => Promise<void> | void): Promise<void>;
|
|
89
|
+
};
|
|
90
|
+
readonly page: PageHandle;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function errMsg(err: unknown): string {
|
|
94
|
+
return err instanceof Error ? err.message : String(err);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* An error carrying a typed {@link ErrorKind} so the dispatch layer can map it
|
|
99
|
+
* onto the wire reply's `error.kind` without poking an untyped `kind` property
|
|
100
|
+
* onto a bare `Error`.
|
|
101
|
+
*/
|
|
102
|
+
export class SidecarError extends Error {
|
|
103
|
+
constructor(
|
|
104
|
+
message: string,
|
|
105
|
+
readonly kind: ErrorKind,
|
|
106
|
+
) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = 'SidecarError';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function badParams(msg: string): SidecarError {
|
|
113
|
+
return new SidecarError(msg, 'runtime');
|
|
114
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { enqueueLine } from './sidecar.js';
|
|
3
|
+
import type { Reply } from './sidecar/types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The serial request queue chains each link off the previous one. A throw from
|
|
7
|
+
* the reply sink (real run: a broken stdout pipe on `process.stdout.write`) used
|
|
8
|
+
* to reject the shared `queue` promise permanently, stranding EVERY subsequent
|
|
9
|
+
* request. The `.catch` on the chained link must keep the queue alive so the
|
|
10
|
+
* next request still serves. We drive `enqueueLine` directly with a stub sink
|
|
11
|
+
* (and an unknown method, so dispatch never touches Playwright).
|
|
12
|
+
*/
|
|
13
|
+
describe('sidecar request queue resilience', () => {
|
|
14
|
+
it('keeps serving after the reply sink throws once', async () => {
|
|
15
|
+
const writes: Reply[] = [];
|
|
16
|
+
let firstWriteThrew = false;
|
|
17
|
+
const out = (reply: Reply): void => {
|
|
18
|
+
if (!firstWriteThrew) {
|
|
19
|
+
firstWriteThrew = true;
|
|
20
|
+
throw new Error('EPIPE: broken pipe'); // poison the first reply write
|
|
21
|
+
}
|
|
22
|
+
writes.push(reply);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Request 1: its reply write throws; the link's `.catch` retries the write
|
|
26
|
+
// (now succeeding) instead of poisoning the queue.
|
|
27
|
+
await enqueueLine(JSON.stringify({ id: 'a', method: 'noop' }), out);
|
|
28
|
+
// Request 2: must STILL get a reply — the queue wasn't stranded.
|
|
29
|
+
await enqueueLine(JSON.stringify({ id: 'b', method: 'noop' }), out);
|
|
30
|
+
|
|
31
|
+
expect(firstWriteThrew).toBe(true);
|
|
32
|
+
const ids = writes.map((r) => r.id);
|
|
33
|
+
expect(ids).toContain('b'); // the next request was served
|
|
34
|
+
// Request 1's error reply lands too (its catch-path re-wrote it).
|
|
35
|
+
expect(ids).toContain('a');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('echoes the real id on a missing-method error so the parent can settle the call', async () => {
|
|
39
|
+
const writes: Reply[] = [];
|
|
40
|
+
const out = (reply: Reply): void => void writes.push(reply);
|
|
41
|
+
// id round-trips, only `method` is missing — the reply MUST carry the real
|
|
42
|
+
// id (not 'unknown') so the parent's pending map matches and the caller is
|
|
43
|
+
// rejected instead of left waiting for its timeout.
|
|
44
|
+
await enqueueLine(JSON.stringify({ id: 'real-id' }), out);
|
|
45
|
+
expect(writes).toHaveLength(1);
|
|
46
|
+
expect(writes[0]!.id).toBe('real-id');
|
|
47
|
+
expect(writes[0]!.ok).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('falls back to "unknown" for wholly unparseable input', async () => {
|
|
51
|
+
const writes: Reply[] = [];
|
|
52
|
+
const out = (reply: Reply): void => void writes.push(reply);
|
|
53
|
+
await enqueueLine('{not json', out);
|
|
54
|
+
expect(writes[0]!.id).toBe('unknown');
|
|
55
|
+
expect(writes[0]!.ok).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/sidecar.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
|
|
18
|
+
import * as readline from 'node:readline';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { dispatch, teardown, type SidecarState } from './sidecar/dispatch.js';
|
|
21
|
+
import { errMsg, type Reply, type Req } from './sidecar/types.js';
|
|
22
|
+
|
|
23
|
+
const state: SidecarState = { handle: null, pendingInstallNotice: null };
|
|
24
|
+
|
|
25
|
+
function write(reply: Reply): void {
|
|
26
|
+
// Drain the install-notice flag into the first reply that goes out
|
|
27
|
+
// after the install completed, then clear it. Errors get the notice
|
|
28
|
+
// too — sometimes the launch retry surfaces a different problem and
|
|
29
|
+
// the user still wants to know we tried to install.
|
|
30
|
+
if (state.pendingInstallNotice) {
|
|
31
|
+
if (reply.ok) {
|
|
32
|
+
reply = { ...reply, notice: state.pendingInstallNotice };
|
|
33
|
+
} else {
|
|
34
|
+
reply = {
|
|
35
|
+
...reply,
|
|
36
|
+
error: {
|
|
37
|
+
...reply.error,
|
|
38
|
+
message: `${state.pendingInstallNotice} Then: ${reply.error.message}`,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
state.pendingInstallNotice = null;
|
|
43
|
+
}
|
|
44
|
+
process.stdout.write(JSON.stringify(reply) + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Tears down the browser context AND exits the process. Used both by
|
|
49
|
+
* the explicit `close` RPC path and the parent-loss / stdin-close
|
|
50
|
+
* paths so all cleanup goes through one routine.
|
|
51
|
+
*/
|
|
52
|
+
async function shutdownAndExit(code: number): Promise<void> {
|
|
53
|
+
await teardown(state);
|
|
54
|
+
process.exit(code);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parent watchdog: if the moxxy process that spawned this sidecar
|
|
59
|
+
* disappears (crash, SIGKILL, terminal hangup), there's no stdin EOF
|
|
60
|
+
* to rely on — orphan Chromium would keep running and chew CPU/memory
|
|
61
|
+
* until the user notices. Poll the parent PID every few seconds and
|
|
62
|
+
* self-terminate when it goes away.
|
|
63
|
+
*
|
|
64
|
+
* `process.kill(pid, 0)` is the POSIX trick for "does this PID
|
|
65
|
+
* exist?" — no signal is actually delivered. Throws ESRCH when the
|
|
66
|
+
* process is gone (or EPERM when it exists but we can't signal it —
|
|
67
|
+
* still alive, so don't treat as gone).
|
|
68
|
+
*/
|
|
69
|
+
function startParentWatchdog(): void {
|
|
70
|
+
const parentPid = process.ppid;
|
|
71
|
+
if (!parentPid || parentPid <= 1) return; // already orphaned (init), nothing to watch
|
|
72
|
+
const interval = setInterval(() => {
|
|
73
|
+
try {
|
|
74
|
+
process.kill(parentPid, 0);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
77
|
+
if (code === 'ESRCH') {
|
|
78
|
+
clearInterval(interval);
|
|
79
|
+
void shutdownAndExit(0);
|
|
80
|
+
}
|
|
81
|
+
// EPERM means the process exists but we can't signal it — still alive.
|
|
82
|
+
}
|
|
83
|
+
}, 2000);
|
|
84
|
+
interval.unref?.(); // never block the event loop from exiting
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let queue: Promise<void> = Promise.resolve();
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse one input line and chain its dispatch onto the serial request queue.
|
|
91
|
+
* `out` is the sink for replies (real run: {@link write}; tests inject a stub).
|
|
92
|
+
* Exported so a test can drive the queue without spinning up the whole stdio
|
|
93
|
+
* loop. Returns the queue tail so callers can await it.
|
|
94
|
+
*
|
|
95
|
+
* The `.catch` on the chained link is load-bearing: a throw from `out` (e.g. a
|
|
96
|
+
* broken stdout pipe) would otherwise reject `queue` permanently and strand
|
|
97
|
+
* EVERY subsequent request, since each link chains off the previous one.
|
|
98
|
+
* Swallow it here — after emitting a best-effort error reply — so the next
|
|
99
|
+
* request still serves.
|
|
100
|
+
*/
|
|
101
|
+
export function enqueueLine(line: string, out: (reply: Reply) => void): Promise<void> {
|
|
102
|
+
if (!line.trim()) return queue;
|
|
103
|
+
let req: Req;
|
|
104
|
+
try {
|
|
105
|
+
req = JSON.parse(line) as Req;
|
|
106
|
+
} catch {
|
|
107
|
+
// Wholly unparseable — there's no id to recover, so the parent can't match
|
|
108
|
+
// this reply; it's best-effort diagnostic output only.
|
|
109
|
+
out({ id: 'unknown', ok: false, error: { message: 'invalid JSON', kind: 'runtime' } });
|
|
110
|
+
return queue;
|
|
111
|
+
}
|
|
112
|
+
if (!req.id || !req.method) {
|
|
113
|
+
// Echo the request's real id whenever it round-tripped (only `method` was
|
|
114
|
+
// missing), so the parent can SETTLE the matching pending call instead of
|
|
115
|
+
// dropping the reply and stranding the caller until its timeout. Fall back
|
|
116
|
+
// to 'unknown' only when the id genuinely can't be recovered.
|
|
117
|
+
out({
|
|
118
|
+
id: typeof req.id === 'string' && req.id ? req.id : 'unknown',
|
|
119
|
+
ok: false,
|
|
120
|
+
error: { message: 'request requires { id, method }', kind: 'runtime' },
|
|
121
|
+
});
|
|
122
|
+
return queue;
|
|
123
|
+
}
|
|
124
|
+
// Sequentially serve requests on the single page. Parent can pipeline by
|
|
125
|
+
// sending more requests; we serialize them inside the sidecar so a goto
|
|
126
|
+
// doesn't race a click.
|
|
127
|
+
queue = queue
|
|
128
|
+
.then(async () => {
|
|
129
|
+
const reply = await dispatch(state, req);
|
|
130
|
+
out(reply);
|
|
131
|
+
})
|
|
132
|
+
.catch((err) => {
|
|
133
|
+
try {
|
|
134
|
+
out({ id: req.id, ok: false, error: { message: errMsg(err), kind: 'runtime' } });
|
|
135
|
+
} catch {
|
|
136
|
+
/* stdout is gone too — nothing left to do but keep the queue alive */
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return queue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function main(): Promise<void> {
|
|
143
|
+
startParentWatchdog();
|
|
144
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
145
|
+
rl.on('line', (line) => {
|
|
146
|
+
void enqueueLine(line, write);
|
|
147
|
+
});
|
|
148
|
+
rl.once('close', () => {
|
|
149
|
+
void shutdownAndExit(0);
|
|
150
|
+
});
|
|
151
|
+
// Defensive: if our stdout pipe breaks (parent died mid-write), Node
|
|
152
|
+
// would otherwise throw an uncaught EPIPE on the next write. Treat
|
|
153
|
+
// it as a signal to clean up gracefully instead.
|
|
154
|
+
process.stdout.on('error', (err) => {
|
|
155
|
+
if ((err as NodeJS.ErrnoException).code === 'EPIPE') {
|
|
156
|
+
void shutdownAndExit(0);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Auto-run only when executed as the bin script — not when imported (tests).
|
|
162
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
163
|
+
main().catch((err) => {
|
|
164
|
+
process.stderr.write(`sidecar fatal: ${errMsg(err)}\n`);
|
|
165
|
+
void shutdownAndExit(1);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { assertPublicUrl, isBlockedIp, setSsrfDnsResolver, SsrfBlockedError } from './ssrf-guard.js';
|
|
3
|
+
|
|
4
|
+
afterEach(() => setSsrfDnsResolver(null));
|
|
5
|
+
|
|
6
|
+
describe('assertPublicUrl (shared SSRF guard)', () => {
|
|
7
|
+
it('blocks the cloud metadata IP literal', async () => {
|
|
8
|
+
await expect(assertPublicUrl('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(
|
|
9
|
+
/private|loopback/,
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('blocks localhost without consulting DNS', async () => {
|
|
14
|
+
setSsrfDnsResolver(async () => {
|
|
15
|
+
throw new Error('resolver should not be consulted for localhost');
|
|
16
|
+
});
|
|
17
|
+
await expect(assertPublicUrl('http://localhost:8080/admin')).rejects.toThrow(/loopback/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('blocks RFC-1918 literals', async () => {
|
|
21
|
+
await expect(assertPublicUrl('http://10.0.0.5/')).rejects.toThrow(/private|loopback/);
|
|
22
|
+
await expect(assertPublicUrl('http://192.168.1.1/')).rejects.toThrow(/private|loopback/);
|
|
23
|
+
await expect(assertPublicUrl('http://172.16.0.1/')).rejects.toThrow(/private|loopback/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('blocks IPv6 loopback / link-local / unique-local', async () => {
|
|
27
|
+
await expect(assertPublicUrl('http://[::1]:3000/')).rejects.toThrow(/private|loopback/);
|
|
28
|
+
await expect(assertPublicUrl('http://[fe80::1]/')).rejects.toThrow(/private|loopback/);
|
|
29
|
+
await expect(assertPublicUrl('http://[fd00::1]/')).rejects.toThrow(/private|loopback/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('blocks v4-mapped IPv6 in the HEX form the URL parser actually produces', async () => {
|
|
33
|
+
// new URL('http://[::ffff:127.0.0.1]/').hostname === '::ffff:7f00:1' — the
|
|
34
|
+
// dotted spelling never reaches the guard via a real URL, so the guard must
|
|
35
|
+
// recognize the hex form. Driving through assertPublicUrl exercises the same
|
|
36
|
+
// canonicalization a real request hits (the regression the dotted-only check
|
|
37
|
+
// missed: loopback AND the cloud-metadata endpoint slipped straight past).
|
|
38
|
+
await expect(assertPublicUrl('http://[::ffff:127.0.0.1]/')).rejects.toThrow(/private|loopback/);
|
|
39
|
+
await expect(assertPublicUrl('http://[::ffff:169.254.169.254]/latest/meta-data/')).rejects.toThrow(
|
|
40
|
+
/private|loopback/,
|
|
41
|
+
);
|
|
42
|
+
await expect(assertPublicUrl('http://[::ffff:192.168.1.1]/')).rejects.toThrow(/private|loopback/);
|
|
43
|
+
await expect(assertPublicUrl('http://[::ffff:10.0.0.1]/')).rejects.toThrow(/private|loopback/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('still allows a v4-mapped PUBLIC address (the embedded v4 is what matters)', async () => {
|
|
47
|
+
// ::ffff:93.184.216.34 → public; must NOT be blocked just for being v4-mapped.
|
|
48
|
+
await expect(assertPublicUrl('http://[::ffff:93.184.216.34]/')).resolves.toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('fail-closed mode rejects an unresolvable host (browser paths)', async () => {
|
|
52
|
+
setSsrfDnsResolver(async () => {
|
|
53
|
+
throw new Error('NXDOMAIN');
|
|
54
|
+
});
|
|
55
|
+
// Default (web_fetch) fails OPEN: a name it can't resolve is no vector.
|
|
56
|
+
await expect(assertPublicUrl('https://nope.invalid/')).resolves.toBeNull();
|
|
57
|
+
// failClosed (browser goto / navigation guard) SOFT-BLOCKS instead.
|
|
58
|
+
await expect(
|
|
59
|
+
assertPublicUrl('https://nope.invalid/', 'goto', { failClosed: true }),
|
|
60
|
+
).rejects.toThrow(/DNS resolution failed/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('blocks non-HTTP(S) schemes', async () => {
|
|
64
|
+
await expect(assertPublicUrl('file:///etc/passwd')).rejects.toThrow(/scheme/);
|
|
65
|
+
await expect(assertPublicUrl('javascript:alert(1)')).rejects.toThrow(/scheme/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('blocks a hostname that resolves to a private address', async () => {
|
|
69
|
+
setSsrfDnsResolver(async () => ['10.0.0.5']);
|
|
70
|
+
await expect(assertPublicUrl('https://intranet.example.com/')).rejects.toThrow(
|
|
71
|
+
/private|loopback/,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('allows a public IP literal and a publicly-resolving hostname', async () => {
|
|
76
|
+
// IP literal: nothing resolved, nothing to pin.
|
|
77
|
+
await expect(assertPublicUrl('https://93.184.216.34/')).resolves.toBeNull();
|
|
78
|
+
setSsrfDnsResolver(async () => ['93.184.216.34']);
|
|
79
|
+
// Hostname: the vetted addresses come back so callers can pin them.
|
|
80
|
+
await expect(assertPublicUrl('https://example.com/')).resolves.toEqual(['93.184.216.34']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('throws SsrfBlockedError with the caller label prefixed', async () => {
|
|
84
|
+
const err = await assertPublicUrl('http://127.0.0.1/', 'browser_session').catch((e) => e);
|
|
85
|
+
expect(err).toBeInstanceOf(SsrfBlockedError);
|
|
86
|
+
expect((err as Error).message).toMatch(/^browser_session:/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('isBlockedIp', () => {
|
|
91
|
+
it('blocks unparseable input', () => {
|
|
92
|
+
expect(isBlockedIp('not-an-ip')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
it('passes public v4 and v6', () => {
|
|
95
|
+
expect(isBlockedIp('93.184.216.34')).toBe(false);
|
|
96
|
+
expect(isBlockedIp('2606:2800:220:1:248:1893:25c8:1946')).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
it('blocks v4-mapped private v6', () => {
|
|
99
|
+
expect(isBlockedIp('::ffff:192.168.1.1')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
it('blocks v4-mapped private v6 in HEX form (the URL-canonicalized spelling)', () => {
|
|
102
|
+
expect(isBlockedIp('::ffff:7f00:1')).toBe(true); // 127.0.0.1
|
|
103
|
+
expect(isBlockedIp('::ffff:a9fe:a9fe')).toBe(true); // 169.254.169.254 metadata
|
|
104
|
+
expect(isBlockedIp('::ffff:c0a8:101')).toBe(true); // 192.168.1.1
|
|
105
|
+
});
|
|
106
|
+
it('does NOT block a v4-mapped PUBLIC address', () => {
|
|
107
|
+
expect(isBlockedIp('::ffff:5db8:d822')).toBe(false); // 93.184.216.34
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared SSRF guard for every model-drivable navigation/fetch path in this
|
|
6
|
+
* plugin: `web_fetch` (initial URL + every redirect hop), `browser_session`'s
|
|
7
|
+
* goto (parent side), the sidecar's goto dispatch (child-process side), and
|
|
8
|
+
* the sidecar's in-page navigation interceptor.
|
|
9
|
+
*
|
|
10
|
+
* Blocks non-HTTP(S) schemes, loopback (incl. `localhost`/`*.localhost`),
|
|
11
|
+
* 0.0.0.0/8, RFC-1918 private ranges, link-local 169.254/16 (incl. the
|
|
12
|
+
* 169.254.169.254 cloud metadata endpoint), CGNAT 100.64/10, multicast/
|
|
13
|
+
* reserved, and IPv6 loopback/link-local/unique-local (+ v4-mapped). Hostnames
|
|
14
|
+
* are resolved so an internal DNS name (or rebinding) can't smuggle a private
|
|
15
|
+
* target past the literal-IP checks.
|
|
16
|
+
*
|
|
17
|
+
* NOTE: deliberately NO `@moxxy/sdk` import. The Playwright sidecar — a
|
|
18
|
+
* separate child process whose bundle must stay free of non-builtin deps —
|
|
19
|
+
* imports this module too. Parent-side callers wrap `SsrfBlockedError` into
|
|
20
|
+
* `MoxxyError` themselves.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Thrown for every guard rejection (bad scheme, private/loopback target, unparseable URL). */
|
|
24
|
+
export class SsrfBlockedError extends Error {
|
|
25
|
+
constructor(message: string) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'SsrfBlockedError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DnsResolver = (host: string) => Promise<ReadonlyArray<string>>;
|
|
32
|
+
const defaultResolver: DnsResolver = async (host) =>
|
|
33
|
+
(await dnsLookup(host, { all: true })).map((a) => a.address);
|
|
34
|
+
let resolver: DnsResolver = defaultResolver;
|
|
35
|
+
|
|
36
|
+
/** Test seam: override DNS resolution so SSRF tests stay hermetic. Pass null to reset. */
|
|
37
|
+
export function setSsrfDnsResolver(fn: DnsResolver | null): void {
|
|
38
|
+
resolver = fn ?? defaultResolver;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isBlockedIpv4(ip: string): boolean {
|
|
42
|
+
const parts = ip.split('.').map((p) => Number(p));
|
|
43
|
+
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return true;
|
|
44
|
+
const [a, b] = parts as [number, number, number, number];
|
|
45
|
+
if (a === 0) return true; // 0.0.0.0/8 "this host"
|
|
46
|
+
if (a === 127) return true; // loopback
|
|
47
|
+
if (a === 10) return true; // private
|
|
48
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
49
|
+
if (a === 192 && b === 168) return true; // private
|
|
50
|
+
if (a === 169 && b === 254) return true; // link-local incl. 169.254.169.254 metadata
|
|
51
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10
|
|
52
|
+
if (a >= 224) return true; // multicast / reserved
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isBlockedIpv6(ip: string): boolean {
|
|
57
|
+
const addr = ip.toLowerCase().replace(/^\[|\]$/g, '').split('%')[0]!; // strip zone id
|
|
58
|
+
if (addr === '::1' || addr === '::') return true; // loopback / unspecified
|
|
59
|
+
if (addr.startsWith('fe80')) return true; // link-local
|
|
60
|
+
if (addr.startsWith('fc') || addr.startsWith('fd')) return true; // unique-local fc00::/7
|
|
61
|
+
// Embedded-v4 forms: re-run the v4 policy against the embedded address.
|
|
62
|
+
// The WHATWG URL parser canonicalizes [::ffff:127.0.0.1] to its HEX form
|
|
63
|
+
// ::ffff:7f00:1, so the dotted regex alone NEVER matches a real URL — we must
|
|
64
|
+
// also reconstruct the dotted quad from the trailing hex groups. Covers
|
|
65
|
+
// v4-mapped (::ffff:a.b.c.d / ::ffff:0:a.b.c.d), v4-compatible (::a.b.c.d),
|
|
66
|
+
// and NAT64 (64:ff9b::/96) — any of which could otherwise smuggle a private
|
|
67
|
+
// v4 target (loopback / 169.254.169.254 metadata) past the guard.
|
|
68
|
+
const embeddedV4 = extractEmbeddedV4(addr);
|
|
69
|
+
if (embeddedV4) return isBlockedIpv4(embeddedV4);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pull the embedded IPv4 out of any v4-mapped / v4-compatible / NAT64 IPv6
|
|
75
|
+
* address, returning it as a dotted quad, or null if there is none. Handles
|
|
76
|
+
* both the dotted spelling (`::ffff:127.0.0.1`) and — critically — the HEX
|
|
77
|
+
* spelling the URL parser actually produces (`::ffff:7f00:1`).
|
|
78
|
+
*/
|
|
79
|
+
function extractEmbeddedV4(addr: string): string | null {
|
|
80
|
+
// Dotted spelling: ::ffff:a.b.c.d, ::ffff:0:a.b.c.d, ::a.b.c.d, 64:ff9b::a.b.c.d
|
|
81
|
+
const dotted = addr.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
82
|
+
if (dotted && (addr.startsWith('::ffff:') || addr.startsWith('::') || addr.startsWith('64:ff9b:'))) {
|
|
83
|
+
return dotted[1]!;
|
|
84
|
+
}
|
|
85
|
+
// Hex spelling: the embedded v4 lives in the two trailing 16-bit groups.
|
|
86
|
+
const hex = addr.match(/^(?:::ffff:|::ffff:0:|64:ff9b:(?::|.*:)|::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
87
|
+
if (hex) {
|
|
88
|
+
const hi = parseInt(hex[1]!, 16);
|
|
89
|
+
const lo = parseInt(hex[2]!, 16);
|
|
90
|
+
if (Number.isInteger(hi) && Number.isInteger(lo) && hi <= 0xffff && lo <= 0xffff) {
|
|
91
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isBlockedIp(ip: string): boolean {
|
|
98
|
+
const kind = isIP(ip);
|
|
99
|
+
if (kind === 4) return isBlockedIpv4(ip);
|
|
100
|
+
if (kind === 6) return isBlockedIpv6(ip);
|
|
101
|
+
return true; // not a parseable IP → block
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reject `raw` unless it is an http(s) URL whose host is (and resolves to)
|
|
106
|
+
* a public address. `label` prefixes the error message so each caller's
|
|
107
|
+
* rejections stay attributable (e.g. "web_fetch", "browser_session").
|
|
108
|
+
*
|
|
109
|
+
* Returns the vetted resolved addresses so callers can PIN the subsequent
|
|
110
|
+
* connection to exactly what was checked (closing the DNS-rebinding TOCTOU
|
|
111
|
+
* where the guard resolves one answer and the fetch independently resolves
|
|
112
|
+
* another — see web-fetch's pinned undici dispatcher). Returns `null` when
|
|
113
|
+
* there is nothing to pin: the host is already an IP literal (vetted above,
|
|
114
|
+
* no DNS involved) or resolution failed (fail-open — a name the guard can't
|
|
115
|
+
* resolve, the fetch's identical system resolver can't reach either).
|
|
116
|
+
*/
|
|
117
|
+
export async function assertPublicUrl(
|
|
118
|
+
raw: string,
|
|
119
|
+
label = 'request',
|
|
120
|
+
opts: { failClosed?: boolean } = {},
|
|
121
|
+
): Promise<ReadonlyArray<string> | null> {
|
|
122
|
+
let u: URL;
|
|
123
|
+
try {
|
|
124
|
+
u = new URL(raw);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new SsrfBlockedError(`${label}: invalid URL: ${raw}`);
|
|
127
|
+
}
|
|
128
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
129
|
+
throw new SsrfBlockedError(`${label}: refusing non-HTTP(S) scheme "${u.protocol}"`);
|
|
130
|
+
}
|
|
131
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
132
|
+
if (host === 'localhost' || host.endsWith('.localhost')) {
|
|
133
|
+
throw new SsrfBlockedError(`${label}: refusing to fetch loopback host "${host}"`);
|
|
134
|
+
}
|
|
135
|
+
if (isIP(host)) {
|
|
136
|
+
if (isBlockedIp(host))
|
|
137
|
+
throw new SsrfBlockedError(`${label}: refusing private/loopback address "${host}"`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
// Resolve the name and block if it maps to a private range (internal name or
|
|
141
|
+
// DNS rebinding). web_fetch fails OPEN on resolution error (its identical
|
|
142
|
+
// system resolver + pinned dispatcher mean an unresolvable name is no vector),
|
|
143
|
+
// but the BROWSER paths hand the URL to Chromium's own resolver/cache — which
|
|
144
|
+
// may resolve a name node:dns just failed on — so they pass `failClosed` to
|
|
145
|
+
// SOFT-BLOCK instead of allowing an un-vetted name through to the browser.
|
|
146
|
+
let addrs: ReadonlyArray<string>;
|
|
147
|
+
try {
|
|
148
|
+
addrs = await resolver(host);
|
|
149
|
+
} catch {
|
|
150
|
+
if (opts.failClosed) {
|
|
151
|
+
throw new SsrfBlockedError(
|
|
152
|
+
`${label}: refusing host "${host}" — DNS resolution failed (cannot vet against private ranges)`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
for (const addr of addrs) {
|
|
158
|
+
if (isBlockedIp(addr)) {
|
|
159
|
+
throw new SsrfBlockedError(
|
|
160
|
+
`${label}: host "${host}" resolves to a private/loopback address (${addr})`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return addrs;
|
|
165
|
+
}
|