Block local file paths at Campaign server boundaries
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -19,13 +19,14 @@ from govoplan_campaign.backend.db.models import (
|
||||
JobSendStatus,
|
||||
)
|
||||
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 (
|
||||
CampaignPersistenceError,
|
||||
_next_version_number,
|
||||
_write_campaign_snapshot,
|
||||
normalize_campaign_paths,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
|
||||
|
||||
class LockedCampaignVersionError(CampaignPersistenceError):
|
||||
@@ -120,8 +121,8 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
||||
"sent_folder": "auto",
|
||||
},
|
||||
"credentials": {
|
||||
"smtp": {"username": "", "password": ""},
|
||||
"imap": {"username": "", "password": ""},
|
||||
"smtp": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
|
||||
"imap": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
|
||||
},
|
||||
},
|
||||
"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)
|
||||
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)
|
||||
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,
|
||||
autosave: bool = False,
|
||||
) -> 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)
|
||||
campaign = _require_campaign(session, campaign_id)
|
||||
ensure_current_working_version(campaign, version, action="edit")
|
||||
|
||||
Reference in New Issue
Block a user