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:
Chuck
2026-09-30 09:02:01 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c3a7a110c4
commit ba38a83c2c
8 changed files with 565 additions and 10 deletions
+16 -5
View File
@@ -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']