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

@@ -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.campaign.models import CampaignConfig
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"
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]:
"""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.
Once the campaign is stored in the database, the JSON snapshot lives in
app/mailer/runtime/campaign_snapshots. To keep existing file-based
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
data = copy.deepcopy(raw_json)
@@ -128,6 +130,12 @@ def create_campaign_version_from_json(
source_filename: str | None = None,
source_base_path: str | None = None,
) -> 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:
source_path = Path(source_filename).expanduser()
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)
if not campaign:
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 {}
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)