{"record":{"id":"922697521e5e34c4","repo":"getgrav/grav","slug":"invalid-backup-location-backup-root","errorCode":null,"errorMessage":"Invalid backup location: {$backup_root}","messagePattern":"Invalid backup location: (.+?)","errorType":"exception","errorClass":"RuntimeException","httpStatus":null,"severity":"error","filePath":"system/src/Grav/Common/Backup/Backups.php","lineNumber":264,"sourceCode":"        $max_execution_time = ini_set('max_execution_time', '600');\n        $backup_root = $backup->root;\n\n        if ($locator->isStream($backup_root)) {\n            $backup_root = $locator->findResource($backup_root);\n        } else {\n            $backup_root = rtrim(GRAV_ROOT . $backup_root, DS) ?: DS;\n        }\n\n        if (!$backup_root || !file_exists($backup_root)) {\n            throw new RuntimeException(\"Backup location: {$backup_root} does not exist...\");\n        }\n\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 ?? ''),","sourceCodeStart":246,"sourceCodeEnd":282,"githubUrl":"https://github.com/getgrav/grav/blob/6040efed04efa69b8209448ed81308e7c24147c2/system/src/Grav/Common/Backup/Backups.php#L246-L282","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","solutions":["Add the site root (and backup destination) to open_basedir, or disable the restriction if the environment allows","Replace symlink chains in the backup root with the real directory path","Run var_dump(realpath(GRAV_ROOT), realpath($root)) in a scratch script to identify which of the two fails, then fix that path","Point the profile root at a plain directory inside the site instead of a symlinked one"],"exampleFix":"// before — profile root passes through a broken symlink\n$root = '/shared-site'; // /shared-site -> /mnt/old (missing target)\n\n// after\n$root = '/user'; // real directory inside GRAV_ROOT; realpath() succeeds","handlingStrategy":"validation","validationCode":"$realRoot = realpath($resolvedRoot);\n$realGrav = realpath(GRAV_ROOT);\nif ($realRoot === false || $realGrav === false) {\n    // realpath failed: open_basedir, broken symlink chain, or vanished path\n    throw new RuntimeException(\"Cannot canonicalize {$resolvedRoot} — check open_basedir and symlinks\");\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Keep open_basedir inclusive of GRAV_ROOT and all backup destinations","Avoid symlink chains in backup roots; realpath() must resolve every link","Smoke-test backup profiles after PHP hardening changes — realpath failures appear only at runtime"],"tags":["grav","backup","realpath","symlink","open-basedir","filesystem"],"backgroundTag":"realpath-resolution-failed","analyzedSha":"6040efed04efa69b8209448ed81308e7c24147c2","analyzedAt":"2026-08-17T05:07:31.593Z","schemaVersion":2},"datasetVersion":"2026-08-17T09:17:11.063Z"}