fix(webconfig): stop the CLI reading secrets, and enforce the setup password
Two findings from review, both real, both mine. The CLI could read secrets the portal has never exposed. CommonCLI splits its surface by CALLER, not by command: a serial caller (sender_timestamp 0, physical access) reads secrets in plaintext, a remote one gets "******** (serial only)". Its own comments say so — "Serial only (WiFi creds grant LAN access); remote sees set/unset". execCommand passes 0, which is what makes `erase`, `stats-*` and `set freq` reachable at all, and with it the terminal inherited the serial console's plaintext answers for an HTTP request: `get prv.key` returned this node's identity, `get wifi.pwd` the operator's network. Worse in setup mode, which authenticates by proximity to an open AP — and `start webconfig ap` can be run on an already-configured node, so the secrets are real by then, not blank. I had reasoned that the AP was the trust boundary either way because the wizard can already rewrite these. That conflated two capabilities: replacing a WiFi password does not reveal the current one, and replacing an identity does not reveal the existing private key. /api/config has always masked these on read (wcIsSecretKey); the CLI simply broke that rule. Now only the READ is masked — the command surface stays whole — in CommonCLI's own words, keeping the set/unset signal that is the useful part. Onboarding could also skip the mandatory password. handleConfigPost refuses to arm a reboot during initial setup without one; the CLI only warned in the browser, which a pasted script or a direct POST ignores, so a node could reboot onto the LAN still holding the factory credential. Same rule now applies at POST. It is satisfied by a `password` command anywhere in the session rather than only in the same request, so the natural two-step console flow still works — the form batch always sends both together and never needed that memory. wcIsSecretReadCommand lives in WebConfigKeys.h beside the rest of the secret classification, pinned by three host tests: what must be masked, what must not, and that only reads are touched. 17 keys + 24 batch tests pass; the audit checks a masked read round-trips as masked.
This commit is contained in:
1 parent
20826dcccf
commit
8abe26ba7b
7 files changed
+150
-5
No files matched your search
@@ -105,7 +105,10 @@ ROUND_TRIPS = [
|
||||
("set mqtt.neighbors on", "get mqtt.neighbors", "on"),
|
||||
("set path.hash.mode 2", "get path.hash.mode", "2"),
|
||||
("set mqtt.iata den", "get mqtt.iata", "DEN"),
|
||||
("set guest.password hunter2", "get guest.password", "********"),
|
||||
# Secret reads are masked back down for an HTTP caller, in CommonCLI's own
|
||||
# words for a non-serial one (wcIsSecretReadCommand).
|
||||
("set guest.password hunter2", "get guest.password", "******** (serial only)"),
|
||||
("set wifi.pwd hunter2", "get wifi.pwd", "******** (serial only)"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ class State:
|
||||
self.session = None # cookie token when logged in (LAN mode)
|
||||
self.batch = {"state": "idle"}
|
||||
self.cli = {"state": "idle"} # deferred CLI sequence, see /api/cli
|
||||
self.admin_pwd_set = False # satisfies the initial-setup invariant
|
||||
self.scan_started = None
|
||||
|
||||
# ---- auth -------------------------------------------------------------
|
||||
@@ -467,6 +468,17 @@ def cli_unavailable(cmd):
|
||||
# Failure replies CommonCLI emits that do NOT start with "Err" — the shapes that
|
||||
# made a naive prefix test call them success. Mirrors
|
||||
# WebConfigBatch::cliReplyIsFailure.
|
||||
def cli_reads_secret(cmd):
|
||||
"""Commands that READ a secret. CommonCLI gates these on the caller being
|
||||
the serial console; the portal is not, so the value is masked here the way
|
||||
CommonCLI masks it for remote callers. Mirrors wcCliReadsSecret()."""
|
||||
if not cmd.startswith("get "):
|
||||
return False
|
||||
key = cmd[4:].strip()
|
||||
return key in ("prv.key", "guest.password", "alert.psk", "bridge.secret") \
|
||||
or is_secret_key(key)
|
||||
|
||||
|
||||
def cli_reply_is_failure(reply):
|
||||
if not reply:
|
||||
return False
|
||||
@@ -907,6 +919,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
why = cli_unavailable(c)
|
||||
if why:
|
||||
return self._json(400, {"error": why})
|
||||
# Same invariant handleConfigPost enforces (see wcCliUnavailable's
|
||||
# neighbour in WebConfigServer.cpp): first onboarding is committed by the
|
||||
# reboot, and must not commit the factory password onto someone's LAN.
|
||||
if (ST.setup_mode and ST.initial_setup and not ST.admin_pwd_set
|
||||
and not any(c.startswith("password ") for c in cmds)
|
||||
and (any(c.startswith(CLI_DEFERRED_REBOOT) for c in cmds)
|
||||
or any(c.startswith("set wifi.ssid ") for c in cmds))):
|
||||
return self._json(400, {"error": "admin password required for initial setup — "
|
||||
"run `password <new-password>` first"})
|
||||
|
||||
with ST.lock:
|
||||
self._cli_advance(ST.cli)
|
||||
@@ -936,6 +957,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
_, reply = run_cli(ST.cfg, cmd)
|
||||
if cmd.startswith("password "):
|
||||
reply = "OK" # never echo the new password back
|
||||
ST.admin_pwd_set = True
|
||||
elif cli_reads_secret(cmd):
|
||||
val = reply[2:] if reply.startswith("> ") else reply
|
||||
reply = ("> (not set)" if val in ("", "(not set)")
|
||||
else "> ******** (serial only)")
|
||||
ok = not cli_reply_is_failure(reply)
|
||||
# Only writes gate the reboot, and only on the "OK" convention every
|
||||
# setter keeps (WebConfigBatch::cliReplyGatesReboot).
|
||||
|
||||
Reference in new issue
Block a user