Block local file paths at Campaign server boundaries
This commit is contained in:
279
tests/test_server_path_security.py
Normal file
279
tests/test_server_path_security.py
Normal 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()
|
||||
Reference in New Issue
Block a user