{"record":{"id":"8a67a2e0ccff8483","repo":"home-assistant/core","slug":"refusing-to-write-outside-self-backup-dir-can","errorCode":null,"errorMessage":"Refusing to write outside {self._backup_dir}: {candidate}","messagePattern":"Refusing to write outside (.+?): (.+?)","errorType":"exception","errorClass":"InvalidBackupFilename","httpStatus":null,"severity":"error","filePath":"homeassistant/components/backup/backup.py","lineNumber":141,"sourceCode":"    @override\n    def get_backup_path(self, backup_id: str) -> Path:\n        \"\"\"Return the local path to an existing backup.\n\n        Raises BackupAgentError if the backup does not exist.\n        \"\"\"\n        try:\n            return self._backups[backup_id][1]\n        except KeyError as err:\n            raise BackupNotFound(f\"Backup {backup_id} does not exist\") from err\n\n    @override\n    def get_new_backup_path(self, backup: AgentBackup) -> Path:\n        \"\"\"Return the local path to a new backup.\"\"\"\n        candidate = self._backup_dir / suggested_filename(backup)\n        # suggested_filename does not strip separators; refuse paths that would\n        # land outside the backup directory.\n        if candidate.parent != self._backup_dir:\n            raise InvalidBackupFilename(\n                f\"Refusing to write outside {self._backup_dir}: {candidate}\"\n            )\n        return candidate\n\n    @override\n    async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:\n        \"\"\"Delete a backup file.\"\"\"\n        if not self._loaded_backups:\n            await self._load_backups()\n\n        backup_path = self.get_backup_path(backup_id)\n        await self._hass.async_add_executor_job(backup_path.unlink, True)\n        LOGGER.debug(\"Deleted backup located at %s\", backup_path)\n        self._backups.pop(backup_id)\n","sourceCodeStart":123,"sourceCodeEnd":156,"githubUrl":"https://github.com/home-assistant/core/blob/58a3fdb3ea0538617f0a07efcfba6294de64fd59/homeassistant/components/backup/backup.py#L123-L156","documentation":"Raised by LocalBackupAgent.get_new_backup_path when the candidate path built from suggested_filename(backup) would not land directly inside the configured backup directory (candidate.parent != self._backup_dir). Because suggested_filename does not strip path separators, a crafted backup name containing '/' or '..' could otherwise escape the backup directory; this guard blocks path traversal.","triggerScenarios":"Creating/uploading a backup whose name (fed through suggested_filename) contains a path separator — e.g., name=\"../../etc/evil\" or \"nested/dir/backup\" — when the local agent computes the write path via get_new_backup_path. Also triggered by a legitimately odd name such as a trailing '/' or one that resolves differently after Path normalization.","commonSituations":"API clients or scripts creating backups with arbitrary, unsanitized names; malicious upload attempts against the backup upload endpoint; backups created on another system whose names contain slashes.","solutions":["Sanitize the backup name before creating it: strip '/', '\\\\', '..' and other path characters from name and slugify it.","If a filename with directories is genuinely needed, place files inside the backup dir manually rather than relying on the agent.","Verify the backup's name field, not just its ID, when this error appears during receive/create flows."],"exampleFix":"# before\nawait client.async_create_backup(name=\"../../etc/pwned\")\n\n# after\nimport re\nsafe_name = re.sub(r\"[^\\w\\-. ]\", \"_\", \"../../etc/pwned\")  # '.._.._etc_pwned'\nawait client.async_create_backup(name=safe_name)","handlingStrategy":"validation","validationCode":"import re\n\ndef safe_backup_name(name: str) -> str:\n    return re.sub(r\"[^\\w\\-. ]\", \"_\", name).strip(\" .\") or \"backup\"\n\ncandidate = backup_dir / safe_backup_name(name)\nassert candidate.parent == backup_dir","typeGuard":"from pathlib import Path\n\ndef is_safe_backup_name(name: str, backup_dir: Path) -> bool:\n    return (backup_dir / name).parent == backup_dir and name not in (\"\", \".\", \"..\")","tryCatchPattern":"from homeassistant.components.backup.manager import InvalidBackupFilename\n\ntry:\n    path = local_agent.get_new_backup_path(backup)\nexcept InvalidBackupFilename as err:\n    backup.name = safe_backup_name(backup.name)\n    path = local_agent.get_new_backup_path(backup)","preventionTips":["Slugify user-supplied backup names before creating backups.","Never build backup names from file paths or external input verbatim.","Assert candidate.parent == backup_dir client-side as a cheap invariant."],"tags":["backup","security","path-traversal","validation"],"backgroundTag":null,"analyzedSha":"58a3fdb3ea0538617f0a07efcfba6294de64fd59","analyzedAt":"2026-08-14T20:54:38.818Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}