"""What a backup may carry, and what it may be named. `/api/import` takes a JSON object an operator uploaded and writes files out of it. Two things stand between that and the filesystem — a charset for the key, and `safe_join` — and one thing stands between a restore and this instance's own credentials, which is what `dump()` refuses to export. All three have integration coverage, which means they are checked against a live instance by someone who has one. They are pure functions; checking them here costs a millisecond and covers the cases nobody would stage against a running firewall. """ import re from pathlib import Path import pytest from fastapi import HTTPException from utils import SAFE_DB_NAME, SAFE_PY_NAME, safe_join from utils.sqlite import SQLite # --- what a backup entry may be called --------------------------------------- @pytest.mark.parametrize("name", ["services.db", "firegex.db", "a.db", "A-b_9.db"]) def test_the_shapes_export_db_produces_are_accepted(name): assert SAFE_DB_NAME.fullmatch(name) @pytest.mark.parametrize("name", [ "../services.db", "db/services.db", "/etc/services.db", "..", ".db", "services.DB", "services.db.py", "services db.db", "services.db\x00", ]) def test_anything_that_is_not_a_plain_basename_is_refused(name): assert not SAFE_DB_NAME.fullmatch(name) def test_a_trailing_newline_does_not_get_past_the_guard(): """`$` matches before a trailing newline, so `match` accepted `"services.db\\n"`. It was never a way out of the directory — `safe_join` contains the result whatever the name — so this cost nothing. It is fixed because a pattern anchored to mean "this and nothing else" should mean that: the next thing written against one of these guards should not have to know about the exception. """ assert re.match(r'^[A-Za-z0-9_-]+\.db$', "services.db\n"), "the old spelling accepted it" assert not SAFE_DB_NAME.fullmatch("services.db\n") assert not SAFE_PY_NAME.fullmatch("abc123.py\n") def test_the_two_shapes_do_not_accept_each_other(): assert not SAFE_PY_NAME.fullmatch("services.db") assert not SAFE_DB_NAME.fullmatch("abc123.py") # --- and where it may be written --------------------------------------------- def test_a_name_that_climbs_out_is_refused(tmp_path): """Defence in depth on top of the charset: the regex is what is *meant* to catch these, and this is what catches them if the regex is ever loosened.""" for attempt in ("../escape.db", "a/../../escape.db", ".."): with pytest.raises(HTTPException) as raised: safe_join(tmp_path, attempt) assert raised.value.status_code == 403 def test_an_absolute_path_is_taken_as_a_relative_one(tmp_path): """Not refused but *contained*, which is the same outcome by a different route.""" landed = safe_join(tmp_path, "/etc/passwd") assert landed.is_relative_to(Path(tmp_path).resolve()) def test_an_ordinary_name_lands_where_it_should(tmp_path): assert safe_join(tmp_path, "services.db") == Path(tmp_path).resolve() / "services.db" # --- what a backup must never carry ------------------------------------------ def test_a_backup_carries_neither_credentials_nor_the_auth_mode(tmp_path): """The third one is the newest and the easiest to lose. `password` and `secret` are this instance's credentials. `auth_disabled` is not a credential — it is *whether credentials are asked for*, which belongs to the deployment and not to the configuration being restored. A backup able to carry it is a backup able to turn a firewall's authentication off. """ db = SQLite(str(tmp_path / "firegex.db"), {}) db.connect() db.create_schema({}) for key, value in (("password", "$pbkdf2$secret"), ("secret", "jwt-signing-key"), ("auth_disabled", "1"), ("auth_disabled_host", "1"), ("something_else", "kept")): db.query("INSERT INTO keys_values (key, value) VALUES (?, ?);", key, value) carried = {row["key"] for row in db.dump()["keys_values"]} assert carried == {"something_else"}, carried db.disconnect() def test_everything_else_in_a_table_is_carried(tmp_path): """The exclusion is four named keys, not a table nobody exports.""" db = SQLite(str(tmp_path / "other.db"), {}) db.connect() db.create_schema({"things": {"id": "VARCHAR(10) PRIMARY KEY", "v": "TEXT"}}) db.query("INSERT INTO things (id, v) VALUES ('a', 'kept');") dumped = db.dump() assert dumped["things"] == [{"id": "a", "v": "kept"}] db.disconnect() # --- whether authentication starts off ---------------------------------------- # A container's environment is fixed when it is created. Docker starting it again by # itself — a reboot, a daemon restart, a crash under `restart: unless-stopped` — brings that # environment back, older than what `run.py config` has said since: authentication turned # back on with `run.py config --password` was off again after the next reboot. def test_a_fresh_boot_takes_the_environment(): """`run.py` has just written it from the current configuration.""" from utils import boot_auth_mode assert boot_auth_mode(None, True, True) == (True, False) assert boot_auth_mode("0", True, True) == (True, False), \ "a container run.py has just created is told the current answer by its environment" assert boot_auth_mode("1", True, False) == (False, False) def test_a_restarted_container_keeps_what_the_host_decided_since(): from utils import boot_auth_mode assert boot_auth_mode("0", False, True) == (False, True), \ "authentication re-enabled with run.py config came back off after a reboot" assert boot_auth_mode("1", False, False) == (True, True) def test_with_nothing_decided_since_the_environment_stands(): from utils import boot_auth_mode assert boot_auth_mode(None, False, False) == (False, False) assert boot_auth_mode(None, False, True) == (True, False)