Source code for pain001.security.path_validator
# Copyright (C) 2023-2026 Pain001. All rights reserved.
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
# Licensed under either of the Apache License, Version 2.0 or the MIT
# License, at your option. You may not use this file except in
# compliance with one of those licences. Copies are provided in
# LICENSE-APACHE and LICENSE-MIT.
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the Licences is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the applicable Licence for the specific language
# governing permissions and limitations.
"""Path validation and sanitization to prevent security vulnerabilities."""
import os
import re
import tempfile
from pathlib import Path
[docs]
class PathValidationError(ValueError):
"""Raised when path validation fails."""
[docs]
class SecurityError(PermissionError):
"""Raised when a security boundary is violated."""
def _resolve_within_allowed_bases(
untrusted_path: str | Path,
base_dir: str | Path | None = None,
) -> str:
"""Resolve and validate that a path is within allowed directories.
This is the core validation logic, separated so that its return value
is marked as taint-free by the CodeQL neutralModel extension. The
``validate_path`` wrapper adds the optional existence check on the
*already-clean* return value, keeping ``os.path.exists`` outside the
tainted data-flow graph.
Args:
untrusted_path: User-provided path (potentially malicious).
base_dir: Optional base directory to constrain resolution. When
omitted, the cwd, temp directories, and the installed
package directory are allowed.
Returns:
Resolved absolute path string proven to be within allowed bases.
Raises:
PathValidationError: If path is empty or contains traversal.
SecurityError: If path escapes all allowed directories.
"""
if not untrusted_path:
raise PathValidationError("Path cannot be empty")
path_str = str(untrusted_path)
if ".." in path_str:
raise PathValidationError("Path contains invalid traversal sequences")
normalized_str = os.path.normpath(path_str)
try:
resolved_str = os.path.realpath(normalized_str)
except (RuntimeError, OSError) as e:
raise PathValidationError( # pragma: no cover - realpath fails only on OS errors
f"Invalid path: {e}"
) from e
if base_dir is not None:
base_str = os.path.realpath(str(base_dir))
allowed_bases = [base_str]
else:
# The package's own directory is allowed so bundled templates and
# schemas resolve when pain001 is installed outside the cwd
# (e.g. site-packages).
package_dir = os.path.realpath(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
# Temp directories are allowed for callers (and tests) that
# write output to tempfile-created paths; /var/tmp is listed
# separately because gettempdir() may resolve elsewhere
# (e.g. /var/folders on macOS).
allowed_bases = [
os.path.realpath(os.getcwd()),
os.path.realpath(tempfile.gettempdir()),
os.path.realpath(os.path.join(os.path.sep, "var", "tmp")),
package_dir,
]
for base in allowed_bases:
if resolved_str == base or resolved_str.startswith(base + os.sep):
return resolved_str
if base_dir:
raise SecurityError(
f"Path '{resolved_str}' escapes base directory '{base_dir}'."
)
raise SecurityError(
f"Path '{resolved_str}' is outside allowed directories."
)
[docs]
def validate_path(
untrusted_path: str | Path,
must_exist: bool = False,
base_dir: str | Path | None = None,
) -> str:
"""Validate and resolve path to prevent directory traversal attacks.
Args:
untrusted_path: User-provided path (potentially malicious).
must_exist: If True, raise error if path doesn't exist.
base_dir: Optional base directory to constrain resolution.
Returns:
Resolved absolute path as string (CodeQL taint-tracking compliant).
Raises:
FileNotFoundError: If must_exist=True and the path doesn't
exist. PathValidationError (traversal attempts) and
SecurityError (path escapes allowed directories) from the
underlying resolution propagate unchanged.
"""
# Core validation — return value is taint-free per neutralModel.
safe_path = _resolve_within_allowed_bases(untrusted_path, base_dir)
if must_exist and not os.path.exists(safe_path):
raise FileNotFoundError(f"Path does not exist: {safe_path}")
return safe_path
[docs]
def sanitize_for_log(user_input: str, max_length: int = 100) -> str:
"""Sanitize user input for safe logging (prevent log injection).
Args:
user_input: User-provided string (potentially malicious).
max_length: Maximum length to include in log.
Returns:
Sanitized string safe for logging.
"""
if not user_input:
return ""
# Remove newlines, carriage returns, and other control characters
sanitized = re.sub(r"[\r\n\t\x00-\x1f\x7f-\x9f]", "", user_input)
# Truncate if too long
if len(sanitized) > max_length:
sanitized = sanitized[:max_length] + "..."
return sanitized