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
- Change the profile root to '/' or a directory inside the site (e.g. /user, /pages via stream 'user://pages') — this is the intended usage
- If data must live outside the site, move it inside GRAV_ROOT (bind-mount or migrate) so the archive stays contained
- For backups of external data, use external tooling (rsync/restic/cron tar) outside Grav instead of weakening the guard
- 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
- Configure backup roots only inside the site: '/' or subdirectories/streams like 'user://'
- Remember symlinks do not smuggle external data in — realpath() resolves them and containment still fails
- Use OS-level tooling for off-site data; Grav backups are scoped to the installation by design
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
- ZipArchiver: refused to extract {archive_file}. Entry "{name
- No backups defined...
- Backup location: {$backup_root} does not exist...
- Invalid backup location: {$backup_root}
- ZipArchiver: refused to extract {archive_file}. Archive exce
AI-assisted analysis of getgrav/grav@6040efed04 (2026-08-17).
Data as JSON: /api/errors/4375ce1c39f3e7fb.
Report an issue: GitHub.