mirror of
https://github.com/fusion44/blitz_api.git
synced 2026-08-20 12:57:23 +02:00
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>
131 lines
4.5 KiB
Python
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)}"
|