The claimed origin is attacker-chosen, so the refusal reason in the
response names only which header failed; the values are logged instead.
Clears Codacy's directly-returned-format-string finding.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web interface had no CSRF protection, on the reasoning that anyone who
can forge a request on the LAN can also send it directly. That misses the
browser as a confused deputy: any website a LAN user opens can make their
browser POST a plain HTML form to http://<pi>:5000. CORS does not stop that
request, only hides its answer, and /api/v3/system/action accepted form
bodies, so a hostile page could reboot or power off the Pi, pull code, or
reach any other mutating route.
- web_interface/origin_guard.py: an app-wide before_request hook refuses
POST/PUT/PATCH/DELETE whose Origin (or, without one, Referer) is not the
host the request was addressed to, and Origin "null", with 403
CROSS_SITE_REQUEST. Requests with neither header (curl, Home Assistant,
the MQTT bridge) are not from a browser and pass. Host and port are
compared, not the scheme, so a TLS proxy that passes Host through works;
X-Forwarded-Host is not trusted (no ProxyFix).
- /api/v3/system/action refuses a non-JSON body (415) unless HX-Request is
set; every caller in the interface already sends JSON.
- app.py comment states the real threat model; SECURITY.md,
REST_API_REFERENCE.md, WEB_INTERFACE_GUIDE.md and CHANGELOG updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>