T09 · Insecure Skill Coding Practices
- Location
scripts/log_expense.py:13- Finding
Path Traversal Through Unvalidated Month and Date Parameters
- Content
View full analysis
Vulnerability Details
File Location:
scripts/log_expense.py:13-21, with unsafe input propagated atscripts/log_expense.py:42-49andscripts/log_expense.py:131-132
Vulnerability Type: Path traversal and insufficient input validation
Risk Level: MediumVulnerable Code
python def get_expense_file(year_month: str, workspace_path: str = None) -> Path: """Get the path to the expense file for a given month.""" if workspace_path is None: workspace_path = Path.home() / ".openclaw" / "workspace" else: workspace_path = Path(workspace_path) expenses_dir = workspace_path / "expenses" expenses_dir.mkdir(exist_ok=True) return expenses_dir / f"{year_month}.md"Unsafe values reach this function through both supported commands:
python def log_expense(amount: float, category: str, description: str = "", tags: str = "", date: str = None, workspace_path: str = None): """Log an expense entry.""" if date is None: date = datetime.now().strftime("%Y-%m-%d") year_month = date[:7] # YYYY-MM file_path = get_expense_file(year_month, workspace_path)python elif args.command == "summary": year_month = args.year_month or datetime.now().strftime("%Y-%m") summary = get_monthly_summary(year_month, args.workspace)Technical Analysis
The application documents
YYYY-MMandYYYY-MM-DDformats but does not enforce them. Theyear_monthvalue is concatenated into a filesystem path without rejecting path separators or..components.pathlibtherefore permits the resulting path to resolve outside the intendedexpensesdirectory.The
summarycommand passes its unrestricted positional value directly intoget_expense_file(). Thelogcommand derives the filename from the first seven characters of the supplied date, but those characters can still contain a travers ...[truncated 2065 chars]- Remediation
View remediation
Remediation Suggestions
-
Strictly validate month values before using them in paths:
python import re YEAR_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") def validate_year_month(value: str) -> str: if not YEAR_MONTH_RE.fullmatch(value): raise ValueError("Month must use YYYY-MM format") return value -
Parse dates rather than slicing strings:
python parsed_date = datetime.strptime(date, "%Y-%m-%d") year_month = parsed_date.strftime("%Y-%m") date = parsed_date.strftime("%Y-%m-%d") -
Resolve the expense directory and candidate path, then verify containment:
python expenses_dir = (workspace_path / "expenses").resolve() expenses_dir.mkdir(parents=True, exist_ok=True) candidate = (expenses_dir / f"{year_month}.md").resolve() if candidate.parent != expenses_dir: raise ValueError("Expense file must remain inside the expenses directory") -
Reject all path separators, traversal components, null characters, and unexpected characters before constructing a filename.
-
Add tests covering values such as
../../notes, absolute paths, mixed separators, malformed dates, invalid months, and traversal sequences contained in the first seven date characters.
-
