getgrav/grav · error · RuntimeException

Invalid backup location: {$backup_root}

Error message

Invalid backup location: {$backup_root}

What it means

Before archiving, Backups::backup() canonicalizes both the backup root and GRAV_ROOT with realpath(). If either call returns false — realpath fails on nonexistent paths, broken symlink loops, or paths outside open_basedir restrictions — it throws RuntimeException 'Invalid backup location: ...'. This is the sanity stage between the existence check and the containment check.

Source

Thrown at system/src/Grav/Common/Backup/Backups.php:264

        $max_execution_time = ini_set('max_execution_time', '600');
        $backup_root = $backup->root;

        if ($locator->isStream($backup_root)) {
            $backup_root = $locator->findResource($backup_root);
        } else {
            $backup_root = rtrim(GRAV_ROOT . $backup_root, DS) ?: DS;
        }

        if (!$backup_root || !file_exists($backup_root)) {
            throw new RuntimeException("Backup location: {$backup_root} does not exist...");
        }

        // Security: Resolve real path and ensure it's within GRAV_ROOT to prevent path traversal
        $realBackupRoot = realpath($backup_root);
        $realGravRoot = realpath(GRAV_ROOT);

        if ($realBackupRoot === false || $realGravRoot === false) {
            throw new RuntimeException("Invalid backup location: {$backup_root}");
        }

        // Positive containment (GHSA-fch7-cpv4-w7hg): the resolved backup root must
        // BE GRAV_ROOT or a directory beneath it. The previous deny-list only rejected
        // a fixed set of system paths, so a non-blocklisted external directory (e.g.
        // /opt, /mnt, /srv) still fell through and had its contents archived. Comparing
        // against GRAV_ROOT with a trailing separator also prevents a sibling directory
        // (e.g. `/var/www/site-evil` next to `/var/www/site`) from matching by prefix.
        $isWithinGravRoot = $realBackupRoot === $realGravRoot
            || strpos($realBackupRoot, $realGravRoot . DIRECTORY_SEPARATOR) === 0;
        if (!$isWithinGravRoot) {
            throw new RuntimeException("Backup location not allowed (outside site root): {$backup_root}");
        }

        $backup_root = $realBackupRoot;

        $options = [
            'exclude_files' => static::convertExclude($backup->exclude_files ?? ''),

View on GitHub (pinned to 6040efed04)

Solutions

  1. Add the site root (and backup destination) to open_basedir, or disable the restriction if the environment allows
  2. Replace symlink chains in the backup root with the real directory path
  3. Run var_dump(realpath(GRAV_ROOT), realpath($root)) in a scratch script to identify which of the two fails, then fix that path
  4. Point the profile root at a plain directory inside the site instead of a symlinked one

Example fix

// before — profile root passes through a broken symlink
$root = '/shared-site'; // /shared-site -> /mnt/old (missing target)

// after
$root = '/user'; // real directory inside GRAV_ROOT; realpath() succeeds
Defensive patterns

Strategy: validation

Validate before calling

$realRoot = realpath($resolvedRoot);
$realGrav = realpath(GRAV_ROOT);
if ($realRoot === false || $realGrav === false) {
    // realpath failed: open_basedir, broken symlink chain, or vanished path
    throw new RuntimeException("Cannot canonicalize {$resolvedRoot} — check open_basedir and symlinks");
}

Prevention

When it happens

Trigger: The backup root contains a symlink whose target is a broken symlink or a loop; PHP open_basedir excludes the resolved path so realpath() returns false; the path was deleted between the file_exists() check and the realpath() call (race); GRAV_ROOT itself is unreachable under the current open_basedir (common in hardened shared-hosting PHP-FPM pools).

Common situations: Shared hosting with restrictive open_basedir that does not include the site root; symlinked docroots (e.g. /www -> /var/www) where a link in the chain is broken after server reconfiguration; security hardening applied to php.ini after backups previously worked.

Related errors


AI-assisted analysis of getgrav/grav@6040efed04 (2026-08-17). Data as JSON: /api/errors/922697521e5e34c4. Report an issue: GitHub.