mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(web-ui): let the MQTT bridge form save a password without TLS
PUT /api/v3/integrations/mqtt-bridge/config refuses a stored password
while mqtt_tls is off unless allow_insecure_mqtt is set (the CWE-319
guard in api_v3/misc.py). The Tools tab form neither rendered a control
for that flag nor sent it, so a password-protected broker on a LAN
without TLS could never be saved from the UI, and once such a password
was in bridge_config.json every later save from the form was refused.
The form now shows "Allow without TLS (trusted network)" while "Use
TLS" is unchecked, prefilled from the GET's config.allow_insecure_mqtt,
and mqttBody() sends its state as allow_insecure_mqtt. The box is off
until the user ticks it, so the server's guard still refuses a
cleartext password by default.
Tests: the Tools DOM suite checks the control, its show/hide with the
TLS box, the prefill and the value saved; a Flask test pins that the
GET reports the opt-in (false until saved on).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-ui): stop the Overview reconciliation poll from running forever
The reconciliation banner script in partials/overview.html re-asked
/api/v3/plugins/reconciliation-status every 2 s until the answer said
done, with no limit. The route answers done: false whenever
ledmatrix_reconciliation.json is missing or unreadable, which happens
when _run_startup_reconciliation raises before writing it or when /tmp
is cleaned under a long-running web service (reconciliation runs once
per process). The browser then sent that request every 2 s for as long
as the page stayed open, on every tab, since the poll was never tied to
the Overview being visible.
The poll now gives up after 30 tries (a minute) and runs only while the
Overview is the active, visible tab, registered with LEDVisibility under
its own key like the other partials' pollers. Dismissing the banner
ends it too.
Test: test/js/unit/test_overview_reconciliation_poll.js runs the shipped
script in a vm with fake timers and fetch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-ui): drop the Display tab's lookup of a removed brightness label
The brightness slider's input handler in partials/display.html set the
text of both #brightness-value and #brightness-display. #387
(978a03b42) removed the "LED brightness: N%" line that carried
#brightness-display, so getElementById returned null and every step of
the slider threw "Cannot set properties of null" into the console. The
visible label still updated, because it is written first.
The dead lookup is removed.
Test: test/js/unit/test_display_partial_ids.js checks every literal
getElementById() in the partial's inline scripts against the ids its
markup renders, and runs the shipped script in a vm to move the slider.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-ui): a created API token leaves the General tab's form clean
app.js marks a form data-dirty on any input inside it and removes the
mark only after a successful htmx request; its beforeunload handler
asks "Leave site?" while a visible form is still dirty. The API token
form in partials/general.html posts through window.webLogin.createToken
with fetch, so the mark survived the token being created and a reload
of the page with the General tab open prompted about a change that had
already been saved.
createToken now removes data-dirty after a successful create, next to
the form.reset() it already did. A refused request keeps the mark.
Test: test/js/unit/test_general_web_login_token.js runs the shipped
script in a vm with a fake fetch and DOM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(js): match <script> tags the way CodeQL's tag-filter rule expects
The three new suites pull the inline scripts out of their partials with
/<script>([\s\S]*?)<\/script>/g. CodeQL flags that shape as a bad HTML
filtering regexp (js/bad-tag-filter: misses upper case and tags with
attributes or whitespace), four high alerts that blocked the PR. These are
our own templates read by tests, not user input, but the stricter pattern
costs nothing: /<script\b[^>]*>(...)<\/script[^>]*>/gi, as
test_html_escaping.js already uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(js): slice the Display partial's markup around its scripts
CodeQL read the script-stripping replace() as an incomplete HTML sanitizer
(js/incomplete-multi-character-sanitization). The test only reads our own
template, but slicing between the matched blocks gives the same markup
without the pattern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
6.5 KiB
Python
158 lines
6.5 KiB
Python
"""The MQTT bridge settings endpoint must reject bodies it cannot apply.
|
|
|
|
Two defects CodeRabbit raised on #554, both of which reported success while
|
|
doing something other than what the caller asked:
|
|
|
|
* `request.get_json(silent=True) or {}` turned a missing or unparseable body --
|
|
and the JSON literals `null`, `[]` and `false` -- into an empty dict, which
|
|
then satisfied the `isinstance(data, dict)` guard directly below it. Malformed
|
|
JSON therefore returned 200 having applied nothing.
|
|
|
|
* `if data.get('clear_password'):` accepted any truthy value. The string
|
|
"false" is truthy in Python, so a client echoing the field back as a string
|
|
wiped a stored password it meant to keep.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
|
import web_interface.blueprints.api_v3 as pkg # noqa: E402
|
|
import web_interface.blueprints.api_v3.misc as misc # noqa: E402
|
|
|
|
URL = "/api/v3/integrations/mqtt-bridge/config"
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
cfg = tmp_path / "bridge_config.json"
|
|
# Both modules: misc.py writes through its own imported copies of these
|
|
# names, while _read_mqtt_bridge_config() lives in the package __init__ and
|
|
# reads the one bound there. Patching only one leaves the read and the
|
|
# write pointing at different files.
|
|
for mod in (misc, pkg):
|
|
monkeypatch.setattr(mod, "_MQTT_BRIDGE_CONFIG", cfg, raising=False)
|
|
monkeypatch.setattr(mod, "_MQTT_BRIDGE_DIR", tmp_path, raising=False)
|
|
monkeypatch.setattr(api_v3, "config_manager", MagicMock(), raising=False)
|
|
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
|
app.cfg_path = cfg
|
|
return app.test_client(), cfg
|
|
|
|
|
|
class TestABodyItCannotApplyIsRejected:
|
|
@pytest.mark.parametrize("raw", ["{ not json", "null", "[]", "false", '"a string"'])
|
|
def test_unusable_bodies_are_rejected(self, client, raw):
|
|
c, _ = client
|
|
r = c.put(URL, data=raw, content_type="application/json")
|
|
# The regression: every one of these returned 200 having applied nothing.
|
|
assert r.status_code == 400, f"{raw!r} was accepted"
|
|
assert "JSON object" in r.get_json()["message"]
|
|
|
|
def test_a_missing_body_is_rejected(self, client):
|
|
c, _ = client
|
|
assert c.put(URL).status_code == 400
|
|
|
|
def test_a_real_object_is_still_accepted(self, client):
|
|
c, _ = client
|
|
r = c.put(URL, json={"mqtt_host": "192.168.1.20"})
|
|
assert r.status_code == 200, r.get_json()
|
|
|
|
|
|
class TestClearPasswordNeedsARealBoolean:
|
|
def _seed(self, cfg, password="hunter2"):
|
|
# TLS on so these cases exercise clear_password alone: a stored password
|
|
# with TLS off is refused by the CWE-319 guard, which is a separate test.
|
|
cfg.write_text(json.dumps({"mqtt_password": password, "mqtt_tls": True}),
|
|
encoding="utf-8")
|
|
|
|
def _stored(self, cfg):
|
|
return json.loads(cfg.read_text(encoding="utf-8")).get("mqtt_password")
|
|
|
|
def test_the_string_false_does_not_clear_it(self, client):
|
|
c, cfg = client
|
|
self._seed(cfg)
|
|
assert c.put(URL, json={"clear_password": "false"}).status_code == 200
|
|
# The regression: "false" is truthy, so this wiped the password.
|
|
assert self._stored(cfg) == "hunter2"
|
|
|
|
@pytest.mark.parametrize("falsy", [False, "no", "0", "", None])
|
|
def test_other_falsy_spellings_do_not_clear_it(self, client, falsy):
|
|
c, cfg = client
|
|
self._seed(cfg)
|
|
assert c.put(URL, json={"clear_password": falsy}).status_code == 200
|
|
assert self._stored(cfg) == "hunter2"
|
|
|
|
@pytest.mark.parametrize("truthy", [True, "true", "1", "yes", "on"])
|
|
def test_real_truthy_values_still_clear_it(self, client, truthy):
|
|
c, cfg = client
|
|
self._seed(cfg)
|
|
assert c.put(URL, json={"clear_password": truthy}).status_code == 200
|
|
assert self._stored(cfg) is None
|
|
|
|
|
|
class TestCleartextCredentialsNeedAnExplicitOptIn:
|
|
"""CWE-319. A password with TLS off crosses the network in the clear.
|
|
|
|
Refused rather than forbidden: unencrypted MQTT on a trusted LAN is a normal
|
|
deliberate setup, so allow_insecure_mqtt is the explicit acknowledgement.
|
|
"""
|
|
|
|
def test_a_password_without_tls_is_refused(self, client):
|
|
c, _ = client
|
|
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False})
|
|
assert r.status_code == 400
|
|
assert "allow_insecure_mqtt" in r.get_json()["message"]
|
|
|
|
def test_the_opt_in_allows_it(self, client):
|
|
c, cfg = client
|
|
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
|
|
"allow_insecure_mqtt": True})
|
|
assert r.status_code == 200, r.get_json()
|
|
assert json.loads(cfg.read_text(encoding="utf-8"))["mqtt_password"] == "hunter2"
|
|
|
|
def test_tls_on_needs_no_opt_in(self, client):
|
|
c, _ = client
|
|
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": True})
|
|
assert r.status_code == 200, r.get_json()
|
|
|
|
def test_no_password_is_unaffected(self, client):
|
|
c, _ = client
|
|
assert c.put(URL, json={"mqtt_tls": False}).status_code == 200
|
|
|
|
def test_the_opt_in_is_a_real_boolean(self, client):
|
|
""""false" must not switch the guard off, same as clear_password."""
|
|
c, _ = client
|
|
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
|
|
"allow_insecure_mqtt": "false"})
|
|
assert r.status_code == 400
|
|
|
|
def test_the_settings_read_reports_the_opt_in(self, client, monkeypatch):
|
|
"""The Tools form prefills its "Allow without TLS" box from the GET.
|
|
|
|
Off until someone saves it on, so an untouched form sends false and
|
|
the guard above still refuses a cleartext password.
|
|
"""
|
|
c, _ = client
|
|
monkeypatch.setattr(misc, "_mqtt_bridge_service_state",
|
|
lambda: {"installed": False, "active": False, "enabled": False})
|
|
|
|
def read():
|
|
return c.get("/api/v3/integrations/mqtt-bridge").get_json()["data"]["config"]
|
|
|
|
assert read()["allow_insecure_mqtt"] is False
|
|
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
|
|
"allow_insecure_mqtt": True})
|
|
assert r.status_code == 200, r.get_json()
|
|
assert read()["allow_insecure_mqtt"] is True
|
|
|