unclecode/crawl4ai · error · ValueError
Refusing to write download through symlink: {download_path}
Error message
Refusing to write download through symlink: {download_path} What it means
A security guard in the download handler: before Playwright's download.save_as() writes the file, the code checks whether the resolved target path is a symlink and refuses to write through it. This prevents an attacker-controlled downloads directory (or a race-planted symlink) from redirecting the write to an arbitrary file outside the downloads root (symlink attack / TOCTOU mitigation).
Source
Thrown at crawl4ai/async_crawler_strategy.py:1481
2. Get the download path.
3. Log the download.
4. Start the download.
5. Save the downloaded file.
6. Log the completion.
Args:
download (Download): The Playwright download object
Returns:
None
"""
try:
suggested_filename = download.suggested_filename
download_path = _safe_download_filepath(self.browser_config.downloads_path, suggested_filename)
# Playwright's save_as performs the write itself (no O_NOFOLLOW
# hook), so reject a symlink planted at the target before writing.
if os.path.islink(download_path):
raise ValueError(f"Refusing to write download through symlink: {download_path}")
self.logger.info(
message="Downloading {filename} to {path}",
tag="FETCH",
params={"filename": suggested_filename, "path": download_path},
)
start_time = time.perf_counter()
await download.save_as(download_path)
end_time = time.perf_counter()
self._downloaded_files.append(download_path)
self.logger.success(
message="Downloaded {filename} successfully",
tag="COMPLETE",
params={
"filename": suggested_filename,
"path": download_path,View on GitHub (pinned to 7e80152142)
Solutions
- Inspect the offending path (ls -l) and remove or replace the symlink with a regular file/dir.
- Use a dedicated, non-shared downloads directory with restrictive permissions (0700) that only the crawler writes to.
- Clear or rotate the downloads directory between runs if it may be contaminated.
- Treat repeated occurrences as a potential local attack or compromised sibling process and audit the directory's provenance.
Example fix
// before
# symlink exists: downloads/report.pdf -> /etc/cron.d/evil
# crawler download raises ValueError: Refusing to write download through symlink
// after
import os
p = os.path.join(downloads_path, suggested_name)
if os.path.islink(p):
os.unlink(p) # remove attacker-planted symlink
await crawler.arun(url, config) Defensive patterns
Strategy: validation
Validate before calling
import os
def download_target_safe(downloads_path: str, name: str) -> bool:
p = os.path.join(os.path.realpath(downloads_path), os.path.basename(name))
return not os.path.islink(p) Try / catch
try:
await crawler.arun(url, config=cfg)
except ValueError as e:
if "symlink" in str(e):
os.unlink(extract_path_from_message(str(e))) # remove and optionally retry Prevention
- Use a dedicated 0700 downloads directory
- Clear symlinks from the downloads dir before runs
- Never extract untrusted archives into the downloads dir
When it happens
Trigger: A file already exists at <downloads_path>/<suggested_filename> and is a symlink (possibly planted by another process or a previous malicious download). The server-controlled suggested_filename normally cannot itself contain '/', so the symlink must pre-exist at the target location.
Common situations: Shared or world-writable downloads directories where other users/processes created symlinks; leftover symlinks from prior runs or from extracting untrusted archives into the downloads dir; security scanners testing the crawler's download path.
Related errors
- Unsafe download filename rejected: {filename!r}
- Invalid wait_for parameter: '{wait_for}'. It should be eithe
- LLMConfig.api_token may not reference an environment variabl
- Local file not found: {local_file_path}
- Local file not found: {path}
AI-assisted analysis of unclecode/crawl4ai@7e80152142 (2026-08-14).
Data as JSON: /api/errors/07e7395e70834f57.
Report an issue: GitHub.