mobygate 0.5.2 → 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,51 @@ 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
+
7
52
  ## [0.5.2] — 2026-04-19
8
53
 
9
54
  ### Added
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobygate",
3
- "version": "0.5.2",
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
  });