@wong2kim/wmux 1.1.1 → 2.0.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/README.md CHANGED
@@ -2,25 +2,40 @@
2
2
 
3
3
  **AI Agent Terminal for Windows**
4
4
 
5
- Run Claude Code, Codex, Gemini CLI side by side — with built-in browser, smart notifications, and MCP integration.
5
+ Run Claude Code, Codex, Gemini CLI side by side — with built-in browser automation, smart notifications, and MCP integration.
6
6
 
7
7
  Inspired by [cmux](https://github.com/manaflow-ai/cmux) (macOS), wmux brings the same philosophy to Windows: **a primitive, not a solution.** Composable building blocks for multi-agent workflows.
8
8
 
9
9
  ![Windows](https://img.shields.io/badge/Windows-10%2F11-0078D6?logo=windows)
10
10
  ![Electron](https://img.shields.io/badge/Electron-41-47848F?logo=electron)
11
+ ![npm](https://img.shields.io/npm/v/@wong2kim/wmux?color=CB3837&logo=npm)
11
12
  ![License](https://img.shields.io/badge/License-MIT-green)
12
13
 
13
14
  ---
14
15
 
15
16
  ## Install
16
17
 
17
- **Download:** [wmux-1.1.1 Setup.exe](https://github.com/openwong2kim/wmux/releases/latest)
18
+ **Download:** [wmux-2.0.0 Setup.exe](https://github.com/openwong2kim/wmux/releases/latest)
18
19
 
19
20
  Or build from source:
20
21
  ```powershell
21
22
  irm https://raw.githubusercontent.com/openwong2kim/wmux/main/install.ps1 | iex
22
23
  ```
23
24
 
25
+ **npm (CLI + MCP server only):**
26
+ ```bash
27
+ npm install -g @wong2kim/wmux
28
+ ```
29
+
30
+ ---
31
+
32
+ ## What's New in v2.0.0
33
+
34
+ - **Browser automation via CDP** — Click, fill, type, screenshot directly through Chrome DevTools Protocol. Works with React inputs, CJK text, and controlled components.
35
+ - **Security hardening** — Token auth on all pipes, SSRF protection, input sanitization, randomized CDP ports, memory pressure watchdog.
36
+ - **Workspace reset** — One-click reset in Settings to clean all workspaces and start fresh.
37
+ - **Daemon process** — Background session management with suspend/resume, scrollback persistence, and auto-recovery.
38
+
24
39
  ---
25
40
 
26
41
  ## Why wmux?
@@ -28,7 +43,7 @@ irm https://raw.githubusercontent.com/openwong2kim/wmux/main/install.ps1 | iex
28
43
  | Problem | wmux |
29
44
  |---------|------|
30
45
  | Windows has no cmux | Native Windows terminal multiplexer for AI agents |
31
- | Agents can't see the browser | Built-in browser with MCP — Claude clicks, fills, evaluates JS |
46
+ | Agents can't control the browser | Built-in browser with CDP — Claude clicks, fills, types, screenshots |
32
47
  | "Is it done yet?" | Smart activity-based notifications + taskbar flash |
33
48
  | Can't compare agents | Multiview — Ctrl+click workspaces to view side by side |
34
49
  | Hard to describe UI elements to LLM | Inspector — click any element, LLM-friendly context copied |
@@ -45,27 +60,25 @@ irm https://raw.githubusercontent.com/openwong2kim/wmux/main/install.ps1 | iex
45
60
  - **Vi copy mode** — `Ctrl+Shift+X`
46
61
  - **Search** — `Ctrl+F`
47
62
  - **Unlimited scrollback** — 999,999 lines default
63
+ - **Scrollback persistence** — terminal content saved to disk, restored on restart
48
64
 
49
65
  ### Workspaces
50
66
  - Sidebar with drag-and-drop reordering
51
67
  - `Ctrl+1` ~ `Ctrl+9` quick switch
52
68
  - **Multiview** — `Ctrl+click` workspaces to split-view them simultaneously
53
- - `Ctrl+Shift+G` to exit multiview
54
- - Session persistenceeverything restored on restart
69
+ - **Session persistence** workspace layout, tabs, cwd, and terminal scrollback all restored on restart
70
+ - **One-click reset**Settings > General > Reset to clean all workspaces
55
71
 
56
- ### Browser
72
+ ### Browser + CDP Automation
57
73
  - Built-in browser panel — `Ctrl+Shift+L`
58
74
  - Navigation bar, DevTools, back/forward
59
- - **Element Inspector** — magnifying glass button to inspect elements
60
- - Hover to highlight, click to copy LLM-friendly context:
61
- ```
62
- [Inspector] Google (https://www.google.com/)
63
- selector: input.gLFyf
64
- <input type="text" name="q" aria-label="Search">
65
- text: ""
66
- parent: div.RNNXgb > siblings: button"Google Search", button"I'm Feeling Lucky"
67
- ```
68
- - Paste directly into Claude — it understands the element immediately
75
+ - **Element Inspector** — hover to highlight, click to copy LLM-friendly context
76
+ - **Full CDP automation via MCP:**
77
+ - Click elements by ref or CSS selector
78
+ - Fill forms with real keyboard input (handles React, CJK)
79
+ - Take screenshots via CDP `Page.captureScreenshot`
80
+ - Evaluate JavaScript with user gesture context
81
+ - Navigate, go back, press keys
69
82
 
70
83
  ### Notifications
71
84
  - **Activity-based detection** — monitors output throughput, no fragile pattern matching
@@ -82,10 +95,16 @@ wmux automatically registers its MCP server when launched. Claude Code can:
82
95
  |------|-------------|
83
96
  | `browser_open` | Open a new browser panel |
84
97
  | `browser_navigate` | Go to URL |
85
- | `browser_snapshot` | Get full page HTML |
86
- | `browser_click` | Click element by CSS selector |
87
- | `browser_fill` | Fill input field |
88
- | `browser_eval` | Execute JavaScript |
98
+ | `browser_screenshot` | Capture page as PNG (CDP) |
99
+ | `browser_snapshot` | Get page structure with interactive element refs |
100
+ | `browser_click` | Click element by ref number |
101
+ | `browser_fill` | Fill form fields by ref |
102
+ | `browser_type` | Type text into element (CDP keyboard input) |
103
+ | `browser_press_key` | Press keyboard key (Enter, Tab, etc.) |
104
+ | `browser_evaluate` | Execute JavaScript in page context |
105
+ | `browser_hover` | Hover over element |
106
+ | `browser_select` | Select dropdown options |
107
+ | `browser_scroll_into_view` | Scroll element into viewport |
89
108
  | `terminal_read` | Read terminal screen |
90
109
  | `terminal_send` | Send text to terminal |
91
110
  | `terminal_send_key` | Send key (enter, ctrl+c, etc.) |
@@ -95,12 +114,26 @@ wmux automatically registers its MCP server when launched. Claude Code can:
95
114
 
96
115
  **Multi-agent:** All browser tools accept `surfaceId` — each Claude Code session controls its own browser independently.
97
116
 
117
+ ### Security
118
+ - **Token authentication** on all IPC pipes (named pipe + session pipes)
119
+ - **SSRF protection** — URL validation blocks private IPs, file://, javascript: schemes
120
+ - **Input sanitization** — PTY command injection prevention
121
+ - **CDP port randomization** — no fixed debug port
122
+ - **Memory pressure watchdog** — auto-reaps dead sessions at 750MB, blocks new at 1GB
123
+ - **Electron Fuses** — RunAsNode disabled, cookie encryption enabled
124
+
98
125
  ### Agent Status Detection
99
126
  Gate-based detection for AI coding agents:
100
127
  - Claude Code, Cursor, Aider, Codex CLI, Gemini CLI, OpenCode, GitHub Copilot CLI
101
- - Detects agent startup activates monitoring
128
+ - Detects agent startup, monitors activity
102
129
  - Critical action warnings (git push --force, rm -rf, DROP TABLE, etc.)
103
130
 
131
+ ### Daemon Process
132
+ - Background session management (survives app restart)
133
+ - Suspend/resume with scrollback buffer dump
134
+ - Auto-recovery of sessions on daemon restart
135
+ - Dead session TTL reaping (24h default)
136
+
104
137
  ### Themes
105
138
  Catppuccin, Tokyo Night, Dracula, Nord, Gruvbox, Solarized, One Dark, and more.
106
139
 
@@ -171,32 +204,48 @@ The `install.ps1` script auto-installs Python and VS Build Tools if missing.
171
204
 
172
205
  ```
173
206
  Electron Main Process
174
- ├── PTYManager (node-pty)
207
+ ├── PTYManager (node-pty / ConPTY)
175
208
  ├── PTYBridge (data forwarding + ActivityMonitor)
176
209
  ├── AgentDetector (gate-based agent status)
177
- ├── PipeServer (Named Pipe JSON-RPC)
210
+ ├── SessionManager (atomic save with .bak recovery)
211
+ ├── ScrollbackPersistence (dump/load terminal buffers)
212
+ ├── PipeServer (Named Pipe JSON-RPC + token auth)
178
213
  ├── McpRegistrar (auto-registers MCP in ~/.claude.json)
214
+ ├── WebviewCdpManager (CDP proxy to <webview> via debugger)
215
+ ├── DaemonClient (optional daemon mode connector)
179
216
  └── ToastManager (OS notifications + taskbar flash)
180
217
 
181
218
  Renderer Process (React 19 + Zustand)
182
219
  ├── PaneContainer (recursive split layout)
183
- ├── Terminal (xterm.js + WebGL)
184
- ├── BrowserPanel (webview + Inspector)
220
+ ├── Terminal (xterm.js + WebGL + scrollback restore)
221
+ ├── BrowserPanel (webview + Inspector + CDP)
185
222
  ├── NotificationPanel
223
+ ├── SettingsPanel (workspace reset)
186
224
  └── Multiview grid
187
225
 
226
+ Daemon Process (optional, standalone)
227
+ ├── DaemonSessionManager (ConPTY lifecycle)
228
+ ├── RingBuffer (circular scrollback buffer)
229
+ ├── StateWriter (session suspend/resume)
230
+ ├── ProcessMonitor (external process watchdog)
231
+ ├── Watchdog (memory pressure escalation)
232
+ └── DaemonPipeServer (Named Pipe RPC + token auth)
233
+
188
234
  MCP Server (stdio)
189
- └── Bridges Claude Code ↔ wmux via Named Pipe RPC
235
+ ├── PlaywrightEngine (CDP connection, fast-fail)
236
+ ├── CDP RPC fallback (browser.screenshot, browser.evaluate, etc.)
237
+ └── Bridges Claude Code <-> wmux via Named Pipe RPC
190
238
  ```
191
239
 
192
240
  ---
193
241
 
194
242
  ## Acknowledgments
195
243
 
196
- - [cmux](https://github.com/manaflow-ai/cmux) — The macOS AI agent terminal that inspired wmux. Same philosophy: primitives over prescriptive workflows.
244
+ - [cmux](https://github.com/manaflow-ai/cmux) — The macOS AI agent terminal that inspired wmux
197
245
  - [xterm.js](https://xtermjs.org/) — Terminal rendering
198
246
  - [node-pty](https://github.com/microsoft/node-pty) — Pseudo-terminal
199
247
  - [Electron](https://www.electronjs.org/) — Desktop framework
248
+ - [Playwright](https://playwright.dev/) — Browser automation engine
200
249
 
201
250
  ---
202
251
 
@@ -4,24 +4,25 @@ exports.handleBrowser = handleBrowser;
4
4
  const client_1 = require("../client");
5
5
  const utils_1 = require("../utils");
6
6
  const BROWSER_HELP = `
7
- wmux browser — Scriptable Browser API
7
+ wmux browser — Browser Commands
8
8
 
9
9
  USAGE
10
10
  wmux browser <subcommand> [args]
11
11
 
12
12
  SUBCOMMANDS
13
- snapshot Return the full page HTML (document.documentElement.outerHTML)
14
- click <selector> Click the first element matching the CSS selector
15
- fill <selector> <text> Set the value of an input matching the CSS selector
16
- eval <code> Execute arbitrary JavaScript in the page context
17
13
  navigate <url> Navigate the active browser surface to a URL
14
+ close Close the browser panel
15
+ session start [--profile <name>] Start a browser session
16
+ session stop Stop the active browser session
17
+ session status Show active session status
18
+ session list List available profiles
18
19
 
19
20
  EXAMPLES
20
- wmux browser snapshot
21
- wmux browser click "#submit-btn"
22
- wmux browser fill "input[name=email]" "user@example.com"
23
- wmux browser eval "document.title"
24
21
  wmux browser navigate "https://example.com"
22
+ wmux browser close
23
+ wmux browser session start --profile login
24
+ wmux browser session status
25
+ wmux browser session list
25
26
  `.trimStart();
26
27
  async function handleBrowser(args, jsonMode) {
27
28
  const sub = args[0];
@@ -32,30 +33,14 @@ async function handleBrowser(args, jsonMode) {
32
33
  }
33
34
  let response;
34
35
  switch (sub) {
35
- // ── browser snapshot ─────────────────────────────────────────────────────
36
- case 'snapshot': {
37
- response = await (0, client_1.sendRequest)('browser.snapshot', {});
38
- if (jsonMode) {
39
- (0, utils_1.printResult)(response);
40
- }
41
- else {
42
- if (!response.ok) {
43
- (0, utils_1.printError)(response);
44
- return;
45
- }
46
- const r = response.result;
47
- process.stdout.write(r?.html ?? '');
48
- }
49
- break;
50
- }
51
- // ── browser click <selector> ─────────────────────────────────────────────
52
- case 'click': {
53
- const selector = rest[0];
54
- if (!selector) {
55
- console.error('Error: browser click requires <selector>');
36
+ // ── browser navigate <url> ───────────────────────────────────────────────
37
+ case 'navigate': {
38
+ const url = rest[0];
39
+ if (!url) {
40
+ console.error('Error: browser navigate requires <url>');
56
41
  process.exit(1);
57
42
  }
58
- response = await (0, client_1.sendRequest)('browser.click', { selector });
43
+ response = await (0, client_1.sendRequest)('browser.navigate', { url });
59
44
  if (jsonMode) {
60
45
  (0, utils_1.printResult)(response);
61
46
  }
@@ -64,19 +49,13 @@ async function handleBrowser(args, jsonMode) {
64
49
  (0, utils_1.printError)(response);
65
50
  return;
66
51
  }
67
- console.log(`Clicked: ${selector}`);
52
+ console.log(`Navigated to: ${url}`);
68
53
  }
69
54
  break;
70
55
  }
71
- // ── browser fill <selector> <text> ───────────────────────────────────────
72
- case 'fill': {
73
- const selector = rest[0];
74
- const text = rest.slice(1).join(' ');
75
- if (!selector) {
76
- console.error('Error: browser fill requires <selector> <text>');
77
- process.exit(1);
78
- }
79
- response = await (0, client_1.sendRequest)('browser.fill', { selector, text });
56
+ // ── browser close ────────────────────────────────────────────────────────
57
+ case 'close': {
58
+ response = await (0, client_1.sendRequest)('browser.close', {});
80
59
  if (jsonMode) {
81
60
  (0, utils_1.printResult)(response);
82
61
  }
@@ -85,48 +64,93 @@ async function handleBrowser(args, jsonMode) {
85
64
  (0, utils_1.printError)(response);
86
65
  return;
87
66
  }
88
- console.log(`Filled "${selector}" with "${text}"`);
67
+ console.log('Browser panel closed.');
89
68
  }
90
69
  break;
91
70
  }
92
- // ── browser eval <code> ──────────────────────────────────────────────────
93
- case 'eval': {
94
- const code = rest.join(' ');
95
- if (!code) {
96
- console.error('Error: browser eval requires <code>');
97
- process.exit(1);
98
- }
99
- response = await (0, client_1.sendRequest)('browser.eval', { code });
100
- if (jsonMode) {
101
- (0, utils_1.printResult)(response);
71
+ // ── browser session <action> ─────────────────────────────────────────────
72
+ case 'session': {
73
+ const action = rest[0];
74
+ if (!action || action === '--help' || action === '-h') {
75
+ console.log('Usage: wmux browser session <start|stop|status|list>');
76
+ process.exit(0);
102
77
  }
103
- else {
104
- if (!response.ok) {
105
- (0, utils_1.printError)(response);
106
- return;
78
+ switch (action) {
79
+ case 'start': {
80
+ const profileIdx = rest.indexOf('--profile');
81
+ const profile = profileIdx !== -1 ? rest[profileIdx + 1] : undefined;
82
+ const params = {};
83
+ if (profile)
84
+ params['profile'] = profile;
85
+ response = await (0, client_1.sendRequest)('browser.session.start', params);
86
+ if (jsonMode) {
87
+ (0, utils_1.printResult)(response);
88
+ }
89
+ else {
90
+ if (!response.ok) {
91
+ (0, utils_1.printError)(response);
92
+ return;
93
+ }
94
+ const r = response.result;
95
+ console.log(`Session started with profile: ${r['profile'] ?? 'default'}`);
96
+ }
97
+ break;
107
98
  }
108
- const r = response.result;
109
- console.log(JSON.stringify(r?.result, null, 2));
110
- }
111
- break;
112
- }
113
- // ── browser navigate <url> ───────────────────────────────────────────────
114
- case 'navigate': {
115
- const url = rest[0];
116
- if (!url) {
117
- console.error('Error: browser navigate requires <url>');
118
- process.exit(1);
119
- }
120
- response = await (0, client_1.sendRequest)('browser.navigate', { url });
121
- if (jsonMode) {
122
- (0, utils_1.printResult)(response);
123
- }
124
- else {
125
- if (!response.ok) {
126
- (0, utils_1.printError)(response);
127
- return;
99
+ case 'stop': {
100
+ response = await (0, client_1.sendRequest)('browser.session.stop', {});
101
+ if (jsonMode) {
102
+ (0, utils_1.printResult)(response);
103
+ }
104
+ else {
105
+ if (!response.ok) {
106
+ (0, utils_1.printError)(response);
107
+ return;
108
+ }
109
+ console.log('Session stopped.');
110
+ }
111
+ break;
128
112
  }
129
- console.log(`Navigated to: ${url}`);
113
+ case 'status': {
114
+ response = await (0, client_1.sendRequest)('browser.session.status', {});
115
+ if (jsonMode) {
116
+ (0, utils_1.printResult)(response);
117
+ }
118
+ else {
119
+ if (!response.ok) {
120
+ (0, utils_1.printError)(response);
121
+ return;
122
+ }
123
+ const r = response.result;
124
+ console.log(`Active profile: ${r['profile'] ?? 'none'}`);
125
+ console.log(`CDP port: ${r['port'] ?? 'none'}`);
126
+ }
127
+ break;
128
+ }
129
+ case 'list': {
130
+ response = await (0, client_1.sendRequest)('browser.session.list', {});
131
+ if (jsonMode) {
132
+ (0, utils_1.printResult)(response);
133
+ }
134
+ else {
135
+ if (!response.ok) {
136
+ (0, utils_1.printError)(response);
137
+ return;
138
+ }
139
+ const profiles = response.result['profiles'];
140
+ if (!profiles || profiles.length === 0) {
141
+ console.log('No profiles found.');
142
+ }
143
+ else {
144
+ for (const p of profiles) {
145
+ console.log(` ${p['name']} (partition: ${p['partition']}, persistent: ${p['persistent']})`);
146
+ }
147
+ }
148
+ }
149
+ break;
150
+ }
151
+ default:
152
+ console.error(`Unknown session action: "${action}". Use start, stop, status, or list.`);
153
+ process.exit(1);
130
154
  }
131
155
  break;
132
156
  }
@@ -51,11 +51,12 @@ SYSTEM COMMANDS
51
51
  capabilities List all supported RPC methods
52
52
 
53
53
  BROWSER COMMANDS
54
- browser snapshot Return the full page HTML of the active browser surface
55
- browser click <selector> Click an element by CSS selector
56
- browser fill <selector> <text> Fill an input field by CSS selector
57
- browser eval <code> Execute JavaScript in the browser context
58
54
  browser navigate <url> Navigate the browser surface to a URL
55
+ browser close Close the browser panel
56
+ browser session start [--profile <name>] Start a browser session
57
+ browser session stop Stop the active browser session
58
+ browser session status Show active session status
59
+ browser session list List available profiles
59
60
 
60
61
  GLOBAL FLAGS
61
62
  --json Output raw JSON (useful for scripting)
@@ -67,9 +68,8 @@ EXAMPLES
67
68
  wmux send "echo hello"
68
69
  wmux notify --title "Done" --body "Build finished"
69
70
  wmux identify --json
70
- wmux browser snapshot
71
71
  wmux browser navigate "https://example.com"
72
- wmux browser click "#login-btn"
72
+ wmux browser close
73
73
  `.trimStart();
74
74
  const WORKSPACE_CMDS = new Set([
75
75
  'list-workspaces',
@@ -45,6 +45,9 @@ exports.IPC = {
45
45
  FS_WATCH: 'fs:watch',
46
46
  FS_UNWATCH: 'fs:unwatch',
47
47
  FS_CHANGED: 'fs:changed',
48
+ // Scrollback persistence
49
+ SCROLLBACK_DUMP: 'scrollback:dump',
50
+ SCROLLBACK_LOAD: 'scrollback:load',
48
51
  };
49
52
  // Named Pipe / Unix socket path for wmux API
50
53
  // Fixed name so MCP clients (e.g. Claude Code) can reconnect across wmux restarts
@@ -25,9 +25,26 @@ exports.ALL_RPC_METHODS = [
25
25
  'system.identify',
26
26
  'system.capabilities',
27
27
  'browser.open',
28
- 'browser.snapshot',
29
- 'browser.click',
30
- 'browser.fill',
31
- 'browser.eval',
32
28
  'browser.navigate',
29
+ 'browser.close',
30
+ 'browser.session.start',
31
+ 'browser.session.stop',
32
+ 'browser.session.status',
33
+ 'browser.session.list',
34
+ 'browser.type.humanlike',
35
+ 'browser.cdp.target',
36
+ 'browser.cdp.info',
37
+ 'browser.cdp.send',
38
+ 'browser.screenshot',
39
+ 'browser.evaluate',
40
+ 'browser.type.cdp',
41
+ 'browser.click.cdp',
42
+ 'browser.press.cdp',
43
+ 'daemon.createSession',
44
+ 'daemon.destroySession',
45
+ 'daemon.attachSession',
46
+ 'daemon.detachSession',
47
+ 'daemon.resizeSession',
48
+ 'daemon.listSessions',
49
+ 'daemon.ping',
33
50
  ];
@@ -7,19 +7,22 @@ exports.validateMessage = validateMessage;
7
7
  exports.createSurface = createSurface;
8
8
  exports.createLeafPane = createLeafPane;
9
9
  exports.createWorkspace = createWorkspace;
10
+ exports.validateNavigationUrl = validateNavigationUrl;
10
11
  // === Utility: generate unique IDs ===
11
12
  function generateId(prefix) {
12
13
  return `${prefix}-${crypto.randomUUID()}`;
13
14
  }
14
15
  // === Security: sanitize text before PTY write ===
15
16
  /**
16
- * Strips control characters (\r, \n, \x00-\x1f except \t) from text
17
- * that will be written to a PTY, preventing embedded command injection.
17
+ * Strips dangerous control characters from text before writing to a PTY.
18
+ * Removes: NULL byte (\x00) and C1 control characters (\x80-\x9f).
19
+ * Preserves: CR (\r), LF (\n), Tab (\t), ESC sequences (\x1b[...),
20
+ * and other standard terminal control characters needed for normal operation.
18
21
  */
19
22
  function sanitizePtyText(text) {
20
- // Remove all control chars except tab (\x09)
23
+ // Remove NULL byte and C1 control characters (U+0080–U+009F)
21
24
  // eslint-disable-next-line no-control-regex
22
- return text.replace(/[\x00-\x08\x0a-\x1f\x7f\u0080-\u009f]/g, '');
25
+ return text.replace(/[\x00\u0080-\u009f]/g, '');
23
26
  }
24
27
  /**
25
28
  * Validates and clamps a user-supplied name string.
@@ -77,3 +80,104 @@ function createWorkspace(name) {
77
80
  activePaneId: rootPane.id,
78
81
  };
79
82
  }
83
+ // === Security: URL validation for SSRF prevention ===
84
+ /**
85
+ * Validates a URL for safe navigation. Blocks dangerous schemes and private
86
+ * network addresses to prevent SSRF attacks from AI agent-driven browsing.
87
+ *
88
+ * Allows localhost/127.0.0.1/[::1] for local development servers.
89
+ *
90
+ * NOTE (v1 limitation): This is string-based validation only. DNS-resolved IPs
91
+ * are not checked, so DNS rebinding attacks are not mitigated. A future version
92
+ * should resolve hostnames and re-validate the resolved IP.
93
+ */
94
+ function validateNavigationUrl(url) {
95
+ let parsed;
96
+ try {
97
+ parsed = new URL(url);
98
+ }
99
+ catch {
100
+ return { valid: false, reason: 'Invalid URL' };
101
+ }
102
+ // Only allow http and https schemes
103
+ const scheme = parsed.protocol.toLowerCase();
104
+ if (scheme !== 'http:' && scheme !== 'https:') {
105
+ return { valid: false, reason: `Blocked URL scheme: ${scheme}` };
106
+ }
107
+ // Extract hostname (strip brackets from IPv6)
108
+ const hostname = parsed.hostname.toLowerCase();
109
+ // Allow localhost and IPv4/IPv6 loopback
110
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
111
+ return { valid: true };
112
+ }
113
+ // Block IPv6 private/link-local ranges
114
+ if (hostname.startsWith('[') || hostname.includes(':')) {
115
+ // Hostname is an IPv6 address (URL parser strips brackets in .hostname)
116
+ const addr = hostname;
117
+ // Block fc00::/7 (unique local) — starts with fc or fd
118
+ if (addr.startsWith('fc') || addr.startsWith('fd')) {
119
+ return { valid: false, reason: 'Blocked private IPv6 address (fc00::/7)' };
120
+ }
121
+ // Block fe80::/10 (link-local) — starts with fe8, fe9, fea, feb
122
+ if (/^fe[89ab]/.test(addr)) {
123
+ return { valid: false, reason: 'Blocked link-local IPv6 address (fe80::/10)' };
124
+ }
125
+ // ::1 already allowed above; block any other loopback representation
126
+ // Normalize: collapse :: and check
127
+ if (addr === '0:0:0:0:0:0:0:1' || addr === '0000:0000:0000:0000:0000:0000:0000:0001') {
128
+ return { valid: true };
129
+ }
130
+ // Block null IPv6 address (:: or 0:0:0:0:0:0:0:0) — equivalent to 0.0.0.0
131
+ if (addr === '::' || addr === '0:0:0:0:0:0:0:0' || addr === '0000:0000:0000:0000:0000:0000:0000:0000') {
132
+ return { valid: false, reason: 'Blocked null IPv6 address (equivalent to 0.0.0.0)' };
133
+ }
134
+ // Block IPv4-mapped IPv6 (::ffff:x.x.x.x) and IPv4-compatible IPv6 (::x.x.x.x)
135
+ // These resolve to their embedded IPv4 address, bypassing IPv4 private IP checks.
136
+ const v4MappedMatch = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(addr);
137
+ const v4CompatMatch = !v4MappedMatch ? /^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(addr) : null;
138
+ const embeddedV4 = v4MappedMatch?.[1] ?? v4CompatMatch?.[1];
139
+ if (embeddedV4) {
140
+ // Recursively validate the embedded IPv4 through the same checks
141
+ const embeddedResult = validateNavigationUrl(`http://${embeddedV4}/`);
142
+ if (!embeddedResult.valid) {
143
+ return { valid: false, reason: `Blocked IPv4-mapped/compatible IPv6: embedded ${embeddedV4} — ${embeddedResult.reason}` };
144
+ }
145
+ }
146
+ return { valid: true };
147
+ }
148
+ // Check for IPv4 addresses
149
+ const ipv4Match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
150
+ if (ipv4Match) {
151
+ const octets = [
152
+ parseInt(ipv4Match[1], 10),
153
+ parseInt(ipv4Match[2], 10),
154
+ parseInt(ipv4Match[3], 10),
155
+ parseInt(ipv4Match[4], 10),
156
+ ];
157
+ // 127.0.0.1 already allowed above; block other 127.x.x.x
158
+ if (octets[0] === 127) {
159
+ return { valid: false, reason: 'Blocked loopback address' };
160
+ }
161
+ // Block 10.0.0.0/8
162
+ if (octets[0] === 10) {
163
+ return { valid: false, reason: 'Blocked private IP address (10.0.0.0/8)' };
164
+ }
165
+ // Block 172.16.0.0/12 (172.16.x.x – 172.31.x.x)
166
+ if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
167
+ return { valid: false, reason: 'Blocked private IP address (172.16.0.0/12)' };
168
+ }
169
+ // Block 192.168.0.0/16
170
+ if (octets[0] === 192 && octets[1] === 168) {
171
+ return { valid: false, reason: 'Blocked private IP address (192.168.0.0/16)' };
172
+ }
173
+ // Block 169.254.0.0/16 (link-local, includes cloud metadata 169.254.169.254)
174
+ if (octets[0] === 169 && octets[1] === 254) {
175
+ return { valid: false, reason: 'Blocked link-local/cloud metadata address (169.254.0.0/16)' };
176
+ }
177
+ // Block 0.0.0.0
178
+ if (octets.every((o) => o === 0)) {
179
+ return { valid: false, reason: 'Blocked null address (0.0.0.0)' };
180
+ }
181
+ }
182
+ return { valid: true };
183
+ }