From 5d158e10716a6a1e38137ca070b88de6252370d6 Mon Sep 17 00:00:00 2001 From: JM Date: Thu, 10 Sep 2026 10:53:46 -0700 Subject: [PATCH 1/2] Harden local HTTP, Git delivery, and software-update defaults Reject non-loopback Host headers, add frame-ancestors, and strip prompt fields from dashboard JSON. Git delivery ignores local aliases and hooks. Quota HTTP no longer follows redirects. Auto-install is off by default and only runs from the official origin. Health no longer lists local path candidates. systemd unit generation rejects control characters in the install path. Fixes #34 #35 #36 #37 #38 #39 #40 #41 --- README.md | 7 +- page.html | 34 ++----- scripts/install-systemd-user | 11 ++ specs/USER_GUIDE.md | 19 ++-- tests/contracts/test_quota_privacy.py | 9 ++ tests/test_codex_model_identity.py | 2 + tests/test_git_delivery.py | 13 +++ tests/test_meter.py | 64 +++++++++++- token_meter/app.py | 140 ++++++++++++++++++++++++-- token_meter/quotas/common.py | 15 ++- token_meter/services/git_delivery.py | 54 +++++++--- 11 files changed, 302 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 73c52e3..32f329b 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,8 @@ signal, not a code-quality or productivity score. Manage monthly budgets, model pricing, language signals, native preferences, and local read-only connections for Codex and Claude. Software update checks -and automatic installation are separate settings; both are on by default. +and automatic installation are separate settings; checks are on by default, +and automatic installation is off.

Token Meter Settings view for local read-only agent connections @@ -188,8 +189,8 @@ content, prompts, responses, reasoning text, tool payloads, or trace paths. Use the macOS menu bar, Linux tray, or beta Windows extension to reach the current or pinned run. The native clients read a compact local payload and do not parse traces or read provider credentials directly. Token Meter checks for -updates every 10 minutes and installs safe `main` updates automatically by -default, so normal updates do not require opening the dashboard. If automatic +updates every 10 minutes. Automatic installation of `main` updates is off by +default and only runs from the official GitHub origin. If automatic installation is off, the native menu shows **New update available** instead.

diff --git a/page.html b/page.html index 38ad81e..e878e9b 100644 --- a/page.html +++ b/page.html @@ -1179,10 +1179,10 @@

Getting started

Stored locally by Token Meter.
-

Software updates

Waiting
+

Software updates

Waiting
- +
Waiting for the first update check.
@@ -3211,10 +3211,9 @@

Assign model to this session

const groups=[]; let cur=null; (series||[]).forEach((s,idx)=>{ - const msg=String(s.user_message||s.user_input||'').trim(); const exec=s.i??idx+1; - if(msg||!cur){ - cur={i:groups.length+1,message:msg||'No user message captured',hasMessage:!!msg,start:exec,end:exec,steps:0,cost:0,input:0,output:0,tools:0,peakContext:null}; + if(!cur){ + cur={i:groups.length+1,message:'',hasMessage:false,start:exec,end:exec,steps:0,cost:0,input:0,output:0,tools:0,peakContext:null}; groups.push(cur); } const p=chartParts(s); @@ -3268,7 +3267,7 @@

Assign model to this session

tip.style.display='block'; tip.style.left=left+'px'; tip.style.top=top+'px'; const row=(cls,name,val)=>`
${name}${val}
`; const execRange=g.start===g.end?`#${g.start}`:`#${g.start}-${g.end}`; - const messageBlock=g.hasMessage?`
user message${esc(g.message)}
`:''; + const messageBlock=''; const est=CURRENT?.provider==='cursor'?' est':''; tip.innerHTML=`
request #${g.i}
${row('tools','steps',f(g.steps))}${row('in','executions',execRange)}${row('cost','cost',metricAvailable(CURRENT,'cost')?money(g.cost)+est:'--')}${row('in',est?'input context':'input total',metricAvailable(CURRENT,'input_tokens')?f(g.input)+est:'--')}${row('out',est?'visible output':'output',metricAvailable(CURRENT,'output_tokens')?f(g.output)+est:'--')}${row('tools','tool calls',f(g.tools))}${row('context','peak context',g.peakContext===null?'--':pct(g.peakContext))}${messageBlock}`; } @@ -3303,7 +3302,7 @@

Assign model to this session

const local=point.matrixTransform(svg.getScreenCTM().inverse()),index=Math.max(0,Math.min(n-1,Math.round((local.x-ML)/PW*(n-1)))); const row=samples[index],xx=x(index),yy=y(row.duration_s),group=groups[index]; hoverGroup.setAttribute('opacity','1');cross.setAttribute('x1',xx);cross.setAttribute('x2',xx);dot.setAttribute('cx',xx);dot.setAttribute('cy',yy); - if(tip){const rect=svg.parentElement.getBoundingClientRect(),message=group?.hasMessage?`
user message${esc(group.message)}
`:'',cursor=CURRENT?.provider==='cursor';tip.innerHTML=`
request #${index+1}
wait time${waitFmt(row.duration_s)}
model${esc(row.model||'unknown')}
tool calls${f(row.tool_calls)}
${cursor?'visible output est':'output'}${metricAvailable(CURRENT,'output_tokens')?f(row.output_tokens)+' tok'+(cursor?' est':''):'--'}
timing${esc(row.timing_basis||'observed')}
${message}`;tip.style.display='block';tip.style.left=Math.min(rect.width-tip.offsetWidth-8,Math.max(8,clientX-rect.left+12))+'px';tip.style.top=Math.max(8,clientY-rect.top-tip.offsetHeight-10)+'px';} + if(tip){const rect=svg.parentElement.getBoundingClientRect(),cursor=CURRENT?.provider==='cursor';tip.innerHTML=`
request #${index+1}
wait time${waitFmt(row.duration_s)}
model${esc(row.model||'unknown')}
tool calls${f(row.tool_calls)}
${cursor?'visible output est':'output'}${metricAvailable(CURRENT,'output_tokens')?f(row.output_tokens)+' tok'+(cursor?' est':''):'--'}
timing${esc(row.timing_basis||'observed')}
`;tip.style.display='block';tip.style.left=Math.min(rect.width-tip.offsetWidth-8,Math.max(8,clientX-rect.left+12))+'px';tip.style.top=Math.max(8,clientY-rect.top-tip.offsetHeight-10)+'px';} } hit.addEventListener('mousemove',event=>hoverAt(event.clientX,event.clientY)); hit.addEventListener('mouseleave',()=>{hoverGroup.setAttribute('opacity','0');if(tip)tip.style.display='none';}); @@ -3402,13 +3401,7 @@

Assign model to this session

if(p.tools>=toolCut) marks+=``; if(p.reason>=reasonCut) marks+=``; if(s.side) marks+=``; - if(s.user_message||s.user_input){ - const u=primaryFor(p); - if(u.value!==null&&u.value!==undefined&&!Number.isNaN(u.value)){ - const yy=y(u.value); - marks+=``; - } - } + }); const yTitle=mode==='cost'?(cumulativeCost?'cumulative usd':'usd'):(mode==='context'?'context':(mode==='tools'?'tool calls':(cumulativeTokens?'cumulative tokens':'tokens'))); const titles=`execution${yTitle}`; @@ -3441,10 +3434,8 @@

Assign model to this session

tip.style.left=left+'px'; tip.style.top=top+'px'; const row=(cls,name,val)=>`
${name}${val}
`; - const userMessage=s.user_message||s.user_input||''; - const markers=[userMessage?'user message':null,p.tools?`${f(p.tools)} tool calls`:null,p.reason?`${f(p.reason)} reasoning`:null,s.side?'coordination':null].filter(Boolean).join(' / '); - const prompt=userMessage?esc(userMessage):'--'; - const promptBlock=userMessage?`
user message${prompt}
`:''; + const markers=[p.tools?`${f(p.tools)} tool calls`:null,p.reason?`${f(p.reason)} reasoning`:null,s.side?'coordination':null].filter(Boolean).join(' / '); + const promptBlock=''; const cursor=CURRENT?.provider==='cursor',est=cursor?' est':''; const costLabel=cumulativeCost?'cumulative cost':'cost'; const common=[ @@ -3548,12 +3539,7 @@

Assign model to this session

} function sessionStartMessage(s){ - for(const row of (s?.series||[])){ - const message=String(row?.user_message||row?.user_input||'').trim(); - if(message)return message; - } - const event=(s?.trace||[]).find(row=>row?.kind==='user'||row?.label==='User message'); - return String(event?.detail||'').trim(); + return ''; } function sessionDisplayName(s){ diff --git a/scripts/install-systemd-user b/scripts/install-systemd-user index 69b1c87..d2f7439 100755 --- a/scripts/install-systemd-user +++ b/scripts/install-systemd-user @@ -9,6 +9,17 @@ TRAY_UNIT="$SYSTEMD_USER_DIR/token-meter-tray.service" PYTHON_BIN="$(command -v python3 || true)" UNIT_ROOT="${ROOT// /\x20}" +case "$ROOT" in + *$'\n'*|*$'\r'*|*$'"'*|*$'`'*|*$'\\'*|*$'$'*) + echo "Token Meter install path contains unsupported characters." >&2 + exit 1 + ;; +esac +if [[ "$ROOT" == *[[:cntrl:]]* || "$PYTHON_BIN" == *[[:cntrl:]]* || "$PYTHON_BIN" == *$'"'* ]]; then + echo "Token Meter install path contains unsupported characters." >&2 + exit 1 +fi + case "$MODE" in all|server-only|menubar-only) ;; *) diff --git a/specs/USER_GUIDE.md b/specs/USER_GUIDE.md index af54409..c4cfcd1 100644 --- a/specs/USER_GUIDE.md +++ b/specs/USER_GUIDE.md @@ -226,17 +226,18 @@ model provider under its own terms. ## Software Updates -**Check for updates every 10 minutes** and **Automatically install available -updates** are both enabled by default. They are separate controls: turning off -automatic installation keeps checks running, while turning off checks also -turns off automatic installation. The interval is fixed at 10 minutes while -the server is active. Checks fetch revision metadata without modifying the -checkout. +**Check for updates every 10 minutes** is enabled by default. +**Automatically install available updates** is off by default. They are +separate controls: turning off automatic installation keeps checks running, +while turning off checks also turns off automatic installation. The interval +is fixed at 10 minutes while the server is active. Checks fetch revision +metadata without modifying the checkout. Automatic installation is limited to a managed checkout that is on `main`, -tracks a remote `main`, is clean and non-diverged, and is behind upstream. A -safe update fast-forwards the checkout, reruns the installer, and returns after -the local server restarts. Other branches, dirty checkouts, and diverged +tracks the official `https://github.com/splunk/token-meter.git` origin, is +clean and non-diverged, and is behind upstream. A safe update fast-forwards +the checkout, reruns the installer, and returns after the local server +restarts. Other branches, dirty checkouts, untrusted origins, and diverged history remain untouched and report that the update needs attention. Normal automatic updates do not require the dashboard. When automatic diff --git a/tests/contracts/test_quota_privacy.py b/tests/contracts/test_quota_privacy.py index f0b5b4d..ddfb62d 100644 --- a/tests/contracts/test_quota_privacy.py +++ b/tests/contracts/test_quota_privacy.py @@ -58,6 +58,15 @@ def test_http_response_is_bounded_before_json_parsing(self): opener=lambda request, timeout: _Response(oversized), ) + def test_redirects_fail_closed_without_following(self): + from token_meter.quotas.common import _NoRedirectHandler + + handler = _NoRedirectHandler() + with self.assertRaises(QuotaUnavailable) as raised: + handler.redirect_request(None, None, 302, "Found", {}, "https://evil.example/x") + self.assertEqual(str(raised.exception), "Provider quota request redirected.") + self.assertNotIn("evil.example", str(raised.exception)) + def test_unavailable_quota_is_not_reported_as_measured_zero(self): snapshot = quota_provider( "provider", diff --git a/tests/test_codex_model_identity.py b/tests/test_codex_model_identity.py index fc7b14f..16a0c80 100644 --- a/tests/test_codex_model_identity.py +++ b/tests/test_codex_model_identity.py @@ -178,6 +178,7 @@ def test_local_action_route_accepts_only_the_opaque_session_key(self): handler = object.__new__(meter.H) handler.path = "/settings/session-model-identity" handler.headers = { + "Host": "127.0.0.1:8722", "Content-Type": "application/json", "Content-Length": "115", "X-Token-Meter-Action": meter._ACTION_TOKEN, @@ -369,6 +370,7 @@ def test_local_action_route_forwards_the_hermes_provider(self): "provider": "hermes", }).encode("utf-8") handler.headers = { + "Host": "127.0.0.1:8722", "Content-Type": "application/json", "Content-Length": str(len(body)), "X-Token-Meter-Action": meter._ACTION_TOKEN, diff --git a/tests/test_git_delivery.py b/tests/test_git_delivery.py index 9ca4462..34a5034 100644 --- a/tests/test_git_delivery.py +++ b/tests/test_git_delivery.py @@ -147,6 +147,19 @@ def test_subprocess_runner_supplies_a_system_path_for_launch_agents(self): ) self.assertEqual(run.call_args.kwargs["env"]["PATH"], os.defpath) + self.assertEqual(run.call_args.kwargs["env"]["GIT_CONFIG_NOSYSTEM"], "1") + self.assertNotIn("GIT_CONFIG_GLOBAL", run.call_args.kwargs["env"]) + + def test_git_argv_disables_aliases_and_hooks(self): + argv = git_delivery.git_argv("/repo", ("rev-parse", "--show-toplevel")) + self.assertEqual(argv[0], "git") + self.assertIn("core.hooksPath=/dev/null", argv) + self.assertIn("alias.rev-parse=", argv) + self.assertIn("/repo", argv) + with self.assertRaises(ValueError): + git_delivery.git_argv("/repo", ("fetch", "origin")) + with self.assertRaises(ValueError): + git_delivery.git_argv("/repo", ("-C", "/tmp")) def test_scan_limits_generator_candidates_without_losing_limit_coverage(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_meter.py b/tests/test_meter.py index 2f0ba99..7b03576 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -3695,6 +3695,7 @@ def test_current_session_rebuilds_immediately_then_coalesces_changes(self): def test_session_detail_reuses_cached_cross_session_snapshot(self): handler = object.__new__(meter.H) handler.path = "/session?id=session-1" + handler.headers = {"Host": "127.0.0.1:8722"} sent = [] handler._send = lambda body, *_args, **_kwargs: sent.append(json.loads(body)) cached = {"current_sessions": []} @@ -6616,9 +6617,10 @@ def test_software_updates_keep_checks_and_install_preferences_independent(self): "id=update-settings", "id=update-enabled", "id=update-enabled type=checkbox checked", - "id=update-auto-install type=checkbox checked", + "id=update-auto-install type=checkbox", "Check for updates every 10 minutes", "Automatically install available updates", + "Off by default", "Enabled by default", "id=update-check", "id=update-notice", @@ -8459,6 +8461,50 @@ def test_dynamic_responses_cannot_be_reused_from_an_http_cache(self): handler.send_header.assert_any_call("Cache-Control", "no-store, max-age=0") handler.send_header.assert_any_call("Pragma", "no-cache") handler.send_header.assert_any_call("Expires", "0") + handler.send_header.assert_any_call("X-Frame-Options", "DENY") + handler.send_header.assert_any_call("X-Content-Type-Options", "nosniff") + handler.send_header.assert_any_call( + "Content-Security-Policy", meter._HTTP_SECURITY_CSP, + ) + + +class LocalHttpGuardTests(unittest.TestCase): + def test_loopback_http_host_accepts_only_local_names(self): + self.assertEqual(meter.loopback_http_host("127.0.0.1:8722"), "127.0.0.1") + self.assertEqual(meter.loopback_http_host("localhost"), "localhost") + self.assertEqual(meter.loopback_http_host("[::1]:8722"), "::1") + self.assertEqual(meter.loopback_http_host("127.0.0.1.example"), "") + self.assertEqual(meter.loopback_http_host("evil.localhost"), "") + self.assertEqual(meter.loopback_http_host(""), "") + + def test_dashboard_payload_strips_prompt_fields(self): + payload = meter.dashboard_state_payload({ + "ok": True, + "trace": [{"kind": "user", "detail": "secret prompt"}], + "series": [{"i": 1, "user_message": "secret prompt", "user_input": "secret prompt"}], + "executions": [{"idx": 1, "user_message": "secret prompt"}], + }) + encoded = json.dumps(payload) + self.assertNotIn("trace", payload) + self.assertNotIn("user_message", encoded) + self.assertNotIn("user_input", encoded) + self.assertNotIn("secret prompt", encoded) + self.assertEqual(payload["series"][0]["i"], 1) + + def test_foreign_host_is_rejected(self): + handler = object.__new__(meter.H) + handler.path = "/state" + handler.headers = {"Host": "evil.example"} + sent = [] + handler._send = lambda body, *_args, **kwargs: sent.append((kwargs.get("status"), body)) + handler.do_GET() + self.assertEqual(sent[0][0], 403) + self.assertIn("Loopback Host required", sent[0][1]) + + def test_trusted_update_remote_is_pinned_to_official_origin(self): + self.assertTrue(meter.trusted_update_remote("https://github.com/splunk/token-meter.git")) + self.assertFalse(meter.trusted_update_remote("https://github.com/evil/token-meter.git")) + self.assertFalse(meter.trusted_update_remote("")) class MenubarSourceTests(unittest.TestCase): @@ -9178,6 +9224,7 @@ def test_health_uses_cached_inventory_without_discovering_sessions(self): self.assertTrue(payload["inventory_ready"]) self.assertEqual(payload["sources"], 2400) self.assertEqual(payload["source_clients"], {"codex": 2300, "claude_code": 100}) + self.assertNotIn("page_candidates", payload) def test_health_marks_undiscovered_inventory_unavailable_instead_of_zero(self): inventory = { @@ -10344,7 +10391,13 @@ def enabled_settings(self, root): def runner(self, outputs, calls): def run(command, **kwargs): - args = tuple(command[3:]) + parts = list(command) + index = 1 + while index < len(parts) - 1 and parts[index] == "-c": + index += 2 + if index >= len(parts) or parts[index] != "-C": + raise AssertionError(f"Unexpected git command: {command}") + args = tuple(parts[index + 2:]) calls.append(args) value = outputs.get(args) if isinstance(value, int): @@ -10387,7 +10440,7 @@ def test_update_setting_defaults_on_and_preserves_an_explicit_off_choice(self): stored = json.loads(path.read_text()) explicit = meter.update_settings(str(path)) self.assertTrue(initial["enabled"]) - self.assertTrue(initial["auto_install"]) + self.assertFalse(initial["auto_install"]) self.assertEqual(initial["interval_seconds"], 600) self.assertFalse(invalid["ok"]) self.assertTrue(result["ok"]) @@ -10486,6 +10539,7 @@ def test_update_watcher_starts_a_safe_available_update_when_enabled(self): mock.patch.object( meter, "check_for_software_update", return_value=available, ) as check, + mock.patch.object(meter, "update_origin_is_trusted", return_value=True), mock.patch.object(meter, "start_software_update") as start): with self.assertRaisesRegex(RuntimeError, "stop watcher"): meter.software_update_watcher() @@ -10892,6 +10946,7 @@ def test_linux_installer_uses_xdg_runtime_systemd_and_appindicator_tray(self): self.assertIn('"$INSTALL_ROOT/scripts/install-systemd-user" menubar-only', installer) self.assertIn("systemctl --user is-active", installer) self.assertIn("token-meter-server.service", systemd) + self.assertIn("unsupported characters", systemd) self.assertIn("token-meter-tray.service", systemd) self.assertIn("all|server-only|menubar-only", systemd) self.assertEqual(systemd.count("Restart=on-failure"), 2) @@ -10926,8 +10981,9 @@ def test_removed_agent_defaults_endpoint_returns_not_found(self): errors, responses = [], [] handler.send_error = lambda status: errors.append(status) handler._send = lambda *args, **kwargs: responses.append((args, kwargs)) + handler.headers = {"Host": "127.0.0.1:8722"} if method == "do_POST": - handler.headers = {} + handler.headers = {"Host": "127.0.0.1:8722"} getattr(handler, method)() self.assertEqual(errors, [404]) self.assertEqual(responses, []) diff --git a/token_meter/app.py b/token_meter/app.py index 1e159f4..4ebc1f9 100644 --- a/token_meter/app.py +++ b/token_meter/app.py @@ -271,6 +271,88 @@ def hermes_state_db_path(environ=None): os.environ.get("TOKEN_METER_GIT_DELIVERY_DB", "~/.token-meter/git-delivery.sqlite3") ) PORT = 8722 +_LOOPBACK_HOSTNAMES = frozenset({"127.0.0.1", "localhost", "::1"}) +_TRUSTED_UPDATE_REMOTES = frozenset({ + "https://github.com/splunk/token-meter.git", + "https://github.com/splunk/token-meter", + "git@github.com:splunk/token-meter.git", + "ssh://git@github.com/splunk/token-meter.git", +}) +_HTTP_PRIVATE_KEYS = frozenset({"user_message", "user_input", "user_inputs"}) +_HTTP_SECURITY_CSP = ( + "default-src 'self'; script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; " + "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" +) + + +def loopback_http_host(value): + """Return a loopback hostname if Host/Origin is local; otherwise empty.""" + raw = str(value or "").strip().lower() + if not raw or any(ord(character) < 32 for character in raw): + return "" + if raw.startswith("["): + end = raw.find("]") + if end < 1: + return "" + hostname = raw[1:end] + rest = raw[end + 1:] + if rest and not re.fullmatch(r":\d{1,5}", rest): + return "" + return hostname if hostname in _LOOPBACK_HOSTNAMES else "" + if raw.count(":") == 1: + hostname, port = raw.rsplit(":", 1) + if not port.isdigit(): + return "" + return hostname if hostname in _LOOPBACK_HOSTNAMES else "" + return raw if raw in _LOOPBACK_HOSTNAMES else "" + + +def trusted_update_remote(url): + value = str(url or "").strip().rstrip("/") + if value in _TRUSTED_UPDATE_REMOTES: + return True + return (value + ".git") in _TRUSTED_UPDATE_REMOTES + + +def update_origin_is_trusted(checkout=None, runner=None): + checkout = checkout or source_checkout_path() + if not checkout: + return False + try: + origin = _run_update_git(checkout, ["remote", "get-url", "origin"], runner) + except (OSError, RuntimeError, subprocess.TimeoutExpired, ValueError): + return False + return trusted_update_remote(origin) + + +def strip_private_http_fields(value): + """Drop prompt-bearing keys from a dashboard JSON payload.""" + if isinstance(value, dict): + return { + key: strip_private_http_fields(item) + for key, item in value.items() + if key not in _HTTP_PRIVATE_KEYS + } + if isinstance(value, list): + return [strip_private_http_fields(item) for item in value] + return value + + +def safe_git_command(checkout, args): + """Invoke git without local aliases or hooks.""" + args = list(args) + verb = args[0] if args else "" + if not verb or str(verb).startswith("-"): + raise ValueError("invalid git verb") + return [ + "git", + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=", + "-c", "alias.{}=".format(verb), + "-C", checkout, + *args, + ] DEFAULT_FRUSTRATION_TERMS = [ "fuck", "fck", "fucked", "fucking", "shit", "shitty", "bullshit", @@ -608,8 +690,7 @@ def atomic_write_text(path, text): fh.write(text) fh.flush() os.fsync(fh.fileno()) - if mode is not None: - os.chmod(tmp, mode) + os.chmod(tmp, mode if mode is not None else 0o600) os.replace(tmp, path) @@ -1823,7 +1904,7 @@ def normalize_update_settings(values): if not isinstance(values, dict): raise ValueError("Update settings must be an object.") enabled = values.get("enabled", True) - auto_install = values.get("auto_install", True) + auto_install = values.get("auto_install", False) if not isinstance(enabled, bool) or not isinstance(auto_install, bool): raise ValueError("Update preferences must be on or off.") if not enabled: @@ -2043,7 +2124,7 @@ def _platform_subprocess_kwargs(purpose=ProcessPurpose.DEFAULT): def _run_update_git(checkout, args, runner=None, timeout=None): runner = runner or subprocess.run result = runner( - ["git", "-C", checkout] + list(args), + safe_git_command(checkout, args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -2287,7 +2368,8 @@ def software_update_watcher(): failed_target = _safe_update_revision(previous.get("failed_revision")) if (settings["auto_install"] and status.get("available") is True and status.get("can_update") is True - and target and target != failed_target): + and target and target != failed_target + and update_origin_is_trusted()): start_software_update() _update_wake.wait(UPDATE_CHECK_INTERVAL_S) @@ -5898,7 +5980,7 @@ def dashboard_state_payload(state): cross.get("capabilities") ) payload["xsession"] = public_cross - return payload + return strip_private_http_fields(payload) def session_optional_capabilities(state, capabilities): @@ -9270,7 +9352,6 @@ def health_state(): "port": PORT, "page_ready": bool(path), "page_path": path, - "page_candidates": PAGE_CANDIDATES, } return payload, 200 if path else 503 @@ -9285,19 +9366,50 @@ def handle(self): def log_message(self, *args): pass + def _security_headers(self): + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Content-Security-Policy", _HTTP_SECURITY_CSP) + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("Cache-Control", "no-store, max-age=0") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + + def _reject_nonlocal(self, head=False): + host = "" + try: + host = self.headers.get("Host") or "" + except Exception: + host = "" + if loopback_http_host(host): + return False + if head: + self.send_response(403) + self._security_headers() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", "0") + self.end_headers() + return True + self._send( + json.dumps({"ok": False, "error": "Loopback Host required."}), + "application/json", + status=403, + ) + return True + def _send(self, body, ctype="text/html; charset=utf-8", status=200): if isinstance(body, str): body = body.encode() self.send_response(status) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) - self.send_header("Cache-Control", "no-store, max-age=0") - self.send_header("Pragma", "no-cache") - self.send_header("Expires", "0") + H._security_headers(self) self.end_headers() self.wfile.write(body) def do_HEAD(self): + if self._reject_nonlocal(head=True): + return req_path = urlparse(self.path).path if is_dashboard_page_path(req_path): path = page_path() @@ -9305,16 +9417,20 @@ def do_HEAD(self): self.send_response(200 if path else 503) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(os.path.getsize(path) if path else len(body))) + self._security_headers() self.end_headers() elif (path := dashboard_asset_path(req_path)): self.send_response(200) self.send_header("Content-Type", dashboard_asset_content_type(req_path)) self.send_header("Content-Length", str(os.path.getsize(path))) + self._security_headers() self.end_headers() else: self.send_error(404) def do_POST(self): + if self._reject_nonlocal(): + return req_path = urlparse(self.path).path if req_path not in ("/capability/toggle", "/capability/disable-unused", "/agent-access/toggle", "/session/delete", @@ -9492,6 +9608,8 @@ def do_POST(self): self._send(json.dumps(result), "application/json", status=status) def do_GET(self): + if self._reject_nonlocal(): + return parsed = urlparse(self.path) req_path = parsed.path if is_dashboard_page_path(req_path): @@ -9572,7 +9690,7 @@ def do_GET(self): # even after Chromium replaces the visible tab with an error page. # A 204 response explicitly tells EventSource clients to stop. self.send_response(204) - self.send_header("Cache-Control", "no-store") + self._security_headers() self.send_header("Content-Length", "0") self.send_header("Connection", "close") self.end_headers() diff --git a/token_meter/quotas/common.py b/token_meter/quotas/common.py index 856bc82..b27269a 100644 --- a/token_meter/quotas/common.py +++ b/token_meter/quotas/common.py @@ -6,7 +6,7 @@ import re import time from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen +from urllib.request import HTTPHandler, HTTPSHandler, HTTPRedirectHandler, Request, build_opener from .base import QuotaUnavailable @@ -170,10 +170,19 @@ def quota_slug(value): return slug[:64] or "quota" +class _NoRedirectHandler(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise QuotaUnavailable("Provider quota request redirected.") + + +def _quota_opener(): + return build_opener(_NoRedirectHandler, HTTPHandler, HTTPSHandler) + + def quota_http_json(url, headers=None, timeout=DEFAULT_HTTP_TIMEOUT_S, opener=None): request = Request(url, headers=headers or {}, method="GET") try: - response = (opener or urlopen)(request, timeout=timeout) + response = (opener or _quota_opener().open)(request, timeout=timeout) with response: raw = response.read(MAX_RESPONSE_BYTES + 1) if len(raw) > MAX_RESPONSE_BYTES: @@ -182,6 +191,8 @@ def quota_http_json(url, headers=None, timeout=DEFAULT_HTTP_TIMEOUT_S, opener=No if not isinstance(value, dict): raise QuotaUnavailable("Provider returned an invalid quota response.") return value + except QuotaUnavailable: + raise except HTTPError as exc: code = exc.code exc.close() diff --git a/token_meter/services/git_delivery.py b/token_meter/services/git_delivery.py index 485bc9c..4c7c863 100644 --- a/token_meter/services/git_delivery.py +++ b/token_meter/services/git_delivery.py @@ -24,8 +24,42 @@ _GIT_OID_LENGTHS = frozenset((40, 64)) _MUTATING_OR_NETWORK_GIT_VERBS = frozenset({ "fetch", "pull", "push", "checkout", "switch", "reset", "prune", - "ls-remote", "remote-update", "update-ref", + "ls-remote", "remote-update", "update-ref", "merge", "rebase", + "add", "commit", "am", "cherry-pick", "stash", "clean", "gc", + "alias", "filter-branch", "remote", }) +_GIT_VERB_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$") + + +def git_argv(root, args): + """Build a git command that ignores local aliases and hooks.""" + if not args: + raise ValueError("Unsupported Git operation") + verb = str(args[0]) + if verb.startswith("-") or not _GIT_VERB_RE.fullmatch(verb): + raise ValueError("Unsupported Git operation") + if _MUTATING_OR_NETWORK_GIT_VERBS.intersection(args): + raise ValueError("Unsupported Git operation") + return [ + "git", + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=", + "-c", "alias.{}=".format(verb), + "-C", root, + *args, + ] + + +def git_subprocess_environment(): + """Keep git from reading system/user config or prompting.""" + return { + "PATH": os.environ.get("PATH") or os.defpath, + "HOME": os.path.expanduser("~"), + "LC_ALL": "C", + "GIT_TERMINAL_PROMPT": "0", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_OPTIONAL_LOCKS": "0", + } _REFLOG_TIMESTAMP_RE = re.compile(r"@\{([0-9]{1,20})\}$") _LEDGER_TABLE_COLUMNS = { "delivery_observations": ( @@ -356,12 +390,6 @@ def __init__(self, ledger_path, runner=None, now=None, salt=""): @staticmethod def _subprocess_runner(argv, timeout): - environment = { - "PATH": os.environ.get("PATH") or os.defpath, - "HOME": os.path.expanduser("~"), - "LC_ALL": "C", - "GIT_TERMINAL_PROMPT": "0", - } return subprocess.run( argv, check=False, @@ -372,7 +400,7 @@ def _subprocess_runner(argv, timeout): text=True, encoding="utf-8", errors="replace", - env=environment, + env=git_subprocess_environment(), ) @staticmethod @@ -386,12 +414,12 @@ def _result_parts(result): return code, str(stdout or "")[:MAX_GIT_OUTPUT_BYTES] def _run_git(self, root, args): - if not args or _MUTATING_OR_NETWORK_GIT_VERBS.intersection(args): - raise ValueError("Unsupported Git operation") try: - result = self._runner( - ["git", "-C", root, *args], timeout=GIT_TIMEOUT_SECONDS, - ) + argv = git_argv(root, args) + except ValueError: + raise + try: + result = self._runner(argv, timeout=GIT_TIMEOUT_SECONDS) except (OSError, subprocess.TimeoutExpired): return None, "" return self._result_parts(result) From f5a94b75d774a0d6c85ff61541613c8a78d7f6ff Mon Sep 17 00:00:00 2001 From: JM Date: Fri, 11 Sep 2026 07:54:08 -0700 Subject: [PATCH 2/2] Close remaining local HTTP disclosure and mutation gaps Keep the action token off unauthenticated GET /updates/status, replace /health page_path with a boolean ownership signal, reject remote Origin or Referer on mutations while still allowing native clients that omit Origin, and send 404 responses through the same security headers. --- tests/test_meter.py | 45 +++++++++++++++++++++++++++++++++++++++++ token_meter/app.py | 49 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/tests/test_meter.py b/tests/test_meter.py index 7b03576..06e0c32 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -8501,6 +8501,48 @@ def test_foreign_host_is_rejected(self): self.assertEqual(sent[0][0], 403) self.assertIn("Loopback Host required", sent[0][1]) + def test_missing_origin_mutations_reject_remote_referer_but_allow_native_clients(self): + self.assertTrue(meter.local_mutation_origin("", "")) + self.assertTrue(meter.local_mutation_origin("http://127.0.0.1:8722", "")) + self.assertTrue(meter.local_mutation_origin("http://localhost:8722", "")) + self.assertFalse(meter.local_mutation_origin("https://evil.example", "")) + self.assertFalse(meter.local_mutation_origin("null", "")) + self.assertFalse(meter.local_mutation_origin("", "https://evil.example/attack")) + self.assertTrue(meter.local_mutation_origin("", "http://127.0.0.1:8722/")) + + handler = object.__new__(meter.H) + handler.path = "/settings/updates" + handler.headers = { + "Host": "127.0.0.1:8722", + "Origin": "https://evil.example", + "Content-Type": "application/json", + "X-Token-Meter-Action": "unused", + } + sent = [] + handler._send = lambda body, *_args, **kwargs: sent.append((kwargs.get("status"), body)) + handler.do_POST() + self.assertEqual(sent[0][0], 403) + self.assertIn("Local dashboard origin required", sent[0][1]) + + def test_unauthenticated_update_status_omits_the_action_token(self): + status = meter.public_software_update_status() + self.assertNotIn("token", status.get("actions") or {}) + self.assertIn("install", status.get("actions") or {}) + + def test_error_responses_use_the_same_security_headers(self): + handler = object.__new__(meter.H) + headers = [] + handler.send_response = lambda code: headers.append(("status", code)) + handler.send_header = lambda key, value: headers.append((key, value)) + handler.end_headers = lambda: None + handler.wfile = mock.Mock() + handler.send_error(404) + keys = [key for key, _value in headers] + self.assertIn(404, [value for key, value in headers if key == "status"]) + self.assertIn("X-Content-Type-Options", keys) + self.assertIn("Content-Security-Policy", keys) + self.assertIn("X-Frame-Options", keys) + def test_trusted_update_remote_is_pinned_to_official_origin(self): self.assertTrue(meter.trusted_update_remote("https://github.com/splunk/token-meter.git")) self.assertFalse(meter.trusted_update_remote("https://github.com/evil/token-meter.git")) @@ -9225,6 +9267,9 @@ def test_health_uses_cached_inventory_without_discovering_sessions(self): self.assertEqual(payload["sources"], 2400) self.assertEqual(payload["source_clients"], {"codex": 2300, "claude_code": 100}) self.assertNotIn("page_candidates", payload) + self.assertNotIn("page_path", payload) + self.assertTrue(payload["page_owned"]) + self.assertTrue(payload["page_ready"]) def test_health_marks_undiscovered_inventory_unavailable_instead_of_zero(self): inventory = { diff --git a/token_meter/app.py b/token_meter/app.py index 4ebc1f9..2e34667 100644 --- a/token_meter/app.py +++ b/token_meter/app.py @@ -286,6 +286,34 @@ def hermes_state_db_path(environ=None): ) +def local_mutation_origin(origin, referer=""): + """Allow same-origin dashboard POSTs and native clients that omit Origin. + + Browsers send Origin on fetch/XHR. Native companions do not. A missing + Origin is therefore the native path, unless a remote Referer is present. + """ + origin = str(origin or "").strip() + if origin: + if origin.lower() == "null": + return False + hostname = urlparse(origin).hostname or "" + return hostname in _LOOPBACK_HOSTNAMES + referer = str(referer or "").strip() + if not referer: + return True + hostname = urlparse(referer).hostname or "" + return hostname in _LOOPBACK_HOSTNAMES + + +def public_software_update_status(settings_path=None, status_path=None): + """Update snapshot for unauthenticated GET: capabilities only, no action token.""" + status = software_update_status(settings_path, status_path) + actions = dict(status.get("actions") or {}) + actions.pop("token", None) + status["actions"] = actions + return status + + def loopback_http_host(value): """Return a loopback hostname if Host/Origin is local; otherwise empty.""" raw = str(value or "").strip().lower() @@ -9351,7 +9379,7 @@ def health_state(): "runtime_adapter_failures": runtime_adapter_failures(), "port": PORT, "page_ready": bool(path), - "page_path": path, + "page_owned": bool(path), } return payload, 200 if path else 503 @@ -9407,6 +9435,20 @@ def _send(self, body, ctype="text/html; charset=utf-8", status=200): self.end_headers() self.wfile.write(body) + def send_error(self, code, message=None, explain=None): + short, long_msg = self.responses.get(code, ("Error", "")) + title = message if message is not None else short + detail = explain if explain is not None else long_msg + body = ( + "{0} {1}" + "

{1}

{2}

" + ).format( + int(code), + html.escape(str(title)), + html.escape(str(detail or "")), + ) + self._send(body, "text/html; charset=utf-8", status=int(code)) + def do_HEAD(self): if self._reject_nonlocal(head=True): return @@ -9441,7 +9483,8 @@ def do_POST(self): self.send_error(404) return origin = self.headers.get("Origin") or "" - if origin and (urlparse(origin).hostname or "") not in ("localhost", "127.0.0.1", "::1"): + referer = self.headers.get("Referer") or "" + if not local_mutation_origin(origin, referer): self._send(json.dumps({"ok": False, "error": "Local dashboard origin required."}), "application/json", status=403) return @@ -9684,7 +9727,7 @@ def do_GET(self): payload, status = health_state() self._send(json.dumps(payload), "application/json", status=status) elif req_path == "/updates/status": - self._send(json.dumps(software_update_status()), "application/json") + self._send(json.dumps(public_software_update_status()), "application/json") elif req_path == "/events": # Older dashboard builds used EventSource and can keep reconnecting # even after Chromium replaces the visible tab with an error page.