Block local file paths at Campaign server boundaries

This commit is contained in:
2026-07-21 03:17:11 +02:00
parent 78f52a36d4
commit 1d291377c0
9 changed files with 576 additions and 19 deletions

View File

@@ -0,0 +1,279 @@
from __future__ import annotations
import copy
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_campaign.backend.attachments.resolver import (
MessageAttachmentStatus,
resolve_entry_attachments,
)
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.path_security import (
CampaignPathSecurityError,
assert_server_safe_campaign_paths,
)
from govoplan_campaign.backend.persistence.campaigns import (
create_campaign_version_from_json,
load_version_config,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
def _campaign_json() -> dict[str, object]:
return {
"version": "1.0",
"campaign": {"id": "path-security", "name": "Path security", "mode": "test"},
"template": {"subject": "Subject", "text": "Body"},
"attachments": {"base_path": ".", "base_paths": [], "global": []},
"entries": {"inline": []},
}
class ServerCampaignPathSecurityTests(unittest.TestCase):
def test_api_import_rejects_absolute_source_filename_before_persistence(self) -> None:
with patch(
"govoplan_campaign.backend.persistence.campaigns.files_integration",
return_value=SimpleNamespace(available=False),
):
with self.assertRaisesRegex(CampaignPathSecurityError, "source_filename"):
create_campaign_version_from_json(
None, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
raw_json=_campaign_json(),
source_filename="/etc/passwd",
)
def test_template_parent_traversal_is_rejected(self) -> None:
raw = _campaign_json()
raw["template"] = {"source": {"text_path": "../../etc/passwd"}}
with self.assertRaisesRegex(CampaignPathSecurityError, "template source paths"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
def test_existing_unsafe_version_cannot_reuse_a_delivery_snapshot(self) -> None:
raw = _campaign_json()
raw["template"] = {"source": {"text_path": "/etc/passwd"}}
version = SimpleNamespace(raw_json=raw, execution_snapshot={})
with patch(
"govoplan_campaign.backend.sending.execution.files_integration",
return_value=SimpleNamespace(available=True),
):
with self.assertRaisesRegex(ExecutionSnapshotError, "template source paths"):
ensure_execution_snapshot(None, version) # type: ignore[arg-type]
def test_existing_unsafe_version_is_rejected_before_config_loading(self) -> None:
raw = _campaign_json()
raw["template"] = {"source": {"text_path": "/etc/passwd"}}
version = SimpleNamespace(campaign_id="campaign-1", raw_json=raw)
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
session = SimpleNamespace(get=lambda _model, key: version if key == "version-1" else campaign)
with (
patch(
"govoplan_campaign.backend.persistence.campaigns.files_integration",
return_value=SimpleNamespace(available=True),
),
patch("govoplan_campaign.backend.persistence.campaigns._write_campaign_snapshot") as write_snapshot,
patch("govoplan_campaign.backend.persistence.campaigns.load_campaign_config_from_json") as load_config,
):
with self.assertRaisesRegex(CampaignPathSecurityError, "template source paths"):
load_version_config(session, "version-1") # type: ignore[arg-type]
write_snapshot.assert_not_called()
load_config.assert_not_called()
def test_source_base_symlink_escape_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
outside = root / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("secret", encoding="utf-8")
trusted = root / "trusted"
trusted.mkdir()
(trusted / "escape").symlink_to(outside, target_is_directory=True)
raw = _campaign_json()
raw["template"] = {"source": {"text_path": "escape/secret.txt"}}
with self.assertRaisesRegex(CampaignPathSecurityError, "source_base_path"):
assert_server_safe_campaign_paths(
raw,
source_base_path=str(trusted),
managed_files_available=True,
)
def test_attachment_absolute_path_and_parent_filter_are_rejected(self) -> None:
raw = _managed_attachment_campaign()
attachments = raw["attachments"]
assert isinstance(attachments, dict)
base_paths = attachments["base_paths"]
assert isinstance(base_paths, list)
assert isinstance(base_paths[0], dict)
base_paths[0]["path"] = "/etc"
with self.assertRaisesRegex(CampaignPathSecurityError, "relative managed-file path"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
raw = _managed_attachment_campaign()
attachments = raw["attachments"]
assert isinstance(attachments, dict)
rules = attachments["global"]
assert isinstance(rules, list)
assert isinstance(rules[0], dict)
rules[0]["file_filter"] = "../../etc/passwd"
with self.assertRaisesRegex(CampaignPathSecurityError, "parent-directory traversal"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
def test_local_attachment_rule_is_rejected_in_server_mode(self) -> None:
raw = _managed_attachment_campaign()
attachments = raw["attachments"]
assert isinstance(attachments, dict)
base_paths = attachments["base_paths"]
assert isinstance(base_paths, list)
assert isinstance(base_paths[0], dict)
base_paths[0].pop("source")
with self.assertRaisesRegex(CampaignPathSecurityError, "backed by managed Files"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
def test_default_attachment_rule_must_use_managed_files(self) -> None:
raw = _managed_attachment_campaign()
attachments = raw["attachments"]
assert isinstance(attachments, dict)
attachments["global"] = []
entries = raw["entries"]
assert isinstance(entries, dict)
entries["defaults"] = {
"attachments": [{"base_dir": "invoices", "file_filter": "invoice.pdf"}]
}
with self.assertRaisesRegex(CampaignPathSecurityError, "entries.defaults.attachments"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
def test_normal_managed_file_campaign_is_allowed(self) -> None:
raw = _managed_attachment_campaign()
assert_server_safe_campaign_paths(raw, managed_files_available=True)
copied = copy.deepcopy(raw)
attachments = copied["attachments"]
assert isinstance(attachments, dict)
rules = attachments["global"]
assert isinstance(rules, list)
assert isinstance(rules[0], dict)
rules[0]["file_filter"] = "**/{{local:invoice_number}}-report.XLSX"
assert_server_safe_campaign_paths(copied, managed_files_available=True)
def test_rendered_managed_filter_cannot_traverse_materialized_source(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
managed_root = root / "managed"
managed_root.mkdir()
(root / "secret.pdf").write_bytes(b"server secret")
config = _runtime_managed_attachment_config(
managed_root,
file_filter="{{local:file_name}}",
file_name="../secret.pdf",
)
resolution = resolve_entry_attachments(
config=config,
campaign_file=root / "campaign.json",
entry=config.entries.inline[0], # type: ignore[index]
entry_index=1,
)
self.assertEqual(resolution.status, MessageAttachmentStatus.BLOCKED)
self.assertEqual(resolution.attachments[0].matches, [])
self.assertIn(
"unsafe_managed_attachment_path",
{issue.code for issue in resolution.issues},
)
def test_managed_match_symlink_cannot_escape_materialized_source(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
managed_root = root / "managed"
managed_root.mkdir()
outside = root / "secret.pdf"
outside.write_bytes(b"server secret")
(managed_root / "escape.pdf").symlink_to(outside)
config = _runtime_managed_attachment_config(
managed_root,
file_filter="escape.pdf",
file_name="unused.pdf",
)
resolution = resolve_entry_attachments(
config=config,
campaign_file=root / "campaign.json",
entry=config.entries.inline[0], # type: ignore[index]
entry_index=1,
)
self.assertEqual(resolution.status, MessageAttachmentStatus.BLOCKED)
self.assertEqual(resolution.attachments[0].matches, [])
self.assertIn(
"managed_attachment_path_escape",
{issue.code for issue in resolution.issues},
)
def _managed_attachment_campaign() -> dict[str, object]:
raw = _campaign_json()
raw["attachments"] = {
"base_path": "invoices",
"base_paths": [
{
"id": "personal-invoices",
"name": "My invoices",
"source": "managed:user:user-1",
"path": "invoices",
}
],
"global": [
{
"base_path_id": "personal-invoices",
"base_dir": "invoices",
"file_filter": "invoice.pdf",
}
],
}
return raw
def _runtime_managed_attachment_config(
managed_root: Path,
*,
file_filter: str,
file_name: str,
) -> CampaignConfig:
raw = _managed_attachment_campaign()
raw["fields"] = [{"name": "file_name", "type": "string", "required": True}]
attachments = raw["attachments"]
assert isinstance(attachments, dict)
base_paths = attachments["base_paths"]
assert isinstance(base_paths, list)
assert isinstance(base_paths[0], dict)
base_paths[0]["path"] = str(managed_root)
rules = attachments["global"]
assert isinstance(rules, list)
assert isinstance(rules[0], dict)
rules[0]["file_filter"] = file_filter
entries = raw["entries"]
assert isinstance(entries, dict)
entries["inline"] = [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.test", "type": "to"}],
"fields": {"file_name": file_name},
}
]
return CampaignConfig.model_validate(raw)
if __name__ == "__main__":
unittest.main()