mobygate 0.5.1 → 0.5.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,66 @@ All notable changes to mobygate are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version numbers are
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.5.3] — 2026-04-19
8
+
9
+ Security pass.
10
+
11
+ ### Changed
12
+
13
+ - **Default listen address is now `127.0.0.1`** (loopback only). Earlier
14
+ versions called `app.listen(PORT)` with no host, which on macOS binds
15
+ to `::` (IPv6 all interfaces) — meaning anyone on your Wi-Fi could
16
+ reach `:3456`, use your Claude Max subscription, and read your
17
+ request logs. New default blocks that; startup banner now calls
18
+ out the bind ("loopback only" vs "⚠ network-reachable — add auth").
19
+ - **Opt-in LAN sharing** via `bind: 0.0.0.0` (or any specific
20
+ interface) in `~/.mobygate/config.yaml`, or via the `BIND` env
21
+ var. If you opt in, consider putting an auth proxy in front of
22
+ the port — the dashboard and HTTP endpoints have no authentication.
23
+
24
+ ### Fixed
25
+
26
+ - **Dashboard XSS** in live-requests and sessions rows. User-
27
+ controlled fields (`model`, `session key`, `model` on session
28
+ entries) were being interpolated directly into `innerHTML`. A
29
+ malicious local process that can reach :3456 could have
30
+ injected `<script>` via a crafted `model` string and executed
31
+ JS in whichever browser tab had the dashboard open. Added an
32
+ `escHtml()` helper and wrapped every user-controlled field
33
+ interpolated via innerHTML.
34
+ - Added `hono >= 4.12.14` as an npm `overrides` entry to clear
35
+ the single `moderate` audit finding (a transitive via
36
+ `@modelcontextprotocol/sdk` → `hono/jsx`). We don't actually
37
+ load hono/jsx, so it was never exploitable, but `npm audit`
38
+ now reports `0 vulnerabilities` — cleaner for downstream users.
39
+
40
+ ### Migration
41
+
42
+ For existing installs: `mobygate update` or
43
+ `npm install -g mobygate@latest` — the postinstall hook restarts
44
+ your service and the new loopback-only bind kicks in.
45
+
46
+ If you were **intentionally** exposing mobygate on the LAN (e.g.,
47
+ "one proxy for the family"), add `bind: 0.0.0.0` to
48
+ `~/.mobygate/config.yaml` and restart. Strongly recommend adding
49
+ an auth proxy (nginx with Basic Auth, Cloudflare Access, etc.)
50
+ in front of the port if you do this.
51
+
52
+ ## [0.5.2] — 2026-04-19
53
+
54
+ ### Added
55
+
56
+ - **`mobygate init` auto-opens the dashboard** in the user's default
57
+ browser after the smoke test passes. Closes the last "copy this
58
+ URL to your browser" step of the install flow. Opt out with
59
+ `--no-browser` or `MOBYGATE_NO_BROWSER=1` for headless / CI /
60
+ nested-script contexts. Also auto-skipped when stdout isn't a TTY.
61
+ - macOS: `open`
62
+ - Linux: `xdg-open`
63
+ - Windows: `cmd /c start`
64
+ - Silent no-op if the OS call fails — init never errors out
65
+ because a browser couldn't launch.
66
+
7
67
  ## [0.5.1] — 2026-04-19
8
68
 
9
69
  ### Fixed
package/bin/mobygate.js CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  installLinuxServices, uninstallLinuxServices,
36
36
  queryLinuxUnit, startLinuxUnit, stopLinuxUnit, LINUX_UNITS,
37
37
  nonMacInstallInstructions,
38
- cleanupLegacyServices, killPort,
38
+ cleanupLegacyServices, killPort, openBrowser,
39
39
  } from '../lib/platform.js';
40
40
  import { getAuthStatus, forceRefresh } from '../scripts/auth-helper.js';
41
41
  import { banner, compactBanner } from '../lib/ascii.js';
@@ -287,6 +287,17 @@ async function cmdInit() {
287
287
  print(`Dashboard: ${c.cyan(`http://localhost:${port}`)}`);
288
288
  print(`Configure: ${c.cyan(CONFIG_PATH)}`);
289
289
  print(`Try: ${c.cyan('mobygate status')} | ${c.cyan('mobygate logs')} | ${c.cyan('mobygate auth')}`);
290
+
291
+ // Auto-open the dashboard unless explicitly suppressed (headless / CI /
292
+ // nested scripts). Silent no-op if the OS call fails — we never want init
293
+ // to error out because a browser couldn't launch.
294
+ const noBrowser = process.argv.includes('--no-browser')
295
+ || process.env.MOBYGATE_NO_BROWSER === '1'
296
+ || !process.stdout.isTTY;
297
+ if (!noBrowser) {
298
+ const opened = openBrowser(`http://localhost:${port}`);
299
+ if (opened) print(c.dim(`\nOpening dashboard in your browser…`));
300
+ }
290
301
  }
291
302
 
292
303
  function cmdStart() {
@@ -605,7 +616,8 @@ function usage() {
605
616
  print(`mobygate — OpenAI → Claude Max local gateway
606
617
 
607
618
  Usage:
608
- mobygate init Interactive setup (add --yes to skip prompts)
619
+ mobygate init Interactive setup (add --yes to skip prompts,
620
+ --no-browser to not auto-open the dashboard)
609
621
  mobygate update Upgrade to the latest version + restart service
610
622
  mobygate doctor Diagnose version mismatches, zombie services, port conflicts
611
623
  mobygate start Start the proxy service
package/index.html CHANGED
@@ -357,6 +357,17 @@
357
357
  <script type="module">
358
358
  // ───────────────────────── helpers
359
359
  const $ = (id) => document.getElementById(id);
360
+ // Escape HTML in user-controlled strings (request model/session/error
361
+ // fields, session keys, etc.) before innerHTML interpolation. The
362
+ // dashboard is unauthenticated, so any process that can reach the
363
+ // proxy could otherwise inject a <script> via a crafted request and
364
+ // execute JS in whoever's tab is viewing the dashboard.
365
+ const escHtml = (s) => String(s ?? '')
366
+ .replace(/&/g, '&amp;')
367
+ .replace(/</g, '&lt;')
368
+ .replace(/>/g, '&gt;')
369
+ .replace(/"/g, '&quot;')
370
+ .replace(/'/g, '&#39;');
360
371
  const fmt = {
361
372
  time(ts) { return new Date(ts).toLocaleTimeString([], { hour12: false }); },
362
373
  ms(n) { return n == null ? '—' : `${n}`; },
@@ -553,10 +564,10 @@
553
564
  <div class="w-[72px] shrink-0 text-[#C9D9A8] text-xs leading-4">${fmt.time(startEv.ts)}</div>
554
565
  <div class="w-[100px] flex shrink-0 gap-1">${kindChips(startEv)}</div>
555
566
  <div class="w-[180px] flex flex-col shrink-0 gap-0.5">
556
- <div class="text-[#F3EFE4] text-xs leading-4 truncate">${startEv.model || '—'}</div>
557
- <div class="text-[#5A5F54] text-[10px] leading-3">${fmt.modelBase(startEv.model)} · ${fmt.modelCtx(startEv.resolvedModel)}</div>
567
+ <div class="text-[#F3EFE4] text-xs leading-4 truncate">${escHtml(startEv.model) || '—'}</div>
568
+ <div class="text-[#5A5F54] text-[10px] leading-3">${escHtml(fmt.modelBase(startEv.model))} · ${escHtml(fmt.modelCtx(startEv.resolvedModel))}</div>
558
569
  </div>
559
- <div class="w-[110px] shrink-0 text-[#8A9A6A] text-xs leading-4 truncate" title="${startEv.session || ''}">${startEv.session ? fmt.short(startEv.session) : '—'}</div>
570
+ <div class="w-[110px] shrink-0 text-[#8A9A6A] text-xs leading-4 truncate" title="${escHtml(startEv.session || '')}">${startEv.session ? escHtml(fmt.short(startEv.session)) : '—'}</div>
560
571
  <div class="grow flex flex-col gap-1">${latencyBar(endEv)}</div>
561
572
  <div class="w-[100px] text-right shrink-0 text-[#8A9A6A] text-[11px] leading-[14px]">${endEv && (endEv.inputTokens || endEv.outputTokens) ? `${endEv.inputTokens || 0}/${endEv.outputTokens || 0}` : '—'}</div>
562
573
  <div class="w-[70px] flex justify-end shrink-0">${statusPill(endEv)}</div>
@@ -680,12 +691,12 @@
680
691
  const row = document.createElement('div');
681
692
  row.className = 'flex items-center py-3 px-6 gap-4 border-b border-[#1A1A15]';
682
693
  row.innerHTML = `
683
- <div class="grow min-w-0 text-[#F3EFE4] text-xs leading-4 truncate" title="${s.key}">${s.key}</div>
684
- <div class="w-[160px] shrink-0 text-[#8A9A6A] text-xs leading-4 truncate">${s.model || '—'}</div>
685
- <div class="w-[60px] text-right shrink-0 text-[#8A9A6A] text-xs">${s.messageCount}</div>
694
+ <div class="grow min-w-0 text-[#F3EFE4] text-xs leading-4 truncate" title="${escHtml(s.key)}">${escHtml(s.key)}</div>
695
+ <div class="w-[160px] shrink-0 text-[#8A9A6A] text-xs leading-4 truncate">${escHtml(s.model) || '—'}</div>
696
+ <div class="w-[60px] text-right shrink-0 text-[#8A9A6A] text-xs">${Number(s.messageCount) || 0}</div>
686
697
  <div class="w-[80px] text-right shrink-0 text-[#5A5F54] text-[11px]">${fmt.uptime(s.idleSec)}</div>
687
698
  <div class="w-[80px] text-right shrink-0 text-[#5A5F54] text-[11px]">${fmt.uptime(s.ttlRemainingSec)} left</div>
688
- <button class="text-[#E89B2E] text-[11px] hover:brightness-110 shrink-0" data-key="${s.key}">expire</button>
699
+ <button class="text-[#E89B2E] text-[11px] hover:brightness-110 shrink-0" data-key="${escHtml(s.key)}">expire</button>
689
700
  `;
690
701
  row.querySelector('button').addEventListener('click', async () => {
691
702
  await fetch('/sessions/' + encodeURIComponent(s.key), { method: 'DELETE' });
package/lib/config.js CHANGED
@@ -27,6 +27,7 @@ export const LOGS_DIR = join(CONFIG_DIR, 'logs');
27
27
 
28
28
  const DEFAULTS = {
29
29
  port: 3456,
30
+ bind: '127.0.0.1', // loopback only by default (no LAN exposure)
30
31
  default_model: 'claude-opus-4-7[1m]',
31
32
  session_ttl_minutes: 60,
32
33
  max_concurrent: null, // reserved for future (per-session throttling)
@@ -57,6 +58,7 @@ export function loadConfig() {
57
58
 
58
59
  const merged = {
59
60
  port: parseInt(process.env.PORT || String(fileConfig.port ?? DEFAULTS.port), 10),
61
+ bind: process.env.BIND || fileConfig.bind || DEFAULTS.bind,
60
62
  default_model: process.env.DEFAULT_MODEL || fileConfig.default_model || DEFAULTS.default_model,
61
63
  session_ttl_minutes: parseInt(
62
64
  process.env.SESSION_TTL_MINUTES
@@ -91,6 +93,13 @@ export function writeConfig(values = {}) {
91
93
  `# HTTP port the proxy listens on.`,
92
94
  `port: ${merged.port}`,
93
95
  '',
96
+ `# Network interface to bind to. Defaults to 127.0.0.1 (loopback only —`,
97
+ `# the proxy is only reachable from this machine). Change to 0.0.0.0 to`,
98
+ `# share it on the LAN (e.g., "one proxy for the whole family"), but be`,
99
+ `# aware: whoever can reach :port can use your Claude Max subscription`,
100
+ `# and read logs containing your prompts. Add auth if you go LAN-public.`,
101
+ `bind: ${JSON.stringify(merged.bind)}`,
102
+ '',
94
103
  `# Default Claude model when the client does not specify one.`,
95
104
  `# Other aliases (opus, sonnet, haiku) resolve per MODEL_MAP in server.js.`,
96
105
  `default_model: ${JSON.stringify(merged.default_model)}`,
package/lib/platform.js CHANGED
@@ -401,6 +401,24 @@ export function cleanupLegacyServices() {
401
401
  * Kill whatever currently owns the given port. Best-effort across platforms.
402
402
  * Returns the list of PIDs killed (empty if nothing was bound).
403
403
  */
404
+ /**
405
+ * Open a URL in the user's default browser. No-op on failure — we never
406
+ * want an init to error out because a browser couldn't launch.
407
+ * - macOS: `open <url>`
408
+ * - Linux: `xdg-open <url>` (falls back to sensible-browser)
409
+ * - Windows: `cmd /c start "" <url>`
410
+ */
411
+ export function openBrowser(url) {
412
+ try {
413
+ if (IS_MAC) spawnSync('open', [url], { stdio: 'ignore', detached: true });
414
+ else if (IS_LINUX) spawnSync('xdg-open', [url], { stdio: 'ignore', detached: true });
415
+ else if (IS_WIN) spawnSync('cmd', ['/c', 'start', '""', url], { stdio: 'ignore', detached: true, windowsHide: true });
416
+ return true;
417
+ } catch {
418
+ return false;
419
+ }
420
+ }
421
+
404
422
  export function killPort(port) {
405
423
  const killed = [];
406
424
  if (IS_MAC || IS_LINUX) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobygate",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "OpenAI-compatible local proxy for Claude Max. The Möbius-strip gateway: OpenAI shape in, Claude Max out.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -22,6 +22,9 @@
22
22
  "js-yaml": "^4.1.1",
23
23
  "uuid": "^11.1.0"
24
24
  },
25
+ "overrides": {
26
+ "hono": ">=4.12.14"
27
+ },
25
28
  "engines": {
26
29
  "node": ">=18"
27
30
  },
package/server.js CHANGED
@@ -58,6 +58,10 @@ const __filename = fileURLToPath(import.meta.url);
58
58
  const __dirname = dirname(__filename);
59
59
 
60
60
  const PORT = parseInt(process.env.PORT || '3456', 10);
61
+ // Bind to loopback only by default — no LAN exposure. Users who intentionally
62
+ // want to share the proxy on a network can set bind: 0.0.0.0 (or a specific
63
+ // interface) in ~/.mobygate/config.yaml, but should add auth in front of it.
64
+ const BIND = process.env.BIND || '127.0.0.1';
61
65
  const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'claude-opus-4-7[1m]';
62
66
  const SESSION_TTL_MS = parseInt(process.env.SESSION_TTL_MS || String(60 * 60 * 1000), 10); // 1 hour default
63
67
 
@@ -1090,14 +1094,14 @@ app.get('/dashboard/logs', async (req, res) => {
1090
1094
  // Start
1091
1095
  // ---------------------------------------------------------------------------
1092
1096
 
1093
- app.listen(PORT, async () => {
1097
+ app.listen(PORT, BIND, async () => {
1094
1098
  const ttlMin = Math.round(SESSION_TTL_MS / 60000);
1095
1099
  const meta = await loadBuildMeta();
1096
1100
  console.log(banner({ version: meta.version }));
1097
- console.log(` port ${PORT}`);
1101
+ console.log(` bind ${BIND}:${PORT}${BIND === '127.0.0.1' ? ' (loopback only)' : ' (⚠ network-reachable — add auth)'}`);
1098
1102
  console.log(` model ${DEFAULT_MODEL}`);
1099
1103
  console.log(` session TTL ${ttlMin} min`);
1100
1104
  console.log(` dashboard http://localhost:${PORT}`);
1101
1105
  console.log('');
1102
- dashboardBus.emitEvent({ type: 'server.boot', port: PORT, defaultModel: DEFAULT_MODEL });
1106
+ dashboardBus.emitEvent({ type: 'server.boot', port: PORT, bind: BIND, defaultModel: DEFAULT_MODEL });
1103
1107
  });