@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/dist/browser-session.d.ts +43 -0
  3. package/dist/browser-session.d.ts.map +1 -0
  4. package/dist/browser-session.js +500 -0
  5. package/dist/browser-session.js.map +1 -0
  6. package/dist/browser-surface.d.ts +3 -0
  7. package/dist/browser-surface.d.ts.map +1 -0
  8. package/dist/browser-surface.js +255 -0
  9. package/dist/browser-surface.js.map +1 -0
  10. package/dist/html-extract.d.ts +20 -0
  11. package/dist/html-extract.d.ts.map +1 -0
  12. package/dist/html-extract.js +122 -0
  13. package/dist/html-extract.js.map +1 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +24 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/sidecar/dispatch.d.ts +18 -0
  19. package/dist/sidecar/dispatch.d.ts.map +1 -0
  20. package/dist/sidecar/dispatch.js +294 -0
  21. package/dist/sidecar/dispatch.js.map +1 -0
  22. package/dist/sidecar/install.d.ts +37 -0
  23. package/dist/sidecar/install.d.ts.map +1 -0
  24. package/dist/sidecar/install.js +242 -0
  25. package/dist/sidecar/install.js.map +1 -0
  26. package/dist/sidecar/types.d.ts +97 -0
  27. package/dist/sidecar/types.d.ts.map +1 -0
  28. package/dist/sidecar/types.js +20 -0
  29. package/dist/sidecar/types.js.map +1 -0
  30. package/dist/sidecar.d.ts +31 -0
  31. package/dist/sidecar.d.ts.map +1 -0
  32. package/dist/sidecar.js +165 -0
  33. package/dist/sidecar.js.map +1 -0
  34. package/dist/ssrf-guard.d.ts +43 -0
  35. package/dist/ssrf-guard.d.ts.map +1 -0
  36. package/dist/ssrf-guard.js +164 -0
  37. package/dist/ssrf-guard.js.map +1 -0
  38. package/dist/web-fetch.d.ts +23 -0
  39. package/dist/web-fetch.d.ts.map +1 -0
  40. package/dist/web-fetch.js +253 -0
  41. package/dist/web-fetch.js.map +1 -0
  42. package/package.json +74 -0
  43. package/src/browser-session.test.ts +333 -0
  44. package/src/browser-session.ts +567 -0
  45. package/src/browser-surface.test.ts +367 -0
  46. package/src/browser-surface.ts +275 -0
  47. package/src/html-extract.ts +152 -0
  48. package/src/index.ts +35 -0
  49. package/src/sidecar/dispatch.test.ts +313 -0
  50. package/src/sidecar/dispatch.ts +314 -0
  51. package/src/sidecar/install.ts +283 -0
  52. package/src/sidecar/types.test.ts +26 -0
  53. package/src/sidecar/types.ts +114 -0
  54. package/src/sidecar.test.ts +57 -0
  55. package/src/sidecar.ts +167 -0
  56. package/src/ssrf-guard.test.ts +109 -0
  57. package/src/ssrf-guard.ts +165 -0
  58. package/src/web-fetch.test.ts +305 -0
  59. 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
+ }