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

@@ -30,7 +30,7 @@ The module has one required runtime dependency:
Files and mail are optional module integrations declared in the campaign manifest: Files and mail are optional module integrations declared in the campaign manifest:
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Without it, campaigns can still use legacy/local attachment paths where configured. - `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable. - `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable.
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed. Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.

View File

@@ -13,6 +13,11 @@ from pydantic import BaseModel, ConfigDict, Field
from govoplan_campaign.backend.campaign.entries import load_campaign_entries from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.template_values import build_template_values from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig, ZipArchiveConfig, ZipRuleMode from govoplan_campaign.backend.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig, ZipArchiveConfig, ZipRuleMode
from govoplan_campaign.backend.path_security import (
CampaignPathSecurityError,
assert_logical_relative_path,
is_managed_source,
)
class AttachmentScope(StrEnum): class AttachmentScope(StrEnum):
@@ -369,6 +374,23 @@ def _match_files(directory: Path, file_filter: str, include_subdirs: bool, match
return sorted(path for path in directory.glob(file_filter) if path.is_file()) return sorted(path for path in directory.glob(file_filter) if path.is_file())
def _confine_managed_matches(directory: Path, matches: list[Path]) -> tuple[list[Path], bool]:
root = directory.resolve()
confined: list[Path] = []
rejected = False
for match in matches:
try:
resolved = match.resolve()
except (OSError, RuntimeError):
rejected = True
continue
if not resolved.is_relative_to(root):
rejected = True
continue
confined.append(match)
return confined, rejected
def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue: def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue:
code = "missing_required_attachment" if config.required else "missing_optional_attachment" code = "missing_required_attachment" if config.required else "missing_optional_attachment"
severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
@@ -429,13 +451,50 @@ def _resolve_one_config(
attachment_config=config, attachment_config=config,
rendered_base_dir=rendered_base_dir, rendered_base_dir=rendered_base_dir,
) )
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
allow_multiple = _rule_allows_multiple(config, rendered_file_filter) allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
issues: list[AttachmentIssue] = [] issues: list[AttachmentIssue] = []
behavior: Behavior | None = None behavior: Behavior | None = None
managed_source = selected_base_path is not None and is_managed_source(selected_base_path.source)
unsafe_managed_path = False
if managed_source:
try:
assert_logical_relative_path(
rendered_file_filter,
field="rendered managed attachment file_filter",
)
except CampaignPathSecurityError as exc:
matches = []
unsafe_managed_path = True
issues.append(
AttachmentIssue(
severity=ResolutionSeverity.ERROR,
code="unsafe_managed_attachment_path",
message=str(exc),
behavior=Behavior.BLOCK,
)
)
else:
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
matches, rejected = _confine_managed_matches(directory, matches)
if rejected:
matches = []
unsafe_managed_path = True
issues.append(
AttachmentIssue(
severity=ResolutionSeverity.ERROR,
code="managed_attachment_path_escape",
message="A managed attachment match resolved outside its materialized source directory.",
behavior=Behavior.BLOCK,
)
)
else:
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
if not matches: if unsafe_managed_path:
status = AttachmentMatchStatus.MISSING
behavior = Behavior.BLOCK
elif not matches:
status = AttachmentMatchStatus.MISSING status = AttachmentMatchStatus.MISSING
behavior = _missing_behavior(campaign_config, config) behavior = _missing_behavior(campaign_config, config)
issues.append(_issue_for_missing(config, behavior)) issues.append(_issue_for_missing(config, behavior))

View File

@@ -14,6 +14,7 @@ from govoplan_campaign.backend.persistence.campaigns import load_campaign_config
from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
from govoplan_campaign.backend.integrations import files_integration, mail_integration from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
class MockCampaignSendError(RuntimeError): class MockCampaignSendError(RuntimeError):
@@ -334,6 +335,10 @@ def run_mock_campaign_send(
mailbox.clear_records() mailbox.clear_records()
files = files_integration() files = files_integration()
assert_server_safe_campaign_paths(
version.raw_json if isinstance(version.raw_json, dict) else {},
managed_files_available=files.available,
)
with files.prepared_campaign_snapshot( with files.prepared_campaign_snapshot(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
from pathlib import PureWindowsPath
from typing import Any, Iterable
class CampaignPathSecurityError(ValueError):
"""Raised when server-provided campaign JSON could read local files."""
def _text(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def assert_logical_relative_path(value: object, *, field: str) -> None:
raw = _text(value)
if not raw:
return
if "\x00" in raw:
raise CampaignPathSecurityError(f"{field} contains a NUL byte")
normalized = raw.replace("\\", "/")
windows_path = PureWindowsPath(raw)
if (
normalized.startswith("/")
or normalized.startswith("~")
or windows_path.is_absolute()
or bool(windows_path.drive)
):
raise CampaignPathSecurityError(f"{field} must be a relative managed-file path")
if any(part == ".." for part in normalized.split("/")):
raise CampaignPathSecurityError(f"{field} must not contain parent-directory traversal")
def is_managed_source(value: object) -> bool:
raw = _text(value)
if not raw.startswith("managed:"):
return False
parts = raw.split(":", 2)
return len(parts) == 3 and parts[1] in {"user", "group"} and bool(parts[2].strip())
def _attachment_rules(raw_json: dict[str, Any]) -> Iterable[tuple[str, dict[str, Any]]]:
attachments = raw_json.get("attachments")
if isinstance(attachments, dict):
global_rules = attachments.get("global")
if isinstance(global_rules, list):
for index, rule in enumerate(global_rules):
if isinstance(rule, dict):
yield f"attachments.global[{index}]", rule
entries = raw_json.get("entries")
inline = entries.get("inline") if isinstance(entries, dict) else None
if isinstance(inline, list):
for entry_index, entry in enumerate(inline):
rules = entry.get("attachments") if isinstance(entry, dict) else None
if not isinstance(rules, list):
continue
for rule_index, rule in enumerate(rules):
if isinstance(rule, dict):
yield f"entries.inline[{entry_index}].attachments[{rule_index}]", rule
defaults = entries.get("defaults") if isinstance(entries, dict) else None
default_rules = defaults.get("attachments") if isinstance(defaults, dict) else None
if isinstance(default_rules, list):
for rule_index, rule in enumerate(default_rules):
if isinstance(rule, dict):
yield f"entries.defaults.attachments[{rule_index}]", rule
def _assert_inline_server_sources(
raw_json: dict[str, Any],
*,
source_filename: str | None,
source_base_path: str | None,
) -> None:
if _text(source_filename):
raise CampaignPathSecurityError("source_filename is not accepted for server-managed campaigns")
if _text(source_base_path):
raise CampaignPathSecurityError("source_base_path is not accepted for server-managed campaigns")
template = raw_json.get("template")
template_source = template.get("source") if isinstance(template, dict) else None
path_fields = ("subject_path", "text_path", "html_path")
if isinstance(template_source, dict) and any(_text(template_source.get(key)) for key in path_fields):
raise CampaignPathSecurityError(
"template source paths are not accepted for server-managed campaigns; store template content inline"
)
entries = raw_json.get("entries")
entries_source = entries.get("source") if isinstance(entries, dict) else None
if isinstance(entries_source, dict) and _text(entries_source.get("path")):
raise CampaignPathSecurityError(
"entries.source.path is not accepted for server-managed campaigns; import recipients into the campaign snapshot"
)
def _managed_attachment_base_paths(attachments: dict[str, Any]) -> dict[str, dict[str, Any]]:
assert_logical_relative_path(attachments.get("base_path"), field="attachments.base_path")
raw_base_paths = attachments.get("base_paths")
base_paths = raw_base_paths if isinstance(raw_base_paths, list) else []
managed: dict[str, dict[str, Any]] = {}
for index, item in enumerate(base_paths):
if not isinstance(item, dict):
continue
assert_logical_relative_path(
item.get("path"),
field=f"attachments.base_paths[{index}].path",
)
source_is_managed = is_managed_source(item.get("source"))
base_path_id = _text(item.get("id"))
if base_path_id and source_is_managed:
managed[base_path_id] = item
if item.get("unsent_warning") is True and not source_is_managed:
raise CampaignPathSecurityError(
f"attachments.base_paths[{index}] must use a managed Files source before unsent-file scanning is enabled"
)
return managed
def _assert_managed_attachment_rules(
raw_json: dict[str, Any],
*,
managed_base_paths: dict[str, dict[str, Any]],
managed_files_available: bool,
) -> None:
rules = list(_attachment_rules(raw_json))
if not rules:
return
if not managed_files_available:
raise CampaignPathSecurityError("campaign attachments require the Files module in server mode")
for field, rule in rules:
base_path_id = _text(rule.get("base_path_id"))
if not base_path_id or base_path_id not in managed_base_paths:
raise CampaignPathSecurityError(
f"{field}.base_path_id must select an attachments.base_paths entry backed by managed Files"
)
assert_logical_relative_path(rule.get("base_dir"), field=f"{field}.base_dir")
assert_logical_relative_path(rule.get("file_filter"), field=f"{field}.file_filter")
def assert_server_safe_campaign_paths(
raw_json: dict[str, Any],
*,
source_filename: str | None = None,
source_base_path: str | None = None,
managed_files_available: bool,
) -> None:
"""Reject local-file references at the HTTP/server trust boundary.
File-oriented campaign loading remains available to trusted operator code
through the campaign loader and message builder. Persisted/API campaigns
must use inline templates and recipients plus managed Files attachment
sources, which are materialized into an isolated snapshot before use.
"""
_assert_inline_server_sources(
raw_json,
source_filename=source_filename,
source_base_path=source_base_path,
)
attachments = raw_json.get("attachments")
if not isinstance(attachments, dict):
return
_assert_managed_attachment_rules(
raw_json,
managed_base_paths=_managed_attachment_base_paths(attachments),
managed_files_available=managed_files_available,
)

View File

@@ -32,6 +32,7 @@ from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.sending.execution import create_execution_snapshot from govoplan_campaign.backend.sending.execution import create_execution_snapshot
from govoplan_campaign.backend.campaign.models import CampaignConfig from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.integrations import files_integration, mail_integration from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime" RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots" CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
@@ -92,13 +93,14 @@ def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | No
def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]: def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]:
"""Return a DB/runtime-safe campaign JSON snapshot. """Resolve paths for an explicitly trusted, file-oriented import.
The CLI naturally resolves relative paths against the campaign.json file. The CLI naturally resolves relative paths against the campaign.json file.
Once the campaign is stored in the database, the JSON snapshot lives in Once the campaign is stored in the database, the JSON snapshot lives in
app/mailer/runtime/campaign_snapshots. To keep existing file-based app/mailer/runtime/campaign_snapshots. To keep existing file-based
campaigns working, relative file paths are normalized to absolute paths at campaigns working, relative file paths are normalized to absolute paths at
import time when a source_base_path is known. import time when a source_base_path is known. HTTP/API callers are rejected
by ``assert_server_safe_campaign_paths`` before they can reach this helper.
""" """
base = Path(source_base_path).expanduser().resolve() if source_base_path else None base = Path(source_base_path).expanduser().resolve() if source_base_path else None
data = copy.deepcopy(raw_json) data = copy.deepcopy(raw_json)
@@ -128,6 +130,12 @@ def create_campaign_version_from_json(
source_filename: str | None = None, source_filename: str | None = None,
source_base_path: str | None = None, source_base_path: str | None = None,
) -> tuple[Campaign, CampaignVersion]: ) -> tuple[Campaign, CampaignVersion]:
assert_server_safe_campaign_paths(
raw_json,
source_filename=source_filename,
source_base_path=source_base_path,
managed_files_available=files_integration().available,
)
if source_base_path is None and source_filename: if source_base_path is None and source_filename:
source_path = Path(source_filename).expanduser() source_path = Path(source_filename).expanduser()
source_base_path = str(source_path.parent if source_path.suffix else source_path) source_base_path = str(source_path.parent if source_path.suffix else source_path)
@@ -235,8 +243,12 @@ def load_version_config(session: Session, version_id: str):
campaign = session.get(Campaign, version.campaign_id) campaign = session.get(Campaign, version.campaign_id)
if not campaign: if not campaign:
raise CampaignPersistenceError(f"Campaign not found for version: {version_id}") raise CampaignPersistenceError(f"Campaign not found for version: {version_id}")
path = _write_campaign_snapshot(version)
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
assert_server_safe_campaign_paths(
raw_json,
managed_files_available=files_integration().available,
)
path = _write_campaign_snapshot(version)
return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id) return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id)

View File

@@ -19,13 +19,14 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus, JobSendStatus,
) )
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
from govoplan_campaign.backend.integrations import mail_integration from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.persistence.campaigns import ( from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError, CampaignPersistenceError,
_next_version_number, _next_version_number,
_write_campaign_snapshot, _write_campaign_snapshot,
normalize_campaign_paths, normalize_campaign_paths,
) )
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
class LockedCampaignVersionError(CampaignPersistenceError): class LockedCampaignVersionError(CampaignPersistenceError):
@@ -120,8 +121,8 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
"sent_folder": "auto", "sent_folder": "auto",
}, },
"credentials": { "credentials": {
"smtp": {"username": "", "password": ""}, "smtp": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
"imap": {"username": "", "password": ""}, "imap": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
}, },
}, },
"recipients": { "recipients": {
@@ -371,6 +372,12 @@ def fork_campaign_version_for_edit(
) )
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json) base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
assert_server_safe_campaign_paths(
base_json,
source_filename=source_filename,
source_base_path=source_base_path,
managed_files_available=files_integration().available,
)
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json) runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id) mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
@@ -522,6 +529,13 @@ def update_campaign_version(
source_base_path: str | None = None, source_base_path: str | None = None,
autosave: bool = False, autosave: bool = False,
) -> CampaignVersion: ) -> CampaignVersion:
if raw_json is not None or source_filename is not None or source_base_path is not None:
assert_server_safe_campaign_paths(
raw_json if raw_json is not None else {},
source_filename=source_filename,
source_base_path=source_base_path,
managed_files_available=files_integration().available,
)
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = _require_campaign(session, campaign_id) campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="edit") ensure_current_working_version(campaign, version, action="edit")

View File

@@ -107,6 +107,7 @@ from govoplan_campaign.backend.persistence.campaigns import (
validate_campaign_version, validate_campaign_version,
) )
from govoplan_campaign.backend.integrations import files_integration from govoplan_campaign.backend.integrations import files_integration
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
from govoplan_campaign.backend.campaign.loader import load_campaign_json from govoplan_campaign.backend.campaign.loader import load_campaign_json
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
@@ -373,6 +374,8 @@ def _campaign_version_detail_response(
return CampaignVersionDetailResponse.model_validate(version) return CampaignVersionDetailResponse.model_validate(version)
except LockedCampaignVersionError as exc: except LockedCampaignVersionError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPathSecurityError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except CampaignPersistenceError as exc: except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
@@ -1487,6 +1490,8 @@ def fork_version_for_edit(
) )
except LockedCampaignVersionError as exc: except LockedCampaignVersionError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPathSecurityError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except CampaignPersistenceError as exc: except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -3203,6 +3208,7 @@ def _attachment_preview_for_version(
include_unlinked_candidates: bool, include_unlinked_candidates: bool,
) -> CampaignAttachmentPreviewResponse: ) -> CampaignAttachmentPreviewResponse:
files = files_integration() files = files_integration()
assert_server_safe_campaign_paths(raw, managed_files_available=files.available)
with files.prepared_campaign_snapshot( with files.prepared_campaign_snapshot(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
@@ -3352,15 +3358,18 @@ def preview_campaign_attachments(
raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json
raw = raw if isinstance(raw, dict) else {} raw = raw if isinstance(raw, dict) else {}
_require_mail_profile_use_if_needed(principal, raw) _require_mail_profile_use_if_needed(principal, raw)
return _attachment_preview_for_version( try:
session, return _attachment_preview_for_version(
principal, session,
campaign=campaign, principal,
version=version, campaign=campaign,
raw=raw, version=version,
include_unmatched=payload.include_unmatched, raw=raw,
include_unlinked_candidates=payload.include_unlinked_candidates, include_unmatched=payload.include_unmatched,
) include_unlinked_candidates=payload.include_unlinked_candidates,
)
except CampaignPathSecurityError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/{campaign_id}/versions/{version_id}/attachments/link-matches", response_model=CampaignAttachmentLinkMatchesResponse) @router.post("/{campaign_id}/versions/{version_id}/attachments/link-matches", response_model=CampaignAttachmentLinkMatchesResponse)

View File

@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
from govoplan_campaign.backend.integrations import MailProfileError, mail_integration from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
SNAPSHOT_VERSION = "3" SNAPSHOT_VERSION = "3"
@@ -250,6 +251,14 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
without a manual data migration. without a manual data migration.
""" """
try:
assert_server_safe_campaign_paths(
version.raw_json if isinstance(version.raw_json, dict) else {},
managed_files_available=files_integration().available,
)
except CampaignPathSecurityError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
if isinstance(version.execution_snapshot, dict): if isinstance(version.execution_snapshot, dict):
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot) snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
expected = snapshot_hash(snapshot.model_dump(mode="json")) expected = snapshot_hash(snapshot.model_dump(mode="json"))

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()