feat: sanitize filenames in apt and yum modules to prevent path traversal

Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
d9pouces
2026-09-09 23:18:24 +02:00
parent 4409c38579
commit be91425355
10 changed files with 169 additions and 13 deletions
@@ -0,0 +1,33 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
lQOYBGqg6rMBCADaMUd27S41+kInPXuRZ0aL41aMZjqr+mw4W34w8i/JRYMJ6eGb
sXf40AaFfS7zkbUpQZpmh3aYPF4jUAdWV03ZNNYdcGerIAKc2xsugSKjJIm1XPQq
upz914b4/hp/UslDUH7SPZ+3Tq17TXW+xP6aUM9VcWMnlJT/DFgxh5sZB3nfDeYr
yBV+PmEVfuQaUSIwKAh3Hv0ZaBHS8KzsawLzbJ+Pa9W9wjVME1izi9ctloGSxtLc
p1suS2IS33QB0jGgj4VbuMcjo2iCpzW3Xd7jQ7WpXGjrl0bHnJTnWWB0ihDD3cJe
tJlk9GScMQu3Z3th67DmqTXQru3btrDjxMifABEBAAEAB/sG3JffwJO8dNNRwpg5
OWM5kPuuzKQ5tlVi7FWqmmMwVDRLwoQqzkj0lS4vUoGAfWuSEZMWeFodbYCUYbbp
WzOCiivVnyxTcF1Eq4tOShdZxBNklbjEAzxeq0F+YRdaO7NCn6xtVizIqfvAre3v
Aujy12m9ySQSIZUR9ERtwlL5VXj4yzcXciyM9aNbh2D4BFAoo+fBL1B5mhpKQ16B
BafkpOf0myWZrwLdcn1t7BQ6UcLqsPJj6COCXXRDHoZvim38UBMMajNP4+8eC2Vk
DDR6iFz53FRvIREQWkoEEW4P80v1lgcnP1apJGhkrJYCPM7ObZk0MoQkBiBdlPPQ
VLiRBADhNsKjNd7v3Af5XGPJGWWZxCMxPlgwc6EMjCX3SQDql042eSIqwn1FFP6H
Hi8lyq/XWz0JcUwcrpFxpIC3OLUsBleX3Z4E17Z6AotSV0FC6y23cqgtweMeLA0L
tayMRmXJ7Aan+xfvi8MbW6lYGzM2uYHl27rVBuCt0kvxn+U4lwQA+ATO8KaBreCL
E/vXGZaPWJs2GoiDBavKwqdYtlXDKlVP5XvBgaPfY6zKLtCsC6N+DKkVo42Stwu3
oB6HSSLvXvFIhvUReh5PLpijmOroAJzvLQN9qmZBvF4IOUgaHmlYlL0DGzlxcwLr
SdzqPaGRa+EgraFDDGGu5oQ5A2wNKTkEAMDS2w5SibOfsvnfK4aDc0mi8/fftzCO
SwsvVzR0SmbXtaCPBnBjlA7YJhRZ6n/7nDpswrWPk8g125A0vK1cNRGM0ufKYW1f
DMbB/aYrJjAKz8zWH6cgOD4fqM2gA9JounkIcJvWOToGume8pXhmem94u2FP8aik
A6hL/Os+WRQcMNi0NWlkLWNvbmZpZ3VyYXRvci10ZXN0cyA8dGVzdHNAaWQtY29u
ZmlndXJhdG9yLmludmFsaWQ+iQFuBBMBCABYFiEEaZF4ALsB1xUU3IP5rl3Himib
1RgFAmqg6rMbFIAAAAAABAAObWFudTIsMi41KzEuMTIsMCwzAxsvBAULCQgHAgIi
AgYVCgkICwIEFgIDAQIeBwIXgAAKCRCuXceKaJvVGFRrB/wIbG1HHQANDCHgyl4T
RTPuKBX2c9lmJi2plp+T5L5sXyTMbAMLfTCkJWL/zHLM3N2v6GZuzbwZM8Dzj9zL
LMpC6newmZe4YX+ZtyZGufBI963kh8PvlSWWUSyH+oYoIhlyJqj7tCQXoijEvQnS
ReKGHUTvQGvQK2Ybp735NmAiDOM3/3KKh2Dqas/Tc2mxec4whr8RyUidsF+Yp6wT
esfMxeax6DmWIcSLDWpUBndeahZ2Ff0c96BzbpF1v2hbvyI/IaP5snb4Nm1xpREm
+y8iDAQhOklfXCyXrWgN3sIrk3tDndGrn+nw0iusnaBVki0keqjlH0camoUP28tu
3sxG
=TrE4
-----END PGP PRIVATE KEY BLOCK-----
@@ -0,0 +1,19 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBGqg6rMBCADaMUd27S41+kInPXuRZ0aL41aMZjqr+mw4W34w8i/JRYMJ6eGb
sXf40AaFfS7zkbUpQZpmh3aYPF4jUAdWV03ZNNYdcGerIAKc2xsugSKjJIm1XPQq
upz914b4/hp/UslDUH7SPZ+3Tq17TXW+xP6aUM9VcWMnlJT/DFgxh5sZB3nfDeYr
yBV+PmEVfuQaUSIwKAh3Hv0ZaBHS8KzsawLzbJ+Pa9W9wjVME1izi9ctloGSxtLc
p1suS2IS33QB0jGgj4VbuMcjo2iCpzW3Xd7jQ7WpXGjrl0bHnJTnWWB0ihDD3cJe
tJlk9GScMQu3Z3th67DmqTXQru3btrDjxMifABEBAAG0NWlkLWNvbmZpZ3VyYXRv
ci10ZXN0cyA8dGVzdHNAaWQtY29uZmlndXJhdG9yLmludmFsaWQ+iQFuBBMBCABY
FiEEaZF4ALsB1xUU3IP5rl3Himib1RgFAmqg6rMbFIAAAAAABAAObWFudTIsMi41
KzEuMTIsMCwzAxsvBAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRCuXceK
aJvVGFRrB/wIbG1HHQANDCHgyl4TRTPuKBX2c9lmJi2plp+T5L5sXyTMbAMLfTCk
JWL/zHLM3N2v6GZuzbwZM8Dzj9zLLMpC6newmZe4YX+ZtyZGufBI963kh8PvlSWW
USyH+oYoIhlyJqj7tCQXoijEvQnSReKGHUTvQGvQK2Ybp735NmAiDOM3/3KKh2Dq
as/Tc2mxec4whr8RyUidsF+Yp6wTesfMxeax6DmWIcSLDWpUBndeahZ2Ff0c96Bz
bpF1v2hbvyI/IaP5snb4Nm1xpREm+y8iDAQhOklfXCyXrWgN3sIrk3tDndGrn+nw
0iusnaBVki0keqjlH0camoUP28tu3sxG
=qZrp
-----END PGP PUBLIC KEY BLOCK-----
+22 -2
View File
@@ -32,7 +32,7 @@ def make_config() -> Configuration:
return Configuration.model_validate(SOURCE_CONTENT)
def make_config_with(source_extra: dict) -> Configuration:
def make_config_with(source_extra: dict, name: str = "interdiode") -> Configuration:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
@@ -40,7 +40,7 @@ def make_config_with(source_extra: dict) -> Configuration:
"apt": {
"sources": [
{
"name": "interdiode",
"name": name,
"uris": ["http://example.invalid/debian"],
"suites": ["stable"],
**source_extra,
@@ -227,6 +227,26 @@ def test_apply_without_signed_by_writes_no_keyring_or_field(
)
def test_apply_sanitizes_source_name_with_slash(tmp_path, monkeypatch) -> None:
mock_run = _mock_gpg_dearmor_success(monkeypatch)
dst = tmp_path / "dst"
context = Context(
config=make_config_with({"signed-by": KEY_CONTENT}, name="../../evil"),
dst=dst,
src=None,
)
AptModule().apply(context)
keyring = dst / "etc/apt/keyrings/.._.._evil.gpg"
assert keyring.read_bytes() == KEYRING_BYTES
source_file = dst / "etc/apt/sources.list.d/.._.._evil.sources"
assert "Signed-By: /etc/apt/keyrings/.._.._evil.gpg" in source_file.read_text(
encoding="utf-8"
)
mock_run.assert_called_once()
def test_resolve_signed_by_raises_when_gpg_not_on_path(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("id_configurator.modules.apt.shutil.which", lambda _: None)
dst = tmp_path / "dst"
+21
View File
@@ -54,6 +54,27 @@ def test_apply_writes_certificate_and_rebuilds_bundle(tmp_path) -> None:
assert openssl_default.readlink().as_posix() == "/etc/ssl/certs/ca-certificates.crt"
def test_apply_sanitizes_certificate_name_with_slash(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"certificates": [{"name": "../../evil", "content": CERT_CONTENT}]
},
}
dst = tmp_path / "dst"
context = Context(config=Configuration.model_validate(data), dst=dst, src=None)
CertificatesModule().apply(context)
local_cert = dst / "usr/local/share/ca-certificates/.._.._evil.crt"
assert local_cert.read_bytes() == b"fake-cert-bytes"
# The "/" must never be interpreted as a path separator: without
# sanitizing, "../../evil" would have reshuffled the write up to
# usr/local/evil.crt instead of staying under ca-certificates/.
assert not (dst / "usr/local/evil.crt").exists()
def test_apply_merges_conf_listed_and_local_certificates(tmp_path) -> None:
src = tmp_path / "src"
local_certs_dir = src / "usr/local/share/ca-certificates"
+18 -1
View File
@@ -1,5 +1,10 @@
from id_configurator.modules import base as base_module
from id_configurator.modules.base import Module, all_modules, register
from id_configurator.modules.base import (
Module,
all_modules,
register,
sanitize_filename,
)
def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
@@ -17,3 +22,15 @@ def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
register(DummyModule)
assert all_modules() == [DummyModule]
def test_sanitize_filename_leaves_plain_names_untouched() -> None:
assert sanitize_filename("interdiode") == "interdiode"
def test_sanitize_filename_replaces_forward_slash() -> None:
assert sanitize_filename("../../etc/passwd") == ".._.._etc_passwd"
def test_sanitize_filename_replaces_backslash() -> None:
assert sanitize_filename(r"a\b") == "a_b"
+30
View File
@@ -165,6 +165,36 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -
)
def test_apply_sanitizes_repository_and_key_names_with_slash(
tmp_path, monkeypatch
) -> None:
mock_run = _mock_gpg_success(monkeypatch)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {
"repositories": [
{
"name": "../../evil-repo",
"index-url": "https://example.invalid/yum",
}
],
"keys": [{"name": "../../evil-key", "content": KEY_CONTENT}],
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
YumModule().apply(context)
assert (dst / "etc/yum.repos.d/.._.._evil-repo.repo").exists()
key_content = (dst / "etc/pki/rpm-gpg/RPM-GPG-KEY-.._.._evil-key").read_bytes()
assert key_content == b"fake-rpm-gpg-key-bytes"
mock_run.assert_called_once()
def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(
"id_configurator.modules.yum.subprocess.run",