Edit PHP version constraint and update Composer dependencies

This commit is contained in:
Paul Nicoué 2025-07-04 15:08:52 +02:00
parent 231e1bce63
commit e5b51981ff
52 changed files with 806 additions and 613 deletions

View file

@ -3,6 +3,7 @@
namespace Kirby\Cms;
use Closure;
use Exception as GlobalException;
use Generator;
use Kirby\Data\Data;
use Kirby\Email\Email as BaseEmail;
@ -318,9 +319,18 @@ class App
}
}
foreach (glob($this->root('blueprints') . '/' . $type . '/*.yml') as $blueprint) {
$name = F::name($blueprint);
$blueprints[$name] = $name;
try {
// protect against path traversal attacks
$root = $this->root('blueprints') . '/' . $type;
$realpath = Dir::realpath($root, $this->root('blueprints'));
foreach (glob($realpath . '/*.yml') as $blueprint) {
$name = F::name($blueprint);
$blueprints[$name] = $name;
}
} catch (GlobalException) {
// if the realpath operation failed, the following glob was skipped,
// keeping just the blueprints from extensions
}
ksort($blueprints);
@ -478,7 +488,7 @@ class App
}
// controller from site root
$controller = Controller::load($this->root('controllers') . '/' . $name . '.php');
$controller = Controller::load($this->root('controllers') . '/' . $name . '.php', $this->root('controllers'));
// controller from extension
$controller ??= $this->extension('controllers', $name);
@ -1184,7 +1194,7 @@ class App
string|null $path = null,
string|null $method = null
): Response|null {
if (($_ENV['KIRBY_RENDER'] ?? true) === false) {
if ((filter_var($_ENV['KIRBY_RENDER'] ?? true, FILTER_VALIDATE_BOOLEAN)) === false) {
return null;
}
@ -1292,11 +1302,36 @@ class App
// try to resolve image urls for pages and drafts
if ($page = $site->findPageOrDraft($id)) {
return $page->file($filename);
return $this->resolveFile($page->file($filename));
}
// try to resolve site files at least
return $site->file($filename);
return $this->resolveFile($site->file($filename));
}
/**
* Filters a resolved file object using the configuration
* @internal
*/
public function resolveFile(File|null $file): File|null
{
// shortcut for files that don't exist
if ($file === null) {
return null;
}
$option = $this->option('content.fileRedirects', true);
if ($option === true) {
return $file;
}
if ($option instanceof Closure) {
return $option($file) === true ? $file : null;
}
// option was set to `false` or an invalid value
return null;
}
/**

View file

@ -79,7 +79,7 @@ trait AppCaches
$prefix =
str_replace(['/', ':'], '_', $this->system()->indexUrl()) .
'/' .
str_replace('.', '/', $key);
str_replace(['/', '.'], ['_', '/'], $key);
$defaults = [
'active' => true,

View file

@ -105,10 +105,11 @@ class Collections
{
$kirby = App::instance();
// first check for collection file
$file = $kirby->root('collections') . '/' . $name . '.php';
// first check for collection file in the `collections` root
$root = $kirby->root('collections');
$file = $root . '/' . $name . '.php';
if (is_file($file) === true) {
if (F::exists($file, $root) === true) {
$collection = F::load($file, allowOutput: false);
if ($collection instanceof Closure) {

View file

@ -617,12 +617,20 @@ class File extends ModelWithContent
}
/**
* Simplified File URL that uses the parent
* Page URL and the filename as a more stable
* alternative for the media URLs.
* Clean file URL that uses the parent page URL
* and the filename as a more stable alternative
* for the media URLs if available. The `content.fileRedirects`
* option is used to disable this behavior or enable it
* on a per-file basis.
*/
public function previewUrl(): string|null
{
// check if the clean file URL is accessible,
// otherwise we need to fall back to the media URL
if ($this->kirby()->resolveFile($this) === null) {
return $this->url();
}
$parent = $this->parent();
$url = Url::to($this->id());
@ -651,6 +659,7 @@ class File extends ModelWithContent
return $url;
case 'user':
// there are no clean URL routes for user files
return $this->url();
default:
return $url;

View file

@ -57,7 +57,7 @@ class Language
}
static::$kirby = $props['kirby'] ?? null;
$this->code = trim($props['code']);
$this->code = basename(trim($props['code'])); // prevent path traversal
$this->default = ($props['default'] ?? false) === true;
$this->direction = ($props['direction'] ?? null) === 'rtl' ? 'rtl' : 'ltr';
$this->name = trim($props['name'] ?? $this->code);
@ -325,6 +325,7 @@ class Language
public static function loadRules(string $code): array
{
$kirby = App::instance();
$code = basename($code); // prevent path traversal
$code = Str::contains($code, '.') ? Str::before($code, '.') : $code;
$file = $kirby->root('i18n:rules') . '/' . $code . '.json';

View file

@ -95,11 +95,13 @@ class Media
string $filename
): Response|false {
$kirby = App::instance();
$index = $kirby->root('index');
$media = $kirby->root('media');
$root = match (true) {
// assets
is_string($model)
=> $kirby->root('media') . '/assets/' . $model . '/' . $hash,
=> $media . '/assets/' . $model . '/' . $hash,
// parent files for file model that already included hash
$model instanceof File
=> dirname($model->mediaRoot()),
@ -108,10 +110,13 @@ class Media
=> $model->mediaRoot() . '/' . $hash
};
$thumb = $root . '/' . $filename;
$job = $root . '/.jobs/' . $filename . '.json';
try {
// prevent path traversal
$root = Dir::realpath($root, $media);
$thumb = $root . '/' . $filename;
$job = $root . '/.jobs/' . $filename . '.json';
$options = Data::read($job);
} catch (Throwable) {
// send a customized error message to make clearer what happened here
@ -127,7 +132,12 @@ class Media
// this adds support for custom assets
$source = match (true) {
is_string($model) === true
=> $kirby->root('index') . '/' . $model . '/' . $options['filename'],
=> F::realpath(
$index . '/' . $model . '/' . $options['filename'],
$index
),
$model instanceof File
=> $model->root(),
default
=> $model->file($options['filename'])->root()
};

View file

@ -361,7 +361,7 @@ class Page extends ModelWithContent
}
/**
* Sorting number + Slug
* Returns the directory name (UID with optional sorting number)
*/
public function dirname(): string
{
@ -377,7 +377,8 @@ class Page extends ModelWithContent
}
/**
* Sorting number + Slug
* Returns the directory path relative to the `content` root
* (including optional sorting numbers and draft directories)
*/
public function diruri(): string
{

View file

@ -2,6 +2,7 @@
namespace Kirby\Cms;
use Kirby\Filesystem\F;
use Kirby\Http\Url as BaseUrl;
use Kirby\Toolkit\Str;
@ -63,10 +64,11 @@ class Url extends BaseUrl
$kirby = App::instance();
$page = $kirby->site()->page();
$path = $assetPath . '/' . $page->template() . '.' . $extension;
$file = $kirby->root('assets') . '/' . $path;
$root = $kirby->root('assets');
$file = $root . '/' . $path;
$url = $kirby->url('assets') . '/' . $path;
return file_exists($file) === true ? $url : null;
return F::exists($file, $root) === true ? $url : null;
}
/**

View file

@ -114,9 +114,14 @@ class Dir
/**
* Checks if the directory exists on disk
*/
public static function exists(string $dir): bool
public static function exists(string $dir, string|null $in = null): bool
{
return is_dir($dir) === true;
try {
static::realpath($dir, $in);
return true;
} catch (Exception) {
return false;
}
}
/**
@ -523,6 +528,33 @@ class Dir
return $result;
}
/**
* Returns the absolute path to the directory if the directory can be found.
* @since 4.7.1
*/
public static function realpath(string $dir, string|null $in = null): string
{
$realpath = realpath($dir);
if ($realpath === false || is_dir($realpath) === false) {
throw new Exception(sprintf('The directory does not exist at the given path: "%s"', $dir));
}
if ($in !== null) {
$parent = realpath($in);
if ($parent === false || is_dir($parent) === false) {
throw new Exception(sprintf('The parent directory does not exist: "%s"', $in));
}
if (substr($realpath, 0, strlen($parent)) !== $parent) {
throw new Exception('The directory is not within the parent directory');
}
}
return $realpath;
}
/**
* Removes a folder including all containing files and folders
*/

View file

@ -810,18 +810,24 @@ class Environment
}
// load the config for the host
if (empty($host) === false) {
if (
empty($host) === false &&
F::exists($path = $root . '/config.' . $host . '.php', $root) === true
) {
$configHost = F::load(
file: $root . '/config.' . $host . '.php',
file: $path,
fallback: [],
allowOutput: false
);
}
// load the config for the server IP
if (empty($addr) === false) {
if (
empty($addr) === false &&
F::exists($path = $root . '/config.' . $addr . '.php', $root) === true
) {
$configAddr = F::load(
file: $root . '/config.' . $addr . '.php',
file: $path,
fallback: [],
allowOutput: false
);

View file

@ -4,6 +4,7 @@ namespace Kirby\Panel\Lab;
use Kirby\Cms\App;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
@ -32,7 +33,7 @@ class Category
) {
$this->root = $root ?? static::base() . '/' . $this->id;
if (file_exists($this->root . '/index.php') === true) {
if (F::exists($this->root . '/index.php', static::base()) === true) {
$this->props = array_merge(
require $this->root . '/index.php',
$this->props

View file

@ -30,6 +30,8 @@ class Docs
public function __construct(
protected string $name
) {
// protect against path traversal
$this->name = basename($name);
$this->kirby = App::instance();
$this->json = $this->read();
}

View file

@ -71,7 +71,7 @@ class Example
public function exists(): bool
{
return is_dir($this->root) === true;
return Dir::exists($this->root, $this->parent->root()) === true;
}
public function file(string $filename): string

View file

@ -231,8 +231,12 @@ abstract class Model
// for card layouts with `cover: true` provide
// crops based on the card ratio
if ($layout === 'cards') {
$ratio = explode('/', $settings['ratio'] ?? '1/1');
$ratio = $ratio[0] / $ratio[1];
$ratio = $settings['ratio'] ?? '1/1';
if (is_numeric($ratio) === false) {
$ratio = explode('/', $ratio);
$ratio = $ratio[0] / $ratio[1];
}
return $image->srcset([
$sizes[0] . 'w' => [

View file

@ -389,7 +389,8 @@ class FileSessionStore extends SessionStore
*/
protected function name(int $expiryTime, string $id): string
{
return $expiryTime . '.' . $id;
// protect against path traversal
return $expiryTime . '.' . basename($id);
}
/**

View file

@ -5,6 +5,7 @@ namespace Kirby\Template;
use Kirby\Cms\App;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Tpl;
@ -187,7 +188,7 @@ class Snippet extends Tpl
$name = (string)$name;
$file = $root . '/' . $name . '.php';
if (file_exists($file) === false) {
if (F::exists($file, $root) === false) {
$file = $kirby->extensions('snippets')[$name] ?? null;
}

View file

@ -3,6 +3,7 @@
namespace Kirby\Toolkit;
use Closure;
use Exception;
use Kirby\Filesystem\F;
use ReflectionFunction;
@ -60,12 +61,24 @@ class Controller
return $this->function->call($bind, ...$args);
}
public static function load(string $file): static|null
public static function load(string $file, string|null $in = null): static|null
{
if (is_file($file) === false) {
return null;
}
// restrict file paths to the provided root
// to prevent path traversal
if ($in !== null) {
try {
$file = F::realpath($file, $in);
} catch (Exception) {
// don't expose whether the file exists
// (which would have returned `null` above)
return null;
}
}
$function = F::load($file);
if ($function instanceof Closure === false) {