--restore: файлы настроек копии – только печатные ASCII, нулевой байт больше не выключает проверку

Нулевой байт в kit.env, install-result.env или kit-backup.env заставлял GNU grep считать
файл двоичным и молчать: safe_env «проходил», а bash при «.» выкидывал нулевые байты
и выполнял остальное (лишние имена вроде PATH и KIT_CLI_URL).

- проверка архива отклоняет три файла настроек с байтами вне 0x20–0x7E и \n (и больше 64 КиБ),
  3x-ui.txt – не UTF-8 или с управляющими символами (кроме \n и \t), больше 1 МиБ;
  проверяются все записи с таким именем, в том числе повторные;
- safe_env: сначала tr – только печатные ASCII и \n, затем grep -a под LC_ALL=C;
- grep -a там, где kit и установщик читают свои файлы настроек (kit.sh: замена токена API);
- отрицательные тесты: \0, \r, \x1b, \x80, \xff в начале, в середине, в конце, отдельной строкой
  и в значении в каждом из трёх файлов, повторная запись в архиве, 3x-ui.txt.
This commit is contained in:
maxdmitrov
2026-10-06 18:58:38 +03:00
parent 436acffc22
commit 018d9cefa2
6 changed files with 92 additions and 6 deletions
+32
View File
@@ -123,6 +123,38 @@ class RestoreEnv(unittest.TestCase):
for v in (b"XUI_WEB_BASE_PATH='x/ { return 200; } location /zz'", b"XUI_WEB_BASE_PATH=x/../y", b"XUI_WEB_BASE_PATH=$(id)"):
self.bad({"etc/x-ui/install-result.env": ENVS["etc/x-ui/install-result.env"].replace(b"XUI_WEB_BASE_PATH=AbCdEf123456789012", v)})
def test_binary_bytes_every_file(self):
# Аудит 1.2 (N6): нулевой байт и прочее непечатное в файлах настроек отклоняет уже проверка архива.
for name, body in ENVS.items():
first = body.index(b"\n") + 1
for bad in (b"\0", b"\r", b"\x1b", b"\x80", b"\xff", b"\t", b"\x7f"):
for variant in (bad + body, body[:first] + bad + b"\n" + body[first:],
body.replace(b"1.2.3.4", b"1.2" + bad + b".3.4", 1), body + b"PATH=/tmp/x" + bad + b"\n",
body + b"PATH=/tmp/x\n" + bad, body + bad + b"\n"):
r = check(archive([(k, v) for k, v in dict(ENVS, **{name: variant}).items()]))
self.assertNotEqual(r.returncode, 0, "%s %r" % (name, variant))
self.assertIn("байты " + name, r.stderr)
# Слишком большой файл (одна длинная строка) – тоже отказ.
r = check(archive([(k, v) for k, v in dict(ENVS, **{name: body + b"X" * 70000 + b"\n"}).items()]))
self.assertIn("байты " + name, r.stderr)
# Тот же файл в архиве дважды (tar распакует последний) – проверяются оба.
p = archive([(k, v) for k, v in ENVS.items()])
with tarfile.open(p, "r:gz") as t:
ms = [(m, t.extractfile(m).read()) for m in t.getmembers()]
with tarfile.open(p, "w:gz") as t:
for m, data in ms + [(tarfile.TarInfo(name), body + b"\0")]:
m.size = len(data)
t.addfile(m, io.BytesIO(data))
self.assertIn("байты " + name, check(p).stderr)
def test_3xui_txt(self):
good = "3X-UI KIT – данные для входа\n\nПанель: https://1.2.3.4/x/\n\tvless://a@1.2.3.4:443?x=1#Мой 🙂\n".encode()
r = check(archive([(k, v) for k, v in ENVS.items()] + [("root/3x-ui.txt", good)]))
self.assertEqual(r.returncode, 0, r.stderr)
for bad in (b"\0", b"\x1b]0;x\x07", b"\x1b[2J", b"\r", b"\xff", b"\xc2\x9b", b"\x7f"):
r = check(archive([(k, v) for k, v in ENVS.items()] + [("root/3x-ui.txt", good + bad + b"\n")]))
self.assertIn("байты root/3x-ui.txt", r.stderr, repr(bad))
def test_values_every_file(self):
for name in ENVS:
for line in ENVS[name].splitlines():
+32
View File
@@ -119,6 +119,38 @@ class SafeEnv(unittest.TestCase):
for bad in ("RESULT=/etc/cron.d/x", "XUI_PIN=v0", "BACKUP_HOST=1.2.3.4"):
self.rejected("kit.env", GOOD["kit.env"] + bad + "\n")
def test_binary_bytes(self):
# Аудит 1.2 (N6): с нулевым байтом GNU grep без -a считал файл двоичным и молчал – проверка «проходила»,
# а bash при «.» выкидывает нулевые байты и выполняет остальное. Лишнее имя PATH плюс один байт в каждом месте.
for name, text in GOOD.items():
body = text.encode()
first = body.index(b"\n") + 1
for bad in (b"\0", b"\r", b"\x1b", b"\x80", b"\xff"):
for variant in (bad + body, # в начале
body[:first] + bad + b"\n" + body[first:], # отдельной строкой
body[:5] + bad + body[5:], # в середине имени или в начале значения
body.replace(b"1.2.3.4", b"1.2" + bad + b".3.4", 1), # в середине значения
body + b"PATH=/tmp/x" + bad + b"\n", # в значении лишнего имени
body + b"PATH=/tmp/x\n" + bad, # в конце, без перевода строки
body + b"PATH=/tmp/x\n" + bad + b"\n"): # в конце, одиночный
self.rejected_bytes(name, variant)
# Без лишнего имени, только байт – тоже отказ.
self.rejected_bytes(name, body + b"\0\n")
self.rejected_bytes(name, body.replace(b"\n", b"\r\n"))
def rejected_bytes(self, name, data):
d = tempfile.mkdtemp()
p = os.path.join(d, name)
with open(p, "wb") as fh:
fh.write(data)
script = ('set -Eeuo pipefail\ndie() { echo "DIE: $*"; exit 1; }\n' + funcs
+ 'safe_env "$1" ' + " ".join(NAMES[name]) + '\necho OK\n')
r = subprocess.run(["bash", "-c", script, "t", p], capture_output=True)
out = (r.stdout + r.stderr).decode("utf-8", "replace")
self.assertNotEqual(r.returncode, 0, "%s %r: %s" % (name, data, out))
self.assertIn("Это не резервная копия 3X-UI KIT", out, "%s %r" % (name, data))
self.assertNotIn("\nOK\n", "\n" + out)
def test_substitutions(self):
for bad in ("BACKUP_DATE=$(id)", "BACKUP_DATE=`id`", 'BACKUP_DATE="$HOME"', "export BACKUP_DATE=1", "BACKUP_DATE=1; id", " BACKUP_DATE=1"):
self.rejected("kit-backup.env", GOOD["kit-backup.env"] + bad + "\n")