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.
@@ -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.
@@ -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..06e0c32 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,92 @@ 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_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"))
+ self.assertFalse(meter.trusted_update_remote(""))
class MenubarSourceTests(unittest.TestCase):
@@ -9178,6 +9266,10 @@ 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)
+ 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 = {
@@ -10344,7 +10436,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 +10485,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 +10584,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 +10991,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 +11026,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..2e34667 100644
--- a/token_meter/app.py
+++ b/token_meter/app.py
@@ -271,6 +271,116 @@ 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 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()
+ 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 +718,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 +1932,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 +2152,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 +2396,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 +6008,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):
@@ -9269,8 +9379,7 @@ def health_state():
"runtime_adapter_failures": runtime_adapter_failures(),
"port": PORT,
"page_ready": bool(path),
- "page_path": path,
- "page_candidates": PAGE_CANDIDATES,
+ "page_owned": bool(path),
}
return payload, 200 if path else 503
@@ -9285,19 +9394,64 @@ 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 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
req_path = urlparse(self.path).path
if is_dashboard_page_path(req_path):
path = page_path()
@@ -9305,16 +9459,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",
@@ -9325,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
@@ -9492,6 +9651,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):
@@ -9566,13 +9727,13 @@ 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.
# 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)