{"record":{"id":"4375ce1c39f3e7fb","repo":"getgrav/grav","slug":"backup-location-not-allowed-outside-site-root","errorCode":null,"errorMessage":"Backup location not allowed (outside site root): {$backup_root}","messagePattern":"Backup location not allowed \\(outside site root\\): (.+?)","errorType":"exception","errorClass":"RuntimeException","httpStatus":null,"severity":"error","filePath":"system/src/Grav/Common/Backup/Backups.php","lineNumber":276,"sourceCode":"\n        // Security: Resolve real path and ensure it's within GRAV_ROOT to prevent path traversal\n        $realBackupRoot = realpath($backup_root);\n        $realGravRoot = realpath(GRAV_ROOT);\n\n        if ($realBackupRoot === false || $realGravRoot === false) {\n            throw new RuntimeException(\"Invalid backup location: {$backup_root}\");\n        }\n\n        // Positive containment (GHSA-fch7-cpv4-w7hg): the resolved backup root must\n        // BE GRAV_ROOT or a directory beneath it. The previous deny-list only rejected\n        // a fixed set of system paths, so a non-blocklisted external directory (e.g.\n        // /opt, /mnt, /srv) still fell through and had its contents archived. Comparing\n        // against GRAV_ROOT with a trailing separator also prevents a sibling directory\n        // (e.g. `/var/www/site-evil` next to `/var/www/site`) from matching by prefix.\n        $isWithinGravRoot = $realBackupRoot === $realGravRoot\n            || strpos($realBackupRoot, $realGravRoot . DIRECTORY_SEPARATOR) === 0;\n        if (!$isWithinGravRoot) {\n            throw new RuntimeException(\"Backup location not allowed (outside site root): {$backup_root}\");\n        }\n\n        $backup_root = $realBackupRoot;\n\n        $options = [\n            'exclude_files' => static::convertExclude($backup->exclude_files ?? ''),\n            'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''),\n        ];\n\n        $archiver = Archiver::create('zip');\n        $archiver->setArchive($destination)->setOptions($options)->compress($backup_root, $status)->addEmptyFolders($options['exclude_paths'], $status);\n\n        $status && $status([\n            'type' => 'message',\n            'message' => 'Done...',\n        ]);\n\n        $status && $status([","sourceCodeStart":258,"sourceCodeEnd":294,"githubUrl":"https://github.com/getgrav/grav/blob/6040efed04efa69b8209448ed81308e7c24147c2/system/src/Grav/Common/Backup/Backups.php#L258-L294","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"# user/config/backups.yaml — before\nprofiles:\n  - name: 'External Data'\n    root: '/mnt/backups-data'   # outside GRAV_ROOT, rejected\n\n# after\nprofiles:\n  - name: 'Site Backup'\n    root: '/'                   # whole site, passes containment","handlingStrategy":"validation","validationCode":"$realRoot = realpath($resolvedRoot);\n$realGrav = realpath(GRAV_ROOT);\n$contained = $realRoot !== false\n    && ($realRoot === $realGrav || str_starts_with($realRoot, $realGrav . DIRECTORY_SEPARATOR));\nif (!$contained) {\n    throw new RuntimeException('Refusing to back up a root outside GRAV_ROOT — adjust the profile');\n}","typeGuard":null,"tryCatchPattern":"try {\n    Backups::backup($id);\n} catch (RuntimeException $e) {\n    if (str_contains($e->getMessage(), 'outside site root')) {\n        // security containment (GHSA-fch7-cpv4-w7hg) — fix the profile, do not retry\n    }\n}","preventionTips":["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"],"tags":["grav","backup","security","path-traversal","ghsa-fch7-cpv4-w7hg","cwe-22"],"backgroundTag":"path-traversal-blocked","analyzedSha":"6040efed04efa69b8209448ed81308e7c24147c2","analyzedAt":"2026-08-17T05:07:31.593Z","schemaVersion":2},"datasetVersion":"2026-08-17T09:17:11.063Z"}