Upgrade to 3.9.8
This commit is contained in:
@@ -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
|
||||
|
@@ -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',
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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'],
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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
|
||||
{
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
*
|
||||
|
@@ -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]);
|
||||
}
|
||||
|
||||
|
216
kirby/src/Toolkit/SymmetricCrypto.php
Normal file
216
kirby/src/Toolkit/SymmetricCrypto.php
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user