From ba38a83c2ca06afa5a01421a6cdb73d429e7cb11 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:02:01 -0400 Subject: [PATCH] fix(web): refuse cross-site state-changing requests (Origin/Referer check) (#674) The web interface refuses state-changing requests (POST/PUT/PATCH/DELETE) whose Origin (or, without one, Referer) is not the host they were sent to, or is null: 403 CROSS_SITE_REQUEST (web_interface/origin_guard.py). Any website a LAN user visited could otherwise make their browser POST a plain form to the Pi. /api/v3/system/action also refuses form-encoded and text/plain bodies (415) unless sent by HTMX. Clients that send no Origin or Referer (curl, requests, Home Assistant, the MQTT bridge) are unaffected. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 24 ++ SECURITY.md | 8 + docs/REST_API_REFERENCE.md | 16 +- docs/WEB_INTERFACE_GUIDE.md | 10 + test/test_web_origin_guard.py | 285 ++++++++++++++++++++++ web_interface/app.py | 20 +- web_interface/blueprints/api_v3/system.py | 21 +- web_interface/origin_guard.py | 191 +++++++++++++++ 8 files changed, 565 insertions(+), 10 deletions(-) create mode 100644 test/test_web_origin_guard.py create mode 100644 web_interface/origin_guard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd21b0b9..c045e049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,30 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Security + +- The web interface refuses state-changing requests (`POST`, `PUT`, `PATCH`, + `DELETE`) sent by another website's page. Any site a LAN user visited could + make their browser submit a plain HTML form to `http://:5000` -- CORS + does not stop such a request, only hides its answer -- and + `/api/v3/system/action` accepted form bodies, so that page could reboot or + power off the Pi, pull code, or reach any other mutating route. A request + whose `Origin` (or, without one, `Referer`) is not the host it was sent to, + or is `null`, now gets 403 `CROSS_SITE_REQUEST` + (`web_interface/origin_guard.py`). `/api/v3/system/action` also refuses a + form-encoded or `text/plain` body (415) unless it carries HTMX's + `HX-Request` header; every caller in the interface already sends JSON. +- **Behaviour change for API scripts:** clients that send no `Origin` or + `Referer` -- curl, Python `requests`, Home Assistant, the MQTT bridge -- + are unaffected. A browser page served from a *different* origin (a + dashboard or userscript on another host) can no longer call the mutating + API; call it server-side instead. Anyone posting a form body to + `system/action` must switch to JSON. Behind a reverse proxy, forward the + original `Host`, port included (`proxy_set_header Host $http_host;`; + nginx's `$host` drops the port); `X-Forwarded-Host` is not trusted. A + TLS-terminating proxy needs nothing more: a portless `Host` matches an + `https://` page. + ### Fixes - On-demand no longer restarts a running display. `POST diff --git a/SECURITY.md b/SECURITY.md index 96b8e88c..69def687 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -63,6 +63,14 @@ are intentional rather than vulnerabilities: - **No web UI authentication.** The web interface assumes the network it's running on is trusted. Don't expose port 5000 to the internet. + "Trusted network" does not mean "trusted websites", though: any page + a LAN user opens could make their browser POST to the Pi. So the + interface refuses a `POST`/`PUT`/`PATCH`/`DELETE` whose `Origin` (or + `Referer`) header names another site (`web_interface/origin_guard.py`), + and `/api/v3/system/action` only accepts JSON or HTMX requests. Tools + that send neither header (curl, Home Assistant, the MQTT bridge) are + unaffected. Not covered: DNS rebinding, and anyone who can reach the + port directly. - **Plugins run unsandboxed.** Installed plugins execute in the same Python process as the display loop with full file-system and network access. Review plugin code (especially third-party plugins diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 34468c93..6f08fb65 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -18,6 +18,17 @@ top level instead of under `data` (install-from-url, registry-from-url, the auth endpoints, upload endpoints, `system/git-info`, `system/check-update`), the entry below says so. +**Cross-site requests are refused.** A `POST`, `PUT`, `PATCH` or `DELETE` +carrying an `Origin` header (or, without one, a `Referer`) that is not the +host the request was sent to gets `403` with `"error_code": +"CROSS_SITE_REQUEST"`; so does `Origin: null`. This stops other websites from +driving the Pi through a LAN user's browser. Scripts, curl, Home Assistant and +the MQTT bridge send neither header and are unaffected. A browser page on +another origin (a dashboard you host elsewhere, say) can no longer call the +API; call it server-side instead. Behind a reverse proxy, pass the original +`Host` through, port included (nginx: `proxy_set_header Host $http_host;`; +`$host` drops the port) -- `X-Forwarded-Host` is not read. + ## Table of Contents - [Configuration](#configuration) @@ -1388,7 +1399,10 @@ Fetches `origin` and lists branches to switch to: `current`, `upstream`, **POST** `/api/v3/system/action` -Execute system-level actions. JSON or form data. +Execute system-level actions. Send JSON (`Content-Type: application/json`). +A form-encoded or `text/plain` body is accepted only with an `HX-Request` +header (HTMX sends it; a cross-site HTML form cannot) and is otherwise +refused with `415`. **Request Body**: ```json diff --git a/docs/WEB_INTERFACE_GUIDE.md b/docs/WEB_INTERFACE_GUIDE.md index 1d354bf5..f42cc432 100644 --- a/docs/WEB_INTERFACE_GUIDE.md +++ b/docs/WEB_INTERFACE_GUIDE.md @@ -412,6 +412,16 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at - No authentication is currently implemented - Recommended for trusted networks only +**Other websites:** +- A web page you open elsewhere could otherwise make your browser send + commands to the Pi (reboot, update, config changes). The interface refuses + any change request whose `Origin`/`Referer` header names a different site + (403 `CROSS_SITE_REQUEST`), so use the interface from its own address. +- Scripts, curl, Home Assistant and the MQTT bridge send no such header and + keep working. Behind a reverse proxy, forward the original `Host` header + with its port (nginx: `proxy_set_header Host $http_host;` -- `$host` + drops the port). + **Best Practices:** 1. Run on a private network (not exposed to internet) 2. Use a firewall to restrict access if needed diff --git a/test/test_web_origin_guard.py b/test/test_web_origin_guard.py new file mode 100644 index 00000000..22244c27 --- /dev/null +++ b/test/test_web_origin_guard.py @@ -0,0 +1,285 @@ +"""State-changing requests from another website's page are refused. + +The interface has no login and was defended only by "it is on the LAN". But +any site a LAN user opens can make their browser POST to http://:5000: a +plain HTML form is a CORS "simple" request, so it arrives and runs even though +the attacking page never sees the answer. /api/v3/system/action accepted +form-encoded bodies and reboots, powers off and pulls code. + +web_interface/origin_guard.py refuses POST/PUT/PATCH/DELETE whose Origin (or +Referer) is not this server's own host, and /system/action only takes a +form-encoded body from HTMX (a cross-site form cannot set HX-Request). +Requests with neither Origin nor Referer are not from a browser -- curl, Home +Assistant, the MQTT bridge -- and still pass. +""" + +import subprocess +from unittest.mock import patch + +import pytest +from flask import Flask, jsonify + +from test._api_v3_test_helpers import ( # noqa: F401 - fixture + api_v3_module, build_app, +) +from web_interface import origin_guard + +# Flask's test client addresses requests to Host: localhost. +SELF = 'http://localhost' +EVIL = 'http://evil.example' + + +# --- The guard itself, on a throwaway app --------------------------------- + +@pytest.fixture +def probe(): + app = Flask(__name__) + app.config['TESTING'] = True + origin_guard.init_app(app) + + @app.route('/change', methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) + def change(): + return jsonify({'status': 'success'}) + + return app.test_client() + + +@pytest.mark.parametrize('method', ['post', 'put', 'patch', 'delete']) +def test_a_cross_site_origin_is_refused_for_every_changing_method(probe, method): + resp = getattr(probe, method)('/change', headers={'Origin': EVIL}) + assert resp.status_code == 403 + body = resp.get_json() + assert body['status'] == 'error' + assert body['error_code'] == 'CROSS_SITE_REQUEST' + assert body['details'].startswith('Origin ') + # The attacker-chosen origin is logged, never echoed back in the body. + assert 'evil.example' not in resp.get_data(as_text=True) + + +@pytest.mark.parametrize('origin', [ + SELF, + 'http://LOCALHOST', # host case is not significant + 'http://localhost:80', # explicit default port + 'https://localhost:80', # TLS proxy passing Host through: scheme ignored +]) +def test_the_interfaces_own_origin_passes(probe, origin): + assert probe.post('/change', headers={'Origin': origin}).status_code == 200 + + +def test_the_host_the_browser_used_is_what_counts(probe): + # Whatever name or address the user typed -- mDNS name, LAN IP, or the + # access-point address the captive portal answers on. + for host in ('ledpi.local:5000', '192.168.1.40:5000', '192.168.4.1', + '[fe80::1]:5000'): + resp = probe.post('/change', headers={ + 'Host': host, 'Origin': f'http://{host}'}) + assert resp.status_code == 200, host + + +def test_captive_portal_via_port_80_redirect_passes(probe): + # iptables REDIRECT 80 -> 5000 keeps the Host the browser sent, which + # carries no port; the page's Origin carries none either. + resp = probe.post('/change', headers={ + 'Host': '192.168.4.1', 'Origin': 'http://192.168.4.1'}) + assert resp.status_code == 200 + + +def test_the_same_host_on_another_port_is_another_site(probe): + resp = probe.post('/change', headers={ + 'Host': 'ledpi.local:5000', 'Origin': 'http://ledpi.local:8080'}) + assert resp.status_code == 403 + + +def test_an_https_page_behind_a_tls_terminating_proxy_passes(probe): + # nginx terminates TLS and forwards a portless Host to the plain-http + # upstream: the browser's Origin is https (443), Flask sees http (80). + resp = probe.post('/change', headers={ + 'Host': 'pi.example', 'Origin': 'https://pi.example'}) + assert resp.status_code == 200 + resp = probe.post('/change', headers={ + 'Host': 'pi.example', 'Referer': 'https://pi.example/v3'}) + assert resp.status_code == 200 + + +def test_a_portless_host_still_refuses_a_nondefault_port(probe): + # Only the standard port of either scheme counts as "no port". + for origin in ('https://pi.example:8443', 'http://pi.example:5000', + 'http://pi.example:443', 'https://evil.example'): + resp = probe.post('/change', headers={ + 'Host': 'pi.example', 'Origin': origin}) + assert resp.status_code == 403, origin + + +def test_an_explicit_host_port_must_match_exactly(probe): + # A Host with a port (the proxy forwards $http_host) is compared as is. + assert probe.post('/change', headers={ + 'Host': 'pi.example:8443', + 'Origin': 'https://pi.example:8443'}).status_code == 200 + assert probe.post('/change', headers={ + 'Host': 'pi.example:8443', + 'Origin': 'https://pi.example'}).status_code == 403 + + +def test_a_refusal_logs_only_the_site_never_the_referer_path(probe, caplog): + # A Referer's path and query can carry tokens. + with caplog.at_level('WARNING', logger='web_interface.origin_guard'): + resp = probe.post('/change', headers={ + 'Referer': EVIL + '/page?token=s3cret#frag'}) + assert resp.status_code == 403 + logged = caplog.text + assert 'evil.example' in logged + assert 's3cret' not in logged + assert '/page' not in logged + + +def test_a_refusal_log_cannot_be_forged_with_newlines(probe, caplog): + with caplog.at_level('WARNING', logger='web_interface.origin_guard'): + probe.post('/change%0D%0AFAKE', headers={'Origin': EVIL}) + assert len(caplog.records) == 1 + message = caplog.records[0].getMessage() + assert '\n' not in message and '\r' not in message + assert 'FAKE' in message # the path was logged, escaped + + +def test_no_origin_and_no_referer_passes(probe): + # curl, Home Assistant, the MQTT bridge: not a browser. + assert probe.post('/change').status_code == 200 + assert probe.post('/change', json={'action': 'x'}).status_code == 200 + + +def test_a_null_origin_is_refused(probe): + # Sandboxed iframes and file:// pages send "Origin: null". + resp = probe.post('/change', headers={'Origin': 'null'}) + assert resp.status_code == 403 + assert 'null' in resp.get_json()['details'] + + +def test_the_referer_is_checked_when_origin_is_absent(probe): + assert probe.post('/change', headers={ + 'Referer': EVIL + '/attack.html'}).status_code == 403 + assert probe.post('/change', headers={ + 'Referer': SELF + '/v3'}).status_code == 200 + + +def test_origin_wins_over_referer(probe): + resp = probe.post('/change', headers={ + 'Origin': EVIL, 'Referer': SELF + '/'}) + assert resp.status_code == 403 + + +@pytest.mark.parametrize('value', [ + 'not a url', 'ftp://localhost', 'http://', 'http://localhost:notaport', +]) +def test_an_unreadable_origin_is_refused(probe, value): + assert probe.post('/change', headers={'Origin': value}).status_code == 403 + + +def test_gets_are_never_checked(probe): + assert probe.get('/change', headers={'Origin': EVIL}).status_code == 200 + assert probe.get('/change', headers={'Origin': 'null'}).status_code == 200 + + +# --- /api/v3/system/action ------------------------------------------------- + +@pytest.fixture +def api_client(api_v3_module): # noqa: F811 - pytest fixture injection + app = build_app(api_v3_module.api_v3) + origin_guard.init_app(app) + return app.test_client() + + +def _ok(args, **kwargs): + return subprocess.CompletedProcess(args, 0, stdout='', stderr='') + + +def test_a_cross_site_form_post_never_reaches_the_reboot(api_client): + with patch('subprocess.run', side_effect=_ok) as run: + resp = api_client.post('/api/v3/system/action', + data={'action': 'reboot_system'}, + headers={'Origin': EVIL}) + assert resp.status_code == 403 + run.assert_not_called() + + +def test_a_form_post_without_hx_request_is_refused_even_without_origin(api_client): + # Belt and braces: a browser whose Origin/Referer never arrived (a + # privacy proxy stripping both) still cannot send the form. + with patch('subprocess.run', side_effect=_ok) as run: + resp = api_client.post('/api/v3/system/action', + data={'action': 'reboot_system'}) + assert resp.status_code == 415 + assert 'JSON' in resp.get_json()['message'] + run.assert_not_called() + + +def test_a_text_plain_body_is_refused(api_client): + # enctype="text/plain" is the other cross-site form encoding. + with patch('subprocess.run', side_effect=_ok) as run: + resp = api_client.post('/api/v3/system/action', + data='{"action": "reboot_system"}', + content_type='text/plain') + assert resp.status_code == 415 + run.assert_not_called() + + +def test_an_htmx_form_post_from_the_interface_runs(api_client): + with patch('subprocess.run', side_effect=_ok) as run: + resp = api_client.post('/api/v3/system/action', + data={'action': 'stop_display'}, + headers={'Origin': SELF, 'HX-Request': 'true'}) + assert resp.status_code == 200 + assert resp.get_json()['status'] == 'success' + assert run.call_args[0][0] == ['sudo', 'systemctl', 'stop', 'ledmatrix.service'] + + +def test_a_same_origin_json_post_runs(api_client): + # What every button and fetch() in the interface sends. + with patch('subprocess.run', side_effect=_ok): + resp = api_client.post('/api/v3/system/action', + json={'action': 'stop_display'}, + headers={'Origin': SELF}) + assert resp.status_code == 200 + assert resp.get_json()['status'] == 'success' + + +def test_a_json_post_with_no_origin_runs(api_client): + # The MQTT bridge, Home Assistant, curl. + with patch('subprocess.run', side_effect=_ok): + resp = api_client.post('/api/v3/system/action', + json={'action': 'stop_display'}) + assert resp.status_code == 200 + + +def test_an_empty_json_body_still_asks_for_an_action(api_client): + resp = api_client.post('/api/v3/system/action', json={}) + assert resp.status_code == 400 + assert resp.get_json()['message'] == 'Action required' + + +def test_a_json_body_that_is_not_an_object_asks_for_an_action(api_client): + resp = api_client.post('/api/v3/system/action', json=['reboot_system']) + assert resp.status_code == 400 + + +# --- The real app ---------------------------------------------------------- + +def test_the_real_app_has_the_guard(): + import web_interface.app as web_app + web_app.app.config['TESTING'] = True + with patch('subprocess.run', side_effect=_ok) as run, \ + web_app.app.test_client() as c: + resp = c.post('/api/v3/system/action', + json={'action': 'reboot_system'}, + headers={'Origin': EVIL}) + assert resp.status_code == 403 + assert resp.get_json()['error_code'] == 'CROSS_SITE_REQUEST' + run.assert_not_called() + + +def test_the_real_app_leaves_gets_alone(): + import web_interface.app as web_app + web_app.app.config['TESTING'] = True + with web_app.app.test_client() as c: + resp = c.get('/api/v3/no-such-endpoint-for-origin-test', + headers={'Origin': EVIL}) + assert resp.status_code == 404 diff --git a/web_interface/app.py b/web_interface/app.py index 14037180..456aa804 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -55,10 +55,17 @@ app = Flask(__name__) app.secret_key = os.urandom(24) config_manager = ConfigManager() -# No CSRF protection: the UI is meant for the local network, where anyone who -# can forge a request can also send it directly, and neither the HTMX forms -# nor the fetch() calls carry a token. Exposing the UI beyond the LAN needs -# CSRF tokens added to both first. +# Cross-site request forgery: the UI has no login, and being "only on the LAN" +# does not keep other websites out. Any page a LAN user opens can make their +# browser POST to this server -- a plain HTML form is not blocked by CORS -- so +# a hostile site could reboot the Pi, pull code or rewrite the config through +# the user's browser. web_interface/origin_guard.py (registered below) refuses +# POST/PUT/PATCH/DELETE whose Origin (or, failing that, Referer) is not this +# server's own host; requests with neither header (curl, Home Assistant, the +# MQTT bridge) are not from a browser and pass. There are no CSRF tokens: +# neither the HTMX forms nor the fetch() calls carry one. Anyone who can reach +# the port directly can still use the API, so exposing the UI beyond a trusted +# network still needs real authentication. # Initialize rate limiting (prevent accidental abuse, not security) try: @@ -402,6 +409,11 @@ def success_txt(): from web_interface import request_logging request_logging.init_app(app) +# Refuse state-changing requests sent by another website's page (see the +# cross-site note near the top of this file). +from web_interface import origin_guard +origin_guard.init_app(app) + # Global error handlers @app.errorhandler(404) def not_found_error(error): diff --git a/web_interface/blueprints/api_v3/system.py b/web_interface/blueprints/api_v3/system.py index ab3403a2..b9cd93f1 100644 --- a/web_interface/blueprints/api_v3/system.py +++ b/web_interface/blueprints/api_v3/system.py @@ -383,16 +383,27 @@ def _perform_core_update_locked(stash_local_changes=True): def execute_system_action(): """Execute system actions (start/stop/reboot/etc)""" try: - # HTMX sends data as form data, not JSON - data = request.get_json(silent=True) or {} - if not data: - # Try to get from form data if JSON fails + data = request.get_json(silent=True) + if data is None and not request.is_json: + # Every caller in the interface sends JSON (the Quick Actions + # buttons use HTMX's json-enc). A form-encoded body is what a + # cross-site HTML form can send without a CORS preflight, and + # this route reboots, powers off and pulls code, so it is only + # accepted from HTMX: a cross-site form cannot set HX-Request. + # This backs up the app-wide Origin check (origin_guard.py). + if not request.headers.get('HX-Request'): + return jsonify({ + 'status': 'error', + 'message': ('Send the action as JSON ' + '(Content-Type: application/json), ' + 'e.g. {"action": "restart_display_service"}'), + }), 415 data = { 'action': request.form.get('action'), 'mode': request.form.get('mode') } - if not data or 'action' not in data: + if not isinstance(data, dict) or not data.get('action'): return jsonify({'status': 'error', 'message': 'Action required'}), 400 action = data['action'] diff --git a/web_interface/origin_guard.py b/web_interface/origin_guard.py new file mode 100644 index 00000000..e12f27f5 --- /dev/null +++ b/web_interface/origin_guard.py @@ -0,0 +1,191 @@ +""" +Cross-site request guard for the web interface. + +The threat: the interface has no login, and "it is only on the LAN" does not +keep other websites out of it. Any page a person on the LAN opens in their +browser can make that browser send a request to ``http://:5000``. A plain +HTML form POST (``application/x-www-form-urlencoded``, ``multipart/form-data`` +or ``text/plain``) is a "simple" request: CORS does not preflight it and does +not stop it from arriving, it only hides the response from the page. So a +hostile or compromised site could reboot the Pi, pull code, install or remove +plugins or rewrite the config, without the user ever seeing the interface. + +The defence here needs no tokens and no frontend change. Browsers attach an +``Origin`` header to every cross-site POST (and to same-origin ones in all +current browsers), and it cannot be set or removed by page script. So for any +state-changing method: + +* ``Origin`` present -> it must name this server's own host, else 403. + ``Origin: null`` (a sandboxed iframe, a ``file://`` page, some cross-site + redirect chains) is never this server, so it is refused too. +* ``Origin`` absent, ``Referer`` present -> the same check on the Referer. +* neither -> allowed. That is curl, Home Assistant, the MQTT bridge and every + other script: not a browser, so not a confused deputy. A browser making a + cross-site request always sends ``Origin``. + +"This server's own host" is the ``Host`` header the request arrived with, so +it follows whatever name or address the user typed: ``ledpi.local:5000``, +``192.168.1.40:5000``, or ``192.168.4.1`` in access-point mode (the captive +portal's port 80 -> 5000 redirect keeps the Host the browser sent, and the +setup page's fetches go back to that same host). + +The scheme is deliberately not compared, only host and port. The claimed +value's default port comes from its own scheme. A ``Host`` without a port +means "the default port of whatever scheme the browser used", and that scheme +is not always the one Flask sees: a TLS-terminating reverse proxy makes the +browser say ``https://pi.example`` (443) while Flask sees ``http`` (80). So a +portless ``Host`` accepts either default. An attacker cannot use that gap, +because to match they would need to serve a page from this same host on its +standard port. The app does not use ``ProxyFix`` and so does not trust +``X-Forwarded-Host`` or ``X-Forwarded-Proto``: a proxy that rewrites ``Host`` +to the upstream address (nginx's default ``proxy_pass`` does) must be +configured to pass the original one, port included +(``proxy_set_header Host $http_host;`` -- nginx's ``$host`` drops the port). + +Not covered: DNS rebinding (an attacker's hostname re-pointed at the Pi is +"same origin" to the browser), and anyone who can reach the port directly. +Neither is new; the interface is still meant for a trusted network. +""" +import logging +from urllib.parse import urlsplit, urlunsplit + +from flask import Flask, jsonify, request + +logger = logging.getLogger('web_interface.origin_guard') + +#: Methods that change state and so must come from this interface's own pages. +STATE_CHANGING_METHODS = frozenset({'POST', 'PUT', 'PATCH', 'DELETE'}) + +_DEFAULT_PORTS = {'http': 80, 'https': 443} + + +def _authority(netloc: str): + """``(hostname, port)`` for an authority; port is None when it has none. + + Lower-cases the host and drops a trailing dot, so ``Pi.local.`` and + ``pi.local`` compare equal. None if the authority is unreadable. + """ + try: + parts = urlsplit(f'//{netloc}') + hostname = parts.hostname + port = parts.port + except ValueError: + # A malformed port or bracketed address. + return None + if not hostname: + return None + return hostname.lower().rstrip('.'), port + + +def _url_host_port(url: str): + """``(hostname, port, default_port)`` for an Origin or Referer, or None. + + ``port`` is the explicit port or, failing that, the URL scheme's default, + which is also returned as ``default_port``. + """ + try: + parts = urlsplit(url.strip()) + except ValueError: + return None + scheme = parts.scheme.lower() + if scheme not in _DEFAULT_PORTS or not parts.netloc: + return None + authority = _authority(parts.netloc.rsplit('@', 1)[-1]) + if authority is None: + return None + hostname, port = authority + default_port = _DEFAULT_PORTS[scheme] + return hostname, default_port if port is None else port, default_port + + +def _names_this_server(claimed) -> bool: + """Whether a claimed ``(hostname, port, default_port)`` is this request's + own ``Host``.""" + own = _authority(request.host) + if own is None: + return False + hostname, port = own + claimed_host, claimed_port, claimed_default = claimed + if claimed_host != hostname: + return False + if port is not None: + return claimed_port == port + # A portless Host is the default port of the scheme the browser used. + # Behind a TLS-terminating proxy that is https/443 while Flask sees + # http/80, so accept the default of either scheme. + return claimed_port in (claimed_default, + _DEFAULT_PORTS.get(request.scheme)) + + +def _loggable(value: str) -> str: + """Just the ``scheme://host[:port]`` of an Origin/Referer, for the log. + + A Referer's path and query can carry tokens or other private data, and + only the site matters when reading a refusal. + """ + try: + parts = urlsplit(value.strip()) + netloc = parts.netloc.rsplit('@', 1)[-1] + except ValueError: + return '' + if not parts.scheme or not netloc: + return '' + return urlunsplit((parts.scheme, netloc, '', '', '')) + + +def check_request_origin(): + """None when the request may proceed, else the reason it may not. + + The reason is a short phrase for the log and the error message. + """ + if request.method not in STATE_CHANGING_METHODS: + return None + + origin = request.headers.get('Origin') + if origin is not None: + header, value = 'Origin', origin + else: + referer = request.headers.get('Referer') + if referer is None: + # Not a browser (curl, Home Assistant, the MQTT bridge, scripts). + return None + header, value = 'Referer', referer + + if value.strip().lower() == 'null': + return f'{header} is "null" (sandboxed or file:// page)' + + claimed = _url_host_port(value) + if claimed is None: + return f'{header} header is not a valid http(s) URL' + if not _names_this_server(claimed): + # The claimed value is attacker-chosen: the hook logs it, but the + # reason (echoed in the 403 body) never repeats it. + return header + ' names a different host than this interface' + return None + + +def init_app(app: Flask) -> None: + """Refuse state-changing requests that another website's page sent.""" + + @app.before_request + def _refuse_cross_site_requests(): + reason = check_request_origin() + if reason is None: + return None + # Only the site each header names, never a Referer's path or query + # (which can carry tokens); %r keeps CR/LF from forging log lines. + origin = request.headers.get('Origin') + referer = request.headers.get('Referer') + logger.warning("Refused cross-site %s %r: %s (Origin=%r, Referer=%r)", + request.method, request.path, reason, + None if origin is None else _loggable(origin), + None if referer is None else _loggable(referer)) + return jsonify({ + 'status': 'error', + 'error_code': 'CROSS_SITE_REQUEST', + 'message': ('Refused: this request came from another website, not ' + 'from the LEDMatrix interface. Open the interface ' + 'directly (the address in your browser bar must be the ' + 'same one the request goes to) and try again.'), + 'details': reason, + }), 403