blitz_api/tests/test_setup_input_validation.py
fusion44 97cbd4cb55
fix(setup): make input validation effective and tighten setup endpoints
password_valid()/name_valid() ended in `return re.match(...)`, which yields a
Match object or None. The setup router tests them with `is False`, and
`None is False` is False, so the charset check never rejected anything --
only the length and space rules were doing any work. The validators now return
real booleans, and use fullmatch: with re.match the trailing `$` also matches
just before a final newline.

Impact is bounded, and this is a correctness bug rather than a vulnerability.
These call sites are reachable only while the node is in `state=waitsetup`,
and that same endpoint hands the caller a signed admin JWT by design, on a
device where blitzapi has passwordless sudo. A malformed hostname reaching the
setup file -- which provisioning sources as bash -- therefore grants nothing
the caller does not already have, and in practice the value comes from the
operator's own setup form. What it does cost: a hostname containing a quote or
`$` corrupts the setup file and breaks provisioning, and the charset gate
would not hold as a defence if the setup flow ever gains operator binding.

Also in this change:

- Gate /setup-start-done and /setup-start-info on setupPhase != "done", as
  /setup/shutdown already does. `state` lives in the unauthenticated key-value
  store, so a local process could flip it back to "waitsetup" on a fully
  provisioned node and be handed an admin JWT. Unlike the above, that is a
  real escalation, because setup is supposed to be closed at that point.
- raise HTTPException instead of returning it (18 sites). FastAPI serialised
  the returned object as a 200 body, so rejections looked like successes; the
  WebUI stored that body as its access token.
- Fix the status.status.HTTP_405_METHOD_NOT_ALLOWED typo (3 sites) that raised
  AttributeError and surfaced as an unhandled 500.
- Create the setup file 0600. It holds passwords A/B/C in cleartext and
  provisioning appends the wallet seed words, on a tmpfs mounted mode=0777.

Regression tests in tests/test_setup_input_validation.py; all nine fail before
this change and pass after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:51:07 +02:00

131 lines
4.5 KiB
Python

"""
Regression tests for the RaspiBlitz setup input validation and file handling.
Values from POST /setup/setup-start-done are written to the setup file, which
provisioning sources as bash as root, so the charset validators are the
boundary between an unauthenticated request body and root command execution.
"""
import os
import stat
from app.system.impl.raspiblitz_utils import name_valid, password_valid
# Values that must never reach a sourced-as-bash file. No spaces: the validators
# reject those separately, and an attacker does not need them ($IFS, redirects).
SHELL_METACHAR_INPUTS = [
"abcdefgh';touch/tmp/pwned;'",
"abcdefgh$(touch/tmp/pwned)",
"abcdefgh`touch/tmp/pwned`",
"abcdefgh;touch/tmp/pwned",
"abcdefgh|touch/tmp/pwned",
"abcdefgh&touch/tmp/pwned",
"abcdefgh>/tmp/pwned",
"abcdefgh\ntouch/tmp/pwned",
]
def test_validators_return_real_booleans():
"""The setup router checks `is False`, so returning None was a bypass."""
assert password_valid("bad'chars") is False
assert name_valid("bad'chars") is False
assert password_valid("goodpassword123") is True
assert name_valid("goodhostname") is True
def test_password_valid_rejects_shell_metacharacters():
for candidate in SHELL_METACHAR_INPUTS:
assert password_valid(candidate) is False, f"accepted: {candidate!r}"
def test_name_valid_rejects_shell_metacharacters():
for candidate in SHELL_METACHAR_INPUTS:
assert name_valid(candidate) is False, f"accepted: {candidate!r}"
def test_validators_reject_trailing_newline():
"""re.match's `$` also matches before a final newline; fullmatch does not."""
assert password_valid("abcd1234\n") is False
assert name_valid("hostname\n") is False
def test_validators_still_accept_legitimate_values():
assert password_valid("Sat0shi-Nakamoto_2009.") is True
assert name_valid("my-blitz_01.node") is True
# length and space rules must keep working
assert password_valid("short7") is False
assert password_valid("has space here") is False
assert name_valid("ab") is False
async def _call_start_done(monkeypatch, redis_values, **overrides):
"""Invoke setup_start_done with a faked key-value store."""
import pytest
from fastapi import HTTPException
from app.setup.impl.raspiblitz import router as setup_router
async def fake_redis_get(key):
return redis_values.get(key, "")
monkeypatch.setattr(setup_router, "redis_get", fake_redis_get)
payload = {
"hostname": "myblitz",
"forceFreshSetup": False,
"keepBlockchain": False,
"lightning": "none",
"passwordA": "goodpassword1",
"passwordB": "goodpassword1",
"passwordC": "",
}
payload.update(overrides)
data = setup_router.StartDoneData(**payload)
with pytest.raises(HTTPException) as excinfo:
await setup_router.setup_start_done(data)
return excinfo.value
async def test_finalized_node_rejects_setup_start_done(monkeypatch):
"""`state` is attacker-writable; `setupPhase` stays "done" after setup."""
exc = await _call_start_done(
monkeypatch, {"state": "waitsetup", "setupPhase": "done"}
)
assert exc.status_code == 405
async def test_setup_start_done_rejects_shell_metacharacter_hostname(monkeypatch):
"""hostname is interpolated unquoted into the sourced-as-root file."""
exc = await _call_start_done(
monkeypatch,
{"state": "waitsetup", "setupPhase": "setup"},
hostname="blitz;touch/tmp/pwned",
)
assert exc.status_code == 400
async def test_setup_start_done_rejects_shell_metacharacter_password(monkeypatch):
exc = await _call_start_done(
monkeypatch,
{"state": "waitsetup", "setupPhase": "setup"},
passwordA="abcdefgh';touch/tmp/pwned;'",
)
assert exc.status_code == 400
def test_setup_file_is_created_private(tmp_path):
"""Holds cleartext passwords and seed words on a mode=0777 tmpfs."""
from app.setup.impl.raspiblitz.router import write_text_file
target = tmp_path / "raspiblitz.setup"
write_text_file(str(target), ["passwordA='secret123'", ""])
mode = stat.S_IMODE(os.stat(target).st_mode)
assert mode == 0o600, f"expected 0600, got {oct(mode)}"
# rewriting an existing file must not silently widen it either
os.chmod(target, 0o644)
write_text_file(str(target), ["passwordA='secret123'", ""])
mode = stat.S_IMODE(os.stat(target).st_mode)
assert mode == 0o600, f"rewrite left mode {oct(mode)}"