getgrav/grav · error · RuntimeException

Backup location not allowed (outside site root): {$backup_ro

Error message

Backup location not allowed (outside site root): {$backup_root}

What it means

This is a deliberate security control (GHSA-fch7-cpv4-w7hg): after realpath() canonicalization, the backup root must be GRAV_ROOT itself or a directory beneath it, checked with an exact-prefix comparison including the directory separator. Any profile whose root resolves outside the site root — including via symlink, since realpath() resolves it — is rejected with RuntimeException 'Backup location not allowed (outside site root)'. The old deny-list approach let non-blocklisted external dirs (/opt, /mnt, /srv) be archived; this positive containment closes that.

Source

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

        // 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 ?? ''),
            'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''),
        ];

        $archiver = Archiver::create('zip');
        $archiver->setArchive($destination)->setOptions($options)->compress($backup_root, $status)->addEmptyFolders($options['exclude_paths'], $status);

        $status && $status([
            'type' => 'message',
            'message' => 'Done...',
        ]);

        $status && $status([

View on GitHub (pinned to 6040efed04)

Solutions

  1. Change the profile root to '/' or a directory inside the site (e.g. /user, /pages via stream 'user://pages') — this is the intended usage
  2. If data must live outside the site, move it inside GRAV_ROOT (bind-mount or migrate) so the archive stays contained
  3. For backups of external data, use external tooling (rsync/restic/cron tar) outside Grav instead of weakening the guard
  4. Do not try to bypass with symlinks — realpath() resolves them and the throw is intentional security behavior

Example fix

# user/config/backups.yaml — before
profiles:
  - name: 'External Data'
    root: '/mnt/backups-data'   # outside GRAV_ROOT, rejected

# after
profiles:
  - name: 'Site Backup'
    root: '/'                   # whole site, passes containment
Defensive patterns

Strategy: validation

Validate before calling

$realRoot = realpath($resolvedRoot);
$realGrav = realpath(GRAV_ROOT);
$contained = $realRoot !== false
    && ($realRoot === $realGrav || str_starts_with($realRoot, $realGrav . DIRECTORY_SEPARATOR));
if (!$contained) {
    throw new RuntimeException('Refusing to back up a root outside GRAV_ROOT — adjust the profile');
}

Try / catch

try {
    Backups::backup($id);
} catch (RuntimeException $e) {
    if (str_contains($e->getMessage(), 'outside site root')) {
        // security containment (GHSA-fch7-cpv4-w7hg) — fix the profile, do not retry
    }
}

Prevention

When it happens

Trigger: A profile with an absolute root like /var/backups or /home/user/data; a root that is a symlink inside GRAV_ROOT pointing to a directory outside it (realpath() resolves the target, so it fails containment); a sibling-directory prefix trick (/var/www/site-evil next to /var/www/site) which the trailing-separator comparison defeats; upgrading Grav to a patched release while keeping a pre-existing external root configured.

Common situations: Site updated to a version containing the path-traversal fix and previously-working external backup roots now throw; admins who deliberately backed up an external mount; profiles copied from documentation that assumed external roots were allowed.

Related errors


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