Block local file paths at Campaign server boundaries
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
170
src/govoplan_campaign/backend/path_security.py
Normal file
170
src/govoplan_campaign/backend/path_security.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,6 +3358,7 @@ 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)
|
||||||
|
try:
|
||||||
return _attachment_preview_for_version(
|
return _attachment_preview_for_version(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -3361,6 +3368,8 @@ def preview_campaign_attachments(
|
|||||||
include_unmatched=payload.include_unmatched,
|
include_unmatched=payload.include_unmatched,
|
||||||
include_unlinked_candidates=payload.include_unlinked_candidates,
|
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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
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