mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,30 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## 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://<pi>: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
|
### Fixes
|
||||||
|
|
||||||
- On-demand no longer restarts a running display. `POST
|
- On-demand no longer restarts a running display. `POST
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ are intentional rather than vulnerabilities:
|
|||||||
|
|
||||||
- **No web UI authentication.** The web interface assumes the network
|
- **No web UI authentication.** The web interface assumes the network
|
||||||
it's running on is trusted. Don't expose port 5000 to the internet.
|
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
|
- **Plugins run unsandboxed.** Installed plugins execute in the same
|
||||||
Python process as the display loop with full file-system and
|
Python process as the display loop with full file-system and
|
||||||
network access. Review plugin code (especially third-party plugins
|
network access. Review plugin code (especially third-party plugins
|
||||||
|
|||||||
@@ -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`),
|
auth endpoints, upload endpoints, `system/git-info`, `system/check-update`),
|
||||||
the entry below says so.
|
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
|
## Table of Contents
|
||||||
|
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
@@ -1388,7 +1399,10 @@ Fetches `origin` and lists branches to switch to: `current`, `upstream`,
|
|||||||
|
|
||||||
**POST** `/api/v3/system/action`
|
**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**:
|
**Request Body**:
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -412,6 +412,16 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
|||||||
- No authentication is currently implemented
|
- No authentication is currently implemented
|
||||||
- Recommended for trusted networks only
|
- 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:**
|
**Best Practices:**
|
||||||
1. Run on a private network (not exposed to internet)
|
1. Run on a private network (not exposed to internet)
|
||||||
2. Use a firewall to restrict access if needed
|
2. Use a firewall to restrict access if needed
|
||||||
|
|||||||
@@ -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://<pi>: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
|
||||||
+16
-4
@@ -55,10 +55,17 @@ app = Flask(__name__)
|
|||||||
app.secret_key = os.urandom(24)
|
app.secret_key = os.urandom(24)
|
||||||
config_manager = ConfigManager()
|
config_manager = ConfigManager()
|
||||||
|
|
||||||
# No CSRF protection: the UI is meant for the local network, where anyone who
|
# Cross-site request forgery: the UI has no login, and being "only on the LAN"
|
||||||
# can forge a request can also send it directly, and neither the HTMX forms
|
# does not keep other websites out. Any page a LAN user opens can make their
|
||||||
# nor the fetch() calls carry a token. Exposing the UI beyond the LAN needs
|
# browser POST to this server -- a plain HTML form is not blocked by CORS -- so
|
||||||
# CSRF tokens added to both first.
|
# 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)
|
# Initialize rate limiting (prevent accidental abuse, not security)
|
||||||
try:
|
try:
|
||||||
@@ -402,6 +409,11 @@ def success_txt():
|
|||||||
from web_interface import request_logging
|
from web_interface import request_logging
|
||||||
request_logging.init_app(app)
|
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
|
# Global error handlers
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found_error(error):
|
def not_found_error(error):
|
||||||
|
|||||||
@@ -383,16 +383,27 @@ def _perform_core_update_locked(stash_local_changes=True):
|
|||||||
def execute_system_action():
|
def execute_system_action():
|
||||||
"""Execute system actions (start/stop/reboot/etc)"""
|
"""Execute system actions (start/stop/reboot/etc)"""
|
||||||
try:
|
try:
|
||||||
# HTMX sends data as form data, not JSON
|
data = request.get_json(silent=True)
|
||||||
data = request.get_json(silent=True) or {}
|
if data is None and not request.is_json:
|
||||||
if not data:
|
# Every caller in the interface sends JSON (the Quick Actions
|
||||||
# Try to get from form data if JSON fails
|
# 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 = {
|
data = {
|
||||||
'action': request.form.get('action'),
|
'action': request.form.get('action'),
|
||||||
'mode': request.form.get('mode')
|
'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
|
return jsonify({'status': 'error', 'message': 'Action required'}), 400
|
||||||
|
|
||||||
action = data['action']
|
action = data['action']
|
||||||
|
|||||||
@@ -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://<pi>: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 '<unreadable>'
|
||||||
|
if not parts.scheme or not netloc:
|
||||||
|
return '<unreadable>'
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user