{"record":{"id":"ea90ccb2d40e5b78","repo":"invoke-ai/InvokeAI","slug":"cannot-derive-a-safe-filename-for-url-from-fil","errorCode":null,"errorMessage":"Cannot derive a safe filename for {url} from '{file_name}'","messagePattern":"Cannot derive a safe filename for (.+?) from '(.+?)'","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"invokeai/app/services/download/download_default.py","lineNumber":512,"sourceCode":"\n        job.content_type = resp.headers.get(\"Content-Type\")\n        job.etag = resp.headers.get(\"ETag\") or job.etag\n        job.last_modified = resp.headers.get(\"Last-Modified\") or job.last_modified\n        content_length = int(resp.headers.get(\"content-length\", 0))\n\n        if job.dest.is_dir():\n            parsed_url = urlparse(str(url))\n            file_name = os.path.basename(parsed_url.path)  # default is to use the last bit of the URL\n\n            if match := re.search('filename=\"(.+)\"', resp.headers.get(\"Content-Disposition\", \"\")):\n                remote_name = match.group(1)\n                if self._validate_filename(job.dest.as_posix(), remote_name):\n                    file_name = remote_name\n\n            # The URL path is attacker-influenced too -- a final segment of \"..\" would\n            # otherwise put download_path one level above dest.\n            if not self._validate_filename(job.dest.as_posix(), file_name):\n                raise ValueError(f\"Cannot derive a safe filename for {url} from '{file_name}'\")\n\n            job.download_path = job.dest / file_name\n\n        else:\n            job.dest.parent.mkdir(parents=True, exist_ok=True)\n            job.download_path = job.dest\n\n        assert job.download_path\n\n        in_progress_path = self._in_progress_path(job.download_path)\n\n        if resume_from > 0 and resp.status_code == 200:\n            # Server ignored Range. Restart download from scratch.\n            job.resume_required = True\n            job.resume_message = \"Resume refused by server. Restart required.\"\n            job.pause()\n            raise DownloadJobCancelledException(\"Resume refused by server. Restart required.\")\n","sourceCodeStart":494,"sourceCodeEnd":530,"githubUrl":"https://github.com/invoke-ai/InvokeAI/blob/0b6a024f2ff6a86bfb953dcdb9cc504ef7397a06/invokeai/app/services/download/download_default.py#L494-L530","documentation":"When the destination is a directory, the library derives a filename from the URL's last path segment (or the Content-Disposition header). Before using it, _validate_filename checks the name won't escape the destination (path traversal, '..', absolute paths, unsafe characters). If validation fails, ValueError is raised because no safe filename could be derived from the attacker-influenced URL.","triggerScenarios":"Downloading to a directory (job.dest is_dir()) where the URL's final path segment is '..', empty, an absolute path, or the Content-Disposition filename fails the same validation — i.e. any URL like https://host/downloads/ or https://host/path/..%2F..","commonSituations":"Hand-written model URLs ending in '/' so basename() yields '' or '..'; redirects to URLs with hostile paths; batch-import lists with malformed URLs; downloading to a directory dest with a query-only URL.","solutions":["Fix the source URL so its final path segment is a real filename (e.g. .../model.safetensors instead of .../model.safetensors/ or a bare directory).","Download to an explicit full file path instead of a directory so no filename derivation from the URL is needed.","Sanitize the URL before submitting (strip trailing slashes and query strings, ensure a non-traversal basename).","If the Content-Disposition header is the culprit, the host is sending hostile headers — download from a trusted mirror."],"exampleFix":"// before: URL ending in '/' — basename is empty/unsafe\nqueue.submit(HttpSource(url='https://example.com/models/v2/'), Path('/models'))\n\n// after: full file path, or a proper file URL\nqueue.submit(HttpSource(url='https://example.com/models/v2/model.safetensors'), Path('/models'))","handlingStrategy":"validation","validationCode":"from urllib.parse import urlparse\nimport os\ndef url_has_safe_basename(url: str) -> bool:\n    name = os.path.basename(urlparse(url).path)\n    return bool(name) and name not in ('.', '..') and not name.startswith('/') and '\\\\' not in name and '\\x00' not in name","typeGuard":"def is_safe_filename(name: str) -> bool:\n    return bool(name) and name not in ('.', '..') and '/' not in name and '\\\\' not in name and not name.startswith('.')","tryCatchPattern":"try:\n    queue.submit(HttpSource(url=url), dest_dir)\nexcept ValueError as e:\n    if 'Cannot derive a safe filename' in str(e):\n        fixed_url = url.rstrip('/')\n        assert url_has_safe_basename(fixed_url), f'bad url: {url}'\n        queue.submit(HttpSource(url=fixed_url), dest_dir)","preventionTips":["Always end source URLs with a real filename — no trailing slash or bare directory links.","When possible download to an explicit file path instead of a directory dest.","Sanitize batch-import URL lists automatically (strip query, reject '..' segments) before submission."],"tags":["validation","path-traversal","security","download"],"backgroundTag":"unsafe-filename-rejected","analyzedSha":"0b6a024f2ff6a86bfb953dcdb9cc504ef7397a06","analyzedAt":"2026-08-29T04:46:49.967Z","schemaVersion":2},"datasetVersion":"2026-08-29T07:17:48.351Z"}