From cc48c9178cd53c6d9aedb2a7b4e92577ebea946c Mon Sep 17 00:00:00 2001 From: claudeMB Date: Fri, 18 Sep 2026 19:01:44 +0200 Subject: [PATCH] dash: drop wildcard CORS on /api/status, send Referrer-Policy (#12) /api/status returns live car facts, the Pi's process list (ps -eo) and the last agent journal lines. It carried Access-Control-Allow-Origin: *. That header is only inert while every caller needs a token. On home wifi the dashboard authorises by NETWORK instead - _peer_is_owner() trusts any private address once _pi_on_home_wifi() is true, deliberately, so phones at home open the dash without one. Ambient authority plus a wildcard CORS header means any web page open on a phone on that wifi could fetch this endpoint in the background and READ the reply cross-origin. The dashboard is same-origin and never needed the header: /api/status is fetched only from the dash page itself, and it is the sole ACAO in the repo, so nothing external breaks. Also adds Referrer-Policy: no-referrer to the common send path. The token rides in the URL as ?t= so a one-tap link works in the car, which means every outbound link from a dashboard page would otherwise carry the credential in its Referer. That is one of the four leak routes #12 lists, and it is the one that costs nothing to close. This does not address the rest of #12 or #13: the token still travels in URLs and still gates /api/update, which pulls and runs code. Splitting that into tiers changes how petrus repairs the car from the roadside, so it needs his call rather than a quiet commit. Co-Authored-By: Claude Opus 5 --- carwatch/webchat.py | 13 ++++++++- tests/test_dash_headers.py | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/test_dash_headers.py diff --git a/carwatch/webchat.py b/carwatch/webchat.py index 3c2063f..ebec6fc 100644 --- a/carwatch/webchat.py +++ b/carwatch/webchat.py @@ -2353,6 +2353,10 @@ def _send(self, code, body, ctype="text/html; charset=utf-8"): data = body if isinstance(body, bytes) else body.encode() self.send_response(code) self.send_header("Content-Type", ctype) + # The dashboard token travels as ?t= so a plain link works in + # the car with one tap (#12). That means every outbound link from a + # dashboard page would otherwise carry the token in its Referer. + self.send_header("Referrer-Policy", "no-referrer") self.send_header("Content-Length", str(len(data))) if "html" in ctype: # The dashboard changed several times in one day; a cached copy @@ -2856,7 +2860,14 @@ def run(cmd): body = json.dumps(payload).encode() self.send_response(200) self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") + # NO Access-Control-Allow-Origin. This endpoint returns live car + # facts, the Pi's process list and agent journal lines, and on + # home wifi _peer_is_owner() authorises by NETWORK rather than by + # token. Ambient authority plus a wildcard CORS header means any + # web page open on a phone on that wifi could fetch this and read + # the response cross-origin. The dashboard is same-origin and + # never needed the header (#12). + self.send_header("Referrer-Policy", "no-referrer") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) diff --git a/tests/test_dash_headers.py b/tests/test_dash_headers.py new file mode 100644 index 0000000..87be318 --- /dev/null +++ b/tests/test_dash_headers.py @@ -0,0 +1,54 @@ +"""#12: the dashboard token rides in the URL, so headers matter. + +Two findings, both about /api/status, which returns live car facts, the Pi's +process list and agent journal lines: + +1. It carried `Access-Control-Allow-Origin: *`. On home wifi the dashboard + authorises by NETWORK (_peer_is_owner), not by token - ambient authority. + Ambient authority plus wildcard CORS means any web page open on a phone on + that wifi could fetch this endpoint and read the reply cross-origin. +2. Nothing set Referrer-Policy, so a `?t=` URL leaked the credential in + the Referer of every outbound link. +""" +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "carwatch", "webchat.py") + + +class DashboardHeaders(unittest.TestCase): + + def setUp(self): + with open(SRC) as f: + self.src = f.read() + + def test_no_wildcard_cors_anywhere(self): + # A comment naming the header is fine; actually sending it is not. + sent = re.findall(r'send_header\(\s*["\']Access-Control-Allow-Origin["\']', + self.src) + self.assertEqual(sent, [], "the dashboard is same-origin and must not " + "advertise CORS on token-gated car data") + + def test_referrer_policy_is_sent(self): + self.assertIn('send_header("Referrer-Policy", "no-referrer")', self.src, + "the token travels as ?t=, so it would leak in Referer") + + def test_status_endpoint_still_returns_the_dashboard_payload(self): + # Removing a header must not have disturbed the response body. + for key in ('"device"', '"listening"', '"facts"', '"top"', '"journal"'): + self.assertIn(key, self.src) + + def test_ambient_auth_still_exists_so_the_cors_fix_matters(self): + # If this ever stops being true the finding changes shape, so assert + # the premise rather than leaving it in a comment. + self.assertIn("_pi_on_home_wifi()", self.src) + self.assertIn("def _peer_is_owner", self.src) + + +if __name__ == "__main__": + unittest.main()