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

  1. Inspect the offending path (ls -l) and remove or replace the symlink with a regular file/dir.
  2. Use a dedicated, non-shared downloads directory with restrictive permissions (0700) that only the crawler writes to.
  3. Clear or rotate the downloads directory between runs if it may be contaminated.
  4. 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

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


AI-assisted analysis of unclecode/crawl4ai@7e80152142 (2026-08-14). Data as JSON: /api/errors/07e7395e70834f57. Report an issue: GitHub.