Upgrade to 3.9.8

This commit is contained in:
Bastian Allgeier
2023-11-14 12:19:47 +01:00
parent 474ecd14c7
commit f96b96af76
53 changed files with 1383 additions and 943 deletions

View File

@@ -66,9 +66,11 @@ class Collection extends BaseCollection
}
/**
* Internal setter for each object in the Collection.
* This takes care of Component validation and of setting
* the collection prop on each object correctly.
* Internal setter for each object in the Collection;
* override from the Toolkit Collection is needed to
* make the CMS collections case-sensitive;
* child classes can override it again to add validation
* and custom behavior depending on the object type
*
* @param string $id
* @param object $object
@@ -79,6 +81,16 @@ class Collection extends BaseCollection
$this->data[$id] = $object;
}
/**
* Internal remover for each object in the Collection;
* override from the Toolkit Collection is needed to
* make the CMS collections case-sensitive
*/
public function __unset($id)
{
unset($this->data[$id]);
}
/**
* Adds a single object or
* an entire second collection to the

View File

@@ -207,7 +207,7 @@ class FileRules
if (
Str::contains($extension, 'php') !== false ||
Str::contains($extension, 'phar') !== false ||
Str::contains($extension, 'phtml') !== false
Str::contains($extension, 'pht') !== false
) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',

View File

@@ -206,6 +206,20 @@ class UpdateStatus
];
}
// add special message for end-of-life PHP versions
$phpMajor = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
$phpEol = $this->data['php'][$phpMajor] ?? null;
if (is_string($phpEol) === true && $eolTime = strtotime($phpEol)) {
// the timestamp is available and valid, now check if it is in the past
if ($eolTime < time()) {
$messages[] = [
'text' => I18n::template('system.issues.eol.php', null, ['release' => $phpMajor]),
'link' => 'https://getkirby.com/security/php-end-of-life',
'icon' => 'bell'
];
}
}
return $this->messages = $messages;
}

View File

@@ -78,6 +78,7 @@ class Mime
'php' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
'php3' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
'phps' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
'pht' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
'phtml' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
'png' => 'image/png',
'ppt' => ['application/powerpoint', 'application/vnd.ms-powerpoint'],

View File

@@ -772,18 +772,28 @@ class Environment
/**
* Loads and returns options from environment-specific
* PHP files (by host name and server IP address)
* PHP files (by host name and server IP address or CLI)
*
* @param string $root Root directory to load configs from
*/
public function options(string $root): array
{
$configCli = [];
$configHost = [];
$configAddr = [];
$host = $this->host();
$addr = $this->ip();
// load the config for the cli
if ($this->cli() === true) {
$configCli = F::load(
file: $root . '/config.cli.php',
fallback: [],
allowOutput: false
);
}
// load the config for the host
if (empty($host) === false) {
$configHost = F::load(
@@ -802,7 +812,7 @@ class Environment
);
}
return array_replace_recursive($configHost, $configAddr);
return array_replace_recursive($configCli, $configHost, $configAddr);
}
/**

View File

@@ -60,6 +60,8 @@ class Remote
/**
* Constructor
*
* @throws \Exception when the curl request failed
*/
public function __construct(string $url, array $options = [])
{
@@ -120,6 +122,7 @@ class Remote
* Sets up all curl options and sends the request
*
* @return $this
* @throws \Exception when the curl request failed
*/
public function fetch(): static
{
@@ -258,6 +261,8 @@ class Remote
/**
* Static method to send a GET request
*
* @throws \Exception when the curl request failed
*/
public static function get(string $url, array $params = []): static
{
@@ -339,6 +344,8 @@ class Remote
/**
* Static method to init this class and send a request
*
* @throws \Exception when the curl request failed
*/
public static function request(string $url, array $params = []): static
{

View File

@@ -28,7 +28,15 @@ class Inline
public function __construct(DOMNode $node, array $marks = [])
{
$this->createMarkRules($marks);
$this->html = trim(static::parseNode($node, $this->marks) ?? '');
$html = static::parseNode($node, $this->marks) ?? '';
// only trim HTML if it doesn't consist of only spaces
if (trim($html) !== '') {
$html = trim($html);
}
$this->html = $html;
}
/**

View File

@@ -10,6 +10,7 @@ use Kirby\Exception\NotFoundException;
use Kirby\Http\Cookie;
use Kirby\Http\Url;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\SymmetricCrypto;
use Throwable;
/**
@@ -38,7 +39,7 @@ class Session
protected $lastActivity;
protected $renewable;
protected $data;
protected $newSession;
protected array|null $newSession;
// temporary state flags
protected $updatingLastActivity = false;
@@ -348,15 +349,27 @@ class Session
}
// collect all data
if ($this->newSession) {
if (isset($this->newSession) === true) {
// the token has changed
// we are writing to the old session: it only gets the reference to the new session
// and a shortened expiry time (30 second grace period)
$data = [
'startTime' => $this->startTime(),
'expiryTime' => time() + 30,
'newSession' => $this->newSession
'newSession' => $this->newSession[0]
];
// include the token key for the new session if we
// have access to the PHP `sodium` extension;
// otherwise (if no encryption is possible), the token key
// is omitted, which makes the new session read-only
// when accessed through the old session
if ($crypto = $this->crypto()) {
// encrypt the new token key with the old token key
// so that attackers with read access to the session file
// (e.g. via directory traversal) cannot impersonate the new session
$data['newSessionKey'] = $crypto->encrypt($this->newSession[1]);
}
} else {
$data = [
'startTime' => $this->startTime(),
@@ -446,7 +459,7 @@ class Session
// mark the old session as moved if there is one
if ($this->tokenExpiry !== null) {
$this->newSession = $tokenExpiry . '.' . $tokenId;
$this->newSession = [$tokenExpiry . '.' . $tokenId, $tokenKey];
$this->commit();
// we are now in the context of the new session
@@ -536,7 +549,7 @@ class Session
}
// don't allow writing for read-only sessions
// (only the case for moved sessions)
// (only the case for moved sessions when the PHP `sodium` extension is not available)
/**
* @todo This check gets flagged by Psalm for unknown reasons
* @psalm-suppress ParadoxicalCondition
@@ -555,6 +568,22 @@ class Session
$this->writeMode = true;
}
/**
* Returns a symmetric crypto instance based on the
* token key of the session
*/
protected function crypto(): SymmetricCrypto|null
{
if (
$this->tokenKey === null ||
SymmetricCrypto::isAvailable() === false
) {
return null; // @codeCoverageIgnore
}
return new SymmetricCrypto(secretKey: hex2bin($this->tokenKey));
}
/**
* Parses a token string into its parts and sets them as instance vars
*
@@ -698,6 +727,20 @@ class Session
// follow to the new session if there is one
if (isset($data['newSession'])) {
// decrypt the token key if provided and we have access to
// the PHP `sodium` extension for decryption
if (
isset($data['newSessionKey']) === true &&
$crypto = $this->crypto()
) {
$tokenKey = $crypto->decrypt($data['newSessionKey']);
$this->parseToken($data['newSession'] . '.' . $tokenKey);
$this->init();
return;
}
// otherwise initialize without the token key (read-only mode)
$this->parseToken($data['newSession'], true);
$this->init();
return;

View File

@@ -59,6 +59,77 @@ class A
return count($array);
}
/**
* Checks if every element in the array passes the test
*
* <code>
* $array = [1, 30, 39, 29, 10, 13];
*
* $isBelowThreshold = fn($value) => $value < 40;
* echo A::every($array, $isBelowThreshold) ? 'true' : 'false';
* // output: 'true'
*
* $isIntegerKey = fn($value, $key) => is_int($key);
* echo A::every($array, $isIntegerKey) ? 'true' : 'false';
* // output: 'true'
* </code>
*
* @since 3.9.8
* @param array $array
* @param callable(mixed $value, int|string $key, array $array):bool $test
* @return bool
*/
public static function every(array $array, callable $test): bool
{
foreach ($array as $key => $value) {
if (!$test($value, $key, $array)) {
return false;
}
}
return true;
}
/**
* Finds the first element matching the given callback
*
* <code>
* $array = [1, 30, 39, 29, 10, 13];
*
* $isAboveThreshold = fn($value) => $value > 30;
* echo A::find($array, $isAboveThreshold);
* // output: '39'
*
* $array = [
* 'cat' => 'miao',
* 'cow' => 'moo',
* 'colibri' => 'humm',
* 'dog' => 'wuff',
* 'chicken' => 'cluck',
* 'bird' => 'tweet'
* ];
*
* $keyNotStartingWithC = fn($value, $key) => $key[0] !== 'c';
* echo A::find($array, $keyNotStartingWithC);
* // output: 'wuff'
* </code>
*
* @since 3.9.8
* @param array $array
* @param callable(mixed $value, int|string $key, array $array):bool $callback
* @return mixed
*/
public static function find(array $array, callable $callback): mixed
{
foreach ($array as $key => $value) {
if ($callback($value, $key, $array)) {
return $value;
}
}
return null;
}
/**
* Gets an element of an array by key
*
@@ -407,6 +478,37 @@ class A
return array_slice($array, $offset, $length, $preserveKeys);
}
/**
* Checks if at least one element in the array passes the test
*
* <code>
* $array = [1, 30, 39, 29, 10, 'foo' => 12, 13];
*
* $isAboveThreshold = fn($value) => $value > 30;
* echo A::some($array, $isAboveThreshold) ? 'true' : 'false';
* // output: 'true'
*
* $isStringKey = fn($value, $key) => is_string($key);
* echo A::some($array, $isStringKey) ? 'true' : 'false';
* // output: 'true'
* </code>
*
* @since 3.9.8
* @param array $array
* @param callable(mixed $value, int|string $key, array $array):bool $test
* @return bool
*/
public static function some(array $array, callable $test): bool
{
foreach ($array as $key => $value) {
if ($test($value, $key, $array)) {
return true;
}
}
return false;
}
/**
* Sums an array
*

View File

@@ -123,6 +123,10 @@ class Collection extends Iterator implements Countable
*/
public function __unset($key)
{
if ($this->caseSensitive !== true) {
$key = strtolower($key);
}
unset($this->data[$key]);
}

View File

@@ -0,0 +1,216 @@
<?php
namespace Kirby\Toolkit;
use Kirby\Data\Json;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use SensitiveParameter;
/**
* User-friendly and safe abstraction for symmetric
* authenticated encryption and decryption using the
* PHP `sodium` extension
* @since 3.9.8
*
* @package Kirby Toolkit
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class SymmetricCrypto
{
/**
* Cache for secret keys derived from the password
* indexed by the used salt and limits
*/
protected array $secretKeysByOptions = [];
/**
* Initializes the keys used for crypto, both optional
*
* @param string|null $password Password to be derived into a `$secretKey`
* @param string|null $secretKey 256-bit key, alternatively a `$password` can be used
*/
public function __construct(
#[SensitiveParameter]
protected string|null $password = null,
#[SensitiveParameter]
protected string|null $secretKey = null,
) {
if ($password !== null && $secretKey !== null) {
throw new InvalidArgumentException('Passing both a secret key and a password is not supported');
}
if ($secretKey !== null && strlen($secretKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new InvalidArgumentException('Invalid secret key length, expected ' . SODIUM_CRYPTO_SECRETBOX_KEYBYTES . ' bytes');
}
}
/**
* Hide values of secrets when printing the object
*/
public function __debugInfo(): array
{
return [
'hasPassword' => isset($this->password),
'hasSecretKey' => isset($this->secretKey),
];
}
/**
* Wipes the secrets from memory when they are no longer needed
*/
public function __destruct()
{
$this->memzero($this->password);
$this->memzero($this->secretKey);
foreach ($this->secretKeysByOptions as $key => &$value) {
$this->memzero($value);
unset($this->secretKeysByOptions[$key]);
}
}
/**
* Decrypts JSON data encrypted by `SymmetricCrypto::encrypt()` using the secret key or password
*
* <code>
* // decryption with a password
* $crypto = new SymmetricCrypto(password: 'super secure');
* $plaintext = $crypto->decrypt('a very confidential string');
*
* // decryption with a previously generated key
* $crypto = new SymmetricCrypto(secretKey: $secretKey);
* $plaintext = $crypto->decrypt('{"mode":"secretbox"...}');
* </code>
*/
public function decrypt(string $json): string
{
$props = Json::decode($json);
if (($props['mode'] ?? null) !== 'secretbox') {
throw new InvalidArgumentException('Unsupported encryption mode "' . ($props['mode'] ?? '') . '"');
}
if (
isset($props['data']) !== true ||
isset($props['nonce']) !== true ||
isset($props['salt']) !== true ||
isset($props['limits']) !== true
) {
throw new InvalidArgumentException('Input data does not contain all required props');
}
$data = base64_decode($props['data']);
$nonce = base64_decode($props['nonce']);
$salt = base64_decode($props['salt']);
$limits = $props['limits'];
$plaintext = sodium_crypto_secretbox_open($data, $nonce, $this->secretKey($salt, $limits));
if (is_string($plaintext) !== true) {
throw new LogicException('Encrypted string was tampered with');
}
return $plaintext;
}
/**
* Encrypts a string using the secret key or password
*
* <code>
* // encryption with a password
* $crypto = new SymmetricCrypto(password: 'super secure');
* $ciphertext = $crypto->encrypt('a very confidential string');
*
* // encryption with a random key
* $crypto = new SymmetricCrypto();
* $ciphertext = $crypto->encrypt('a very confidential string');
* $secretKey = $crypto->secretKey();
* </code>
*/
public function encrypt(
#[SensitiveParameter]
string $string
): string {
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
$limits = [SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE];
$key = $this->secretKey($salt, $limits);
$ciphertext = sodium_crypto_secretbox($string, $nonce, $key);
// bundle all necessary information in a JSON object;
// always include the salt and limits to hide whether a key or password was used
return Json::encode([
'mode' => 'secretbox',
'data' => base64_encode($ciphertext),
'nonce' => base64_encode($nonce),
'salt' => base64_encode($salt),
'limits' => $limits,
]);
}
/**
* Checks if the required PHP `sodium` extension is available
*/
public static function isAvailable(): bool
{
return defined('SODIUM_LIBRARY_MAJOR_VERSION') === true && SODIUM_LIBRARY_MAJOR_VERSION >= 10;
}
/**
* Returns the binary secret key, optionally derived from the password
* or randomly generated
*
* @param string|null $salt Salt for password-based key derivation
* @param array|null $limits Processing limits for password-based key derivation
*/
public function secretKey(
#[SensitiveParameter]
string|null $salt = null,
array|null $limits = null
): string {
if (isset($this->secretKey) === true) {
return $this->secretKey;
}
// derive from password
if (isset($this->password) === true) {
if ($salt === null || $limits === null) {
throw new InvalidArgumentException('Salt and limits are required when deriving a secret key from a password');
}
// access from cache
$options = $salt . ':' . implode(',', $limits);
if (isset($this->secretKeysByOptions[$options]) === true) {
return $this->secretKeysByOptions[$options];
}
return $this->secretKeysByOptions[$options] = sodium_crypto_pwhash(
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
$this->password,
$salt,
$limits[0],
$limits[1],
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13
);
}
// generate a random key
return $this->secretKey = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
}
/**
* Wipes a variable from memory if it is a string
*/
protected function memzero(mixed &$value): void
{
if (is_string($value) === true) {
sodium_memzero($value);
$value = '';
}
}
}