Block local file paths at Campaign server boundaries
This commit is contained in:
@@ -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.template_values import build_template_values
|
||||
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):
|
||||
@@ -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())
|
||||
|
||||
|
||||
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:
|
||||
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
|
||||
severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
|
||||
@@ -429,13 +451,50 @@ def _resolve_one_config(
|
||||
attachment_config=config,
|
||||
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)
|
||||
|
||||
issues: list[AttachmentIssue] = []
|
||||
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
|
||||
behavior = _missing_behavior(campaign_config, config)
|
||||
issues.append(_issue_for_missing(config, behavior))
|
||||
|
||||
Reference in New Issue
Block a user