Files
meshcore-bot/modules/config_snapshot.py
T
agessaman a5f7f711dd fix(config): close config-injection and credential-exposure holes
Four defects in how configuration is written and displayed. All are
reachable without authentication when web_viewer_password is unset,
which is an explicitly supported setup.

- ini_writer: reject sections, keys and values that cannot survive the
  round-trip. update_ini_values() wrote free text straight into the
  file, so a newline in any value ended the key's line and everything
  after it was re-parsed as INI on the next load. Saving a greeting of
  "Welcome!\n[Injected]\npwned = true" through the plugin settings
  endpoint created a real new section; a repeated section name bricked
  startup with DuplicateSectionError. The check lives at the writer
  because three separate callers reach it, and raises IniValueError so a
  bad payload is a 400 instead of a half-written file. DEFAULT is
  refused as well: its keys apply to every section, and
  ConfigParser.add_section('DEFAULT') raises.

- settings_store, web_viewer: persist to disk before mirroring into the
  in-memory config. The mirror ran first, so a value rejected by the
  writer stayed live in the running process despite never reaching
  disk. Both routes taking free-form input (the zombie and offline
  alert emails) had the same ordering. The zombie route also caught
  only OSError, so an IniValueError there escaped as a 500 rather than
  the intended 400.

- config_snapshot: redact Discord webhook URLs. Neither
  discord_webhook_urls nor the [DiscordBridge] bridge.<channel> keys
  matched any redaction rule, so --show-config and /admin/config
  printed live webhook secrets in full; anyone holding one can post to
  the channel. Telegram's api_token was already redacted. The
  bridge. prefix needs its own rule because the varying part is the
  channel name, leaving no fixed stem for the substring match.

- mqtt_weather, packet_capture: verify TLS certificates by default.
  Both called tls_set(cert_reqs=ssl.CERT_NONE) unconditionally, so the
  broker username and password sent immediately afterwards were
  readable by anyone able to intercept the connection.

BREAKING: brokers presenting self-signed certificates now fail to
connect until tls_insecure (mqtt_weather) or mqttN_tls_insecure
(packet_capture) is set to true. Both log a warning while enabled.
2026-07-28 20:07:58 -07:00

56 lines
1.9 KiB
Python

"""Helpers for rendering resolved config snapshots with redaction."""
from __future__ import annotations
from configparser import ConfigParser
_REDACT_KEY_PARTS: tuple[str, ...] = (
"password",
"smtp_password",
"api_key",
"token",
"secret",
"smtp_user",
# A Discord webhook URL is a bearer credential: anyone holding it can post
# to the channel. Covers ``discord_webhook_urls``.
"webhook",
)
# Keys whose *prefix* marks a secret. [DiscordBridge] maps channels as
# ``bridge.<meshcore_channel> = <discord webhook url>``, so the varying part is
# the channel name and there is no fixed stem for the substring rule to catch.
_REDACT_KEY_PREFIXES: tuple[str, ...] = (
"bridge.",
)
def is_sensitive_key(key: str) -> bool:
"""Return True when a config key should be redacted."""
key_lower = key.lower()
if any(key_lower.startswith(prefix) for prefix in _REDACT_KEY_PREFIXES):
return True
return any(part in key_lower for part in _REDACT_KEY_PARTS)
def config_to_redacted_sections(config: ConfigParser) -> dict[str, dict[str, str]]:
"""Return config sections as key/value maps with sensitive keys redacted."""
sections: dict[str, dict[str, str]] = {}
for section in config.sections():
sections[section] = {
key: "●●●●●●" if is_sensitive_key(key) else value
for key, value in config.items(section, raw=True)
}
return sections
def redacted_sections_to_ini_text(sections: dict[str, dict[str, str]]) -> str:
"""Render redacted config sections as human-readable INI text."""
lines: list[str] = []
for idx, (section_name, options) in enumerate(sections.items()):
if idx > 0:
lines.append("")
lines.append(f"[{section_name}]")
for key, value in options.items():
lines.append(f"{key} = {value}")
return "\n".join(lines)