signature and timestamp in your API requests. Example:' ); ?>
diff --git a/composer.lock b/composer.lock
index d8919ca..525e781 100644
--- a/composer.lock
+++ b/composer.lock
@@ -212,16 +212,16 @@
},
{
"name": "composer/ca-bundle",
- "version": "1.5.12",
+ "version": "1.5.14",
"source": {
"type": "git",
"url": "https://github.com/composer/ca-bundle.git",
- "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78"
+ "reference": "0c8abba0634f637bd78c4e451981da368d403463"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78",
- "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78",
+ "url": "https://api.github.com/repos/composer/ca-bundle/zipball/0c8abba0634f637bd78c4e451981da368d403463",
+ "reference": "0c8abba0634f637bd78c4e451981da368d403463",
"shasum": ""
},
"require": {
@@ -268,7 +268,7 @@
"support": {
"irc": "irc://irc.freenode.org/composer",
"issues": "https://github.com/composer/ca-bundle/issues",
- "source": "https://github.com/composer/ca-bundle/tree/1.5.12"
+ "source": "https://github.com/composer/ca-bundle/tree/1.5.14"
},
"funding": [
{
@@ -280,7 +280,7 @@
"type": "github"
}
],
- "time": "2026-05-19T11:26:22+00:00"
+ "time": "2026-08-21T14:57:29+00:00"
},
{
"name": "geoip2/geoip2",
@@ -809,16 +809,16 @@
},
{
"name": "rmccue/requests",
- "version": "v2.0.18",
+ "version": "v2.0.20",
"source": {
"type": "git",
"url": "https://github.com/WordPress/Requests.git",
- "reference": "2e5b8434e0dd54b35bcf1e9a5b52ba2ad84cf773"
+ "reference": "c379f928584da604287bcb102ea52c0751a66584"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/WordPress/Requests/zipball/2e5b8434e0dd54b35bcf1e9a5b52ba2ad84cf773",
- "reference": "2e5b8434e0dd54b35bcf1e9a5b52ba2ad84cf773",
+ "url": "https://api.github.com/repos/WordPress/Requests/zipball/c379f928584da604287bcb102ea52c0751a66584",
+ "reference": "c379f928584da604287bcb102ea52c0751a66584",
"shasum": ""
},
"require": {
@@ -826,13 +826,11 @@
"php": ">=5.6"
},
"require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.0",
"php-parallel-lint/php-console-highlighter": "^0.5.0",
- "php-parallel-lint/php-parallel-lint": "^1.3.1",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
"phpcompatibility/php-compatibility": "^10.0.0@dev",
"requests/test-server": "dev-main",
- "squizlabs/php_codesniffer": "^3.6",
- "wp-coding-standards/wpcs": "^2.0",
+ "wp-coding-standards/wpcs": "^3.1",
"yoast/phpunit-polyfills": "^1.1.5"
},
"suggest": {
@@ -891,7 +889,7 @@
"issues": "https://github.com/WordPress/Requests/issues",
"source": "https://github.com/WordPress/Requests"
},
- "time": "2026-04-30T00:41:23+00:00"
+ "time": "2026-08-31T09:07:24+00:00"
},
{
"name": "robrichards/xmlseclibs",
@@ -1005,16 +1003,16 @@
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.38.1",
+ "version": "v1.42.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "dc21118016c039a66235cf93d96b435ffb282412"
+ "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412",
- "reference": "dc21118016c039a66235cf93d96b435ffb282412",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9",
+ "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9",
"shasum": ""
},
"require": {
@@ -1068,7 +1066,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1"
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0"
},
"funding": [
{
@@ -1088,20 +1086,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-25T15:22:23+00:00"
+ "time": "2026-08-24T10:51:20+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.38.0",
+ "version": "v1.42.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
+ "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
- "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502",
+ "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502",
"shasum": ""
},
"require": {
@@ -1153,7 +1151,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0"
},
"funding": [
{
@@ -1173,7 +1171,7 @@
"type": "tidelift"
}
],
- "time": "2026-05-25T13:48:31+00:00"
+ "time": "2026-08-07T06:33:24+00:00"
},
{
"name": "symfony/polyfill-mbstring",
diff --git a/includes/Config/Config.php b/includes/Config/Config.php
index 2f726d2..e07cbfe 100644
--- a/includes/Config/Config.php
+++ b/includes/Config/Config.php
@@ -20,6 +20,29 @@ class Config {
*/
protected string $config;
+ /**
+ * Constants that must be defined in config.php
+ */
+ protected const MANDATORY_CONSTANTS = [
+ 'YOURLS_DB_USER',
+ 'YOURLS_DB_PASS',
+ 'YOURLS_DB_NAME',
+ 'YOURLS_DB_HOST',
+ 'YOURLS_DB_PREFIX',
+ 'YOURLS_SITE',
+ ];
+
+ /**
+ * Publicly known values that are not acceptable for YOURLS_COOKIEKEY
+ */
+ protected const INVALID_COOKIEKEYS = [
+ '',
+ 'modify this text with something random', // default value in config-sample.php
+ 'qQ4KhL_pu|s@Zm7n#%:b^{A[vhm', // suggested value in the documentation
+ ];
+
+
+
/**
* @since 1.7.3
* @param string $config Optional user defined config path
@@ -93,26 +116,52 @@ public function find_config(): string {
}
/**
- * Define core constants that have not been user defined in config.php
+ * Check that all mandatory constants are defined
*
- * @since 1.7.3
+ * @since 1.10.5
+ * @param string[] $must_haves Constant names to check, defaults to self::MANDATORY_CONSTANTS
* @return void
* @throws ConfigException
*/
- public function define_core_constants(): void {
- // Check minimal config job has been properly done
- $must_haves = array('YOURLS_DB_USER', 'YOURLS_DB_PASS', 'YOURLS_DB_NAME', 'YOURLS_DB_HOST', 'YOURLS_DB_PREFIX', 'YOURLS_SITE');
- foreach($must_haves as $must_have) {
+ public function check_mandatory_constants(array $must_haves = self::MANDATORY_CONSTANTS): void {
+ foreach ($must_haves as $must_have) {
if (!defined($must_have)) {
throw new ConfigException('Config is incomplete (missing at least '.$must_have.') Check config-sample.php and edit your config accordingly');
}
}
+ }
+
+ /**
+ * Check that the cookie key is defined and is not a publicly known value
+ *
+ * @since 1.10.5
+ * @param mixed $key Cookie key value, or null if the constant is undefined
+ * @return void
+ * @throws ConfigException
+ */
+ public function check_cookie_key(mixed $key): void {
+ if (!is_string($key) || in_array($key, self::INVALID_COOKIEKEYS, true)) {
+ throw new ConfigException('YOURLS_COOKIEKEY is undefined or still set to the sample value. Set it to a long random string, see https://yourls.org/cookiedoc');
+ }
+ }
+
+ /**
+ * Define core constants that have not been user defined in config.php
+ *
+ * @since 1.7.3
+ * @return void
+ * @throws ConfigException
+ */
+ public function define_core_constants(): void {
+ // Check minimal config job has been properly done
+ $this->check_mandatory_constants();
+ $this->check_cookie_key(defined('YOURLS_COOKIEKEY') ? YOURLS_COOKIEKEY : null);
/**
* The following has an awful CRAP index and it would be much shorter reduced to something like
* defining an array of ('YOURLS_SOMETHING' => 'default value') and then a simple loop over the
* array, checking if $current is defined as a constant and otherwise define said constant with
- * its default value. I did not wrote it that way because that would make it difficult for code
+ * its default value. I did not write it that way because that would make it difficult for code
* parsers to identify which constants are defined and where. So, here it is, that long list of
* if (!defined) define(). Ho and by the way, such beautiful comment, much right aligned, wow !
*/
@@ -177,30 +226,6 @@ public function define_core_constants(): void {
if (!defined( 'YOURLS_DB_TABLE_LOG' ))
define( 'YOURLS_DB_TABLE_LOG', YOURLS_DB_PREFIX.'log' );
- // minimum delay in sec before a same IP can add another URL. Note: logged in users are not throttled down.
- if (!defined( 'YOURLS_FLOOD_DELAY_SECONDS' ))
- define( 'YOURLS_FLOOD_DELAY_SECONDS', 15 );
-
- // comma separated list of IPs that can bypass flood check.
- if (!defined( 'YOURLS_FLOOD_IP_WHITELIST' ))
- define( 'YOURLS_FLOOD_IP_WHITELIST', '' );
-
- // life span of an auth cookie in seconds (60*60*24*7 = 7 days)
- if (!defined( 'YOURLS_COOKIE_LIFE' ))
- define( 'YOURLS_COOKIE_LIFE', 60*60*24*7 );
-
- // life span of a nonce in seconds
- if (!defined( 'YOURLS_NONCE_LIFE' ))
- define( 'YOURLS_NONCE_LIFE', 43200 ); // 3600 * 12
-
- // if set to true, disable stat logging (no use for it, too busy servers, ...)
- if (!defined( 'YOURLS_NOSTATS' ))
- define( 'YOURLS_NOSTATS', false );
-
- // if set to true, force https:// in the admin area
- if (!defined( 'YOURLS_ADMIN_SSL' ))
- define( 'YOURLS_ADMIN_SSL', false );
-
// if set to true, verbose debug infos
if (!defined( 'YOURLS_DEBUG' )) {
define('YOURLS_DEBUG', false);
diff --git a/includes/auth.php b/includes/auth.php
index be98a87..1d2016f 100644
--- a/includes/auth.php
+++ b/includes/auth.php
@@ -29,14 +29,23 @@
/*
* The following code is a shim that helps users store passwords securely in config.php
- * by storing a password hash and removing the plaintext.
+ * by storing a password hash and removing the plaintext or md5
*
* TODO: Remove this once real user management is implemented
*/
-// Did we just fail at encrypting passwords ?
-if ( isset( $_GET['dismiss'] ) && $_GET['dismiss'] == 'hasherror' ) {
- yourls_update_option( 'defer_hashing_error', time() + 86400 * 7 ); // now + 1 week
+// Did we just fail at encrypting passwords, or did we just notice md5 passwords?
+if ( isset( $_GET['dismiss'] ) ) {
+ // Bold assumption: the user has either a md5 password or a hashing error. If they have both, they'll
+ // see the 2 messages on successive page loads, and can dismiss each one separately.
+
+ if ($_GET['dismiss'] == 'hasherror' ) {
+ yourls_update_option('defer_hashing_error', time() + 86400 * 7); // now + 1 week
+ }
+
+ if ($_GET['dismiss'] == 'md5warning' ) {
+ yourls_update_option('defer_md5_warning', time() + 86400 * 7); // now + 1 week
+ }
} else {
@@ -45,8 +54,9 @@
$hash = yourls_hash_passwords_now( YOURLS_CONFIGFILE );
if ( $hash === true ) {
// Hashing successful. Remove flag from DB if any.
- if( yourls_get_option( 'defer_hashing_error' ) )
- yourls_delete_option( 'defer_hashing_error' );
+ if( yourls_get_option( 'defer_hashing_error' ) ) {
+ yourls_delete_option('defer_hashing_error');
+ }
} else {
// It failed, display message for first time or if last time was a week ago
if ( time() > yourls_get_option( 'defer_hashing_error' ) or !yourls_get_option( 'defer_hashing_error' ) ) {
@@ -60,4 +70,23 @@
}
}
}
+
+ // Warn about deprecated MD5 passwords
+ if ( yourls_has_md5_passwords() ) {
+ if ( time() > yourls_get_option( 'defer_md5_warning' ) or !yourls_get_option( 'defer_md5_warning' ) ) {
+ $message = yourls_s( 'Password stored as MD5 hash. Please update your config.php file to use more secure password hashes.' );
+ $message .= ' ';
+ $message .= yourls_s( 'Get help.', 'http://yourls.org/userpassword' );
+ $message .= '
';
+ $message .= yourls_s( 'Click here to dismiss this message for one week.', '?dismiss=md5warning' );
+
+ yourls_add_notice( $message );
+ }
+ } else {
+ // No md5 password, remove flag from DB if any.
+ if( yourls_get_option( 'defer_md5_warning' ) ) {
+ yourls_delete_option('defer_md5_warning');
+ }
+ }
+
}
diff --git a/includes/functions-auth.php b/includes/functions-auth.php
index 8a702b8..528d60a 100644
--- a/includes/functions-auth.php
+++ b/includes/functions-auth.php
@@ -49,7 +49,7 @@ function yourls_is_valid_user() {
// Determine auth method and check credentials
if
// API only: Secure (no login or pwd) and time limited token
- // ?timestamp=12345678&signature=md5(totoblah12345678)
+ // ?timestamp=12345678&signature=some-long-sha256-hash
( yourls_is_API() &&
isset( $_REQUEST['timestamp'] ) && !empty($_REQUEST['timestamp'] ) &&
isset( $_REQUEST['signature'] ) && !empty($_REQUEST['signature'] )
@@ -61,7 +61,7 @@ function yourls_is_valid_user() {
elseif
// API only: Secure (no login or pwd)
- // ?signature=md5(totoblah)
+ // ?signature=some-long-sha256-hash
( yourls_is_API() &&
!isset( $_REQUEST['timestamp'] ) &&
isset( $_REQUEST['signature'] ) && !empty( $_REQUEST['signature'] )
@@ -163,10 +163,10 @@ function yourls_check_password_hash($user, $submitted_password ) {
} else if( yourls_has_md5_password( $user ) ) {
// Stored password is a salted md5 hash: "md5:<$r = rand(10000,99999)>:"
list( , $salt, ) = explode( ':', $yourls_user_passwords[ $user ] );
- return( $yourls_user_passwords[ $user ] == 'md5:'.$salt.':'.md5( $salt . $submitted_password ) );
+ return hash_equals( $yourls_user_passwords[ $user ], 'md5:'.$salt.':'.md5( $salt . $submitted_password ) );
} else {
// Password stored in clear text
- return( $yourls_user_passwords[ $user ] === $submitted_password );
+ return hash_equals( (string) $yourls_user_passwords[ $user ], (string) $submitted_password );
}
}
@@ -247,13 +247,13 @@ function yourls_hash_passwords_now( $config_file ) {
* @param string $password password to hash
* @return string hashed password
*/
-function yourls_phpass_hash( $password ) {
+function yourls_phpass_hash(string $password ): string {
/**
* Filter for hashing algorithm. See https://www.php.net/manual/en/function.password-hash.php
- * Hashing algos are available if PHP was compiled with it.
- * PASSWORD_BCRYPT is always available.
+ * We're using the default password hashing. This allows us to use better algos as they become
+ * available in future PHP versions, without having to update YOURLS.
*/
- $algo = yourls_apply_filter('hash_algo', PASSWORD_BCRYPT);
+ $algo = yourls_apply_filter('hash_algo', PASSWORD_DEFAULT);
/**
* Filter for hashing options. See https://www.php.net/manual/en/function.password-hash.php
@@ -297,6 +297,22 @@ function yourls_has_cleartext_passwords() {
return false;
}
+/**
+ * Check to see if any password is stored as md5.
+ *
+ * @since 1.10.5
+ * @return bool true if any passwords are md5
+ */
+function yourls_has_md5_passwords(): bool {
+ global $yourls_user_passwords;
+ foreach ( $yourls_user_passwords as $user => $pwdata ) {
+ if ( yourls_has_md5_password($user) ) {
+ return true;
+ }
+ }
+ return false;
+}
+
/**
* Check if a user has a md5 hashed password
*
@@ -307,11 +323,11 @@ function yourls_has_cleartext_passwords() {
* @param string $user user login
* @return bool true if password hashed, false otherwise
*/
-function yourls_has_md5_password( $user ) {
+function yourls_has_md5_password(string $user ): bool {
global $yourls_user_passwords;
return( isset( $yourls_user_passwords[ $user ] )
- && substr( $yourls_user_passwords[ $user ], 0, 4 ) == 'md5:'
- && strlen( $yourls_user_passwords[ $user ] ) == 42 // http://www.google.com/search?q=the+answer+to+life+the+universe+and+everything
+ && str_starts_with($yourls_user_passwords[$user], 'md5:')
+ && strlen( $yourls_user_passwords[ $user ] ) == 42 // https://www.google.com/search?q=the+answer+to+life+the+universe+and+everything
);
}
@@ -321,6 +337,7 @@ function yourls_has_md5_password( $user ) {
* Check if a user password is 'phpass:[lots of chars]'.
* (For historical reason we're using 'phpass' as an identifier.)
* TODO: deprecate this when/if we have proper user management with password hashes stored in the DB
+ * In such case, check password_needs_rehash()
*
* @since 1.7
* @param string $user user login
@@ -341,7 +358,7 @@ function yourls_has_phpass_password( $user ) {
function yourls_check_auth_cookie() {
global $yourls_user_passwords;
foreach( $yourls_user_passwords as $valid_user => $valid_password ) {
- if ( yourls_cookie_value( $valid_user ) === $_COOKIE[ yourls_cookie_name() ] ) {
+ if ( hash_equals( yourls_cookie_value( $valid_user ), (string) $_COOKIE[ yourls_cookie_name() ] ) ) {
yourls_set_user( $valid_user );
return true;
}
@@ -357,12 +374,15 @@ function yourls_check_auth_cookie() {
* Since 1.7.7 we allow a `hash` parameter and an arbitrary hashed signature, hashed
* with the `hash` function. Examples :
* http://sho.rt/yourls-api.php?timestamp=&signature=&hash=sha512&action=...
- * http://sho.rt/yourls-api.php?timestamp=&signature=&hash=crc32&action=...
+ * Since 1.10.5, the hash must be one of: sha256, sha384, or sha512, unless explicitly allowed by a plugin via the
+ * `allowed_hash_algos` filter.
+ *
+ * @see https://yourls.org/docs/guide/advanced/passwordless-api
*
* @since 1.4.1
* @return bool False if signature or timestamp missing or invalid, true if valid
*/
-function yourls_check_signature_timestamp() {
+function yourls_check_signature_timestamp(): bool {
if( !isset( $_REQUEST['signature'] ) OR empty( $_REQUEST['signature'] )
OR !isset( $_REQUEST['timestamp'] ) OR empty( $_REQUEST['timestamp'] )
) {
@@ -374,9 +394,9 @@ function yourls_check_signature_timestamp() {
return false;
}
- // if there is a hash argument, make sure it's part of the availables algos
- $hash_function = isset($_REQUEST['hash']) ? (string)$_REQUEST['hash'] : 'md5';
- if( !in_array($hash_function, hash_algos()) ) {
+ // if there is a hash argument, make sure it's part of the available and allowed hash algorithms
+ $hash_function = isset($_REQUEST['hash']) ? (string)$_REQUEST['hash'] : yourls_default_hash_algo();
+ if( !in_array($hash_function, hash_algos()) OR !in_array( $hash_function, yourls_allowed_hash_algos() ) ) {
return false;
}
@@ -384,9 +404,9 @@ function yourls_check_signature_timestamp() {
global $yourls_user_passwords;
foreach( $yourls_user_passwords as $valid_user => $valid_password ) {
if (
- hash( $hash_function, $_REQUEST['timestamp'].yourls_auth_signature( $valid_user ) ) === $_REQUEST['signature']
+ hash_equals( hash( $hash_function, $_REQUEST['timestamp'].yourls_auth_signature( $valid_user ) ), (string) $_REQUEST['signature'] )
or
- hash( $hash_function, yourls_auth_signature( $valid_user ).$_REQUEST['timestamp'] ) === $_REQUEST['signature']
+ hash_equals( hash( $hash_function, yourls_auth_signature( $valid_user ).$_REQUEST['timestamp'] ), (string) $_REQUEST['signature'] )
) {
yourls_set_user( $valid_user );
return true;
@@ -397,6 +417,26 @@ function yourls_check_signature_timestamp() {
return false;
}
+/**
+ * Helper function: return default hash algorithm for signature hashing, which is sha256 unless filtered
+ *
+ * @since 1.10.5
+ * @return string default hash algorithm for signature hashing
+ */
+function yourls_default_hash_algo(): string {
+ return yourls_apply_filter('default_hash_algo', 'sha256');
+}
+
+/**
+ * Helper function: return list of allowed hash algorithms for signature hashing, which by default are sha256, sha384, and sha512 unless filtered
+ *
+ * @since 1.10.5
+ * @return array list of allowed hash algorithms for signature hashing
+ */
+function yourls_allowed_hash_algos(): array {
+ return yourls_apply_filter( 'allowed_hash_algos', ['sha256', 'sha384', 'sha512'] );
+}
+
/**
* Check auth against signature. Sets user if applicable, returns bool
*
@@ -410,7 +450,7 @@ function yourls_check_signature() {
// Check signature against all possible users
global $yourls_user_passwords;
foreach( $yourls_user_passwords as $valid_user => $valid_password ) {
- if ( yourls_auth_signature( $valid_user ) === $_REQUEST['signature'] ) {
+ if ( hash_equals( yourls_auth_signature( $valid_user ), (string) $_REQUEST['signature'] ) ) {
yourls_set_user( $valid_user );
return true;
}
@@ -423,16 +463,29 @@ function yourls_check_signature() {
/**
* Generate secret signature hash
*
- * @param false|string $username Username to generate signature for, or false to use current user
+ * @param false|string $username Username to generate signature for, or false to use current user
* @return string Signature
*/
-function yourls_auth_signature( $username = false ) {
+function yourls_auth_signature(false|string $username = false ): string {
if( !$username && defined('YOURLS_USER') ) {
$username = YOURLS_USER;
}
- return ( $username ? substr( yourls_salt( $username ), 0, 10 ) : 'Cannot generate auth signature: no username' );
+ $signature = $username ? substr(yourls_salt('api:' . $username), 0, yourls_auth_signature_length()) : 'Cannot generate auth signature: no username';
+
+ return yourls_apply_filter( 'auth_signature', $signature, $username );
}
+
+/**
+ * Return length of auth signature, which is 32 chars by default unless filtered
+ *
+ * @return int
+ */
+function yourls_auth_signature_length(): int {
+ return (int)yourls_apply_filter( 'auth_signature_length', 32 );
+}
+
+
/**
* Check if timestamp is not too old
*
@@ -451,7 +504,7 @@ function yourls_check_timestamp( $time ) {
* @param string $user User login, or empty string to delete cookie
* @return void
*/
-function yourls_store_cookie( $user = '' ) {
+function yourls_store_cookie(string $user = '' ): void {
// No user will delete the cookie with a cookie time from the past
if( !$user ) {
@@ -460,14 +513,11 @@ function yourls_store_cookie( $user = '' ) {
$time = time() + yourls_get_cookie_life();
}
- $path = yourls_apply_filter( 'setcookie_path', '/' );
- $domain = yourls_apply_filter( 'setcookie_domain', parse_url( yourls_get_yourls_site(), PHP_URL_HOST ) );
- $secure = yourls_apply_filter( 'setcookie_secure', yourls_is_ssl() );
- $httponly = yourls_apply_filter( 'setcookie_httponly', true );
-
- // Some browsers refuse to store localhost cookie
- if ( $domain == 'localhost' )
- $domain = '';
+ $attr = yourls_cookie_attributes();
+ $path = $attr['path'];
+ $domain = $attr['domain'];
+ $secure = $attr['secure'];
+ $httponly = $attr['httponly'];
yourls_do_action( 'pre_setcookie', $user, $time, $path, $domain, $secure, $httponly );
@@ -510,6 +560,66 @@ function yourls_setcookie($name, $value, $expire, $path, $domain, $secure, $http
)));
}
+/**
+ * Get auth cookie attributes after filters
+ *
+ * Single source of truth used both when storing the cookie and when deriving the
+ * cookie name prefix in yourls_cookie_name_prefix(). This guarantees the prefix
+ * matches the attributes actually sent: otherwise the browser silently rejects
+ * the Set-Cookie and breaks login.
+ *
+ * @since 1.10.5
+ * @return array Associative array with keys 'path', 'domain', 'secure', 'httponly'
+ */
+function yourls_cookie_attributes(): array {
+ // Cast to string so a null/false return from parse_url normalises to '' and the
+ // __Host- check below stays deterministic
+ $domain = (string) yourls_apply_filter( 'setcookie_domain', parse_url( yourls_get_yourls_site(), PHP_URL_HOST ) );
+
+ // Some browsers refuse to store localhost cookie
+ if ( $domain === 'localhost' ) {
+ $domain = '';
+ }
+
+ return array(
+ 'path' => yourls_apply_filter( 'setcookie_path', '/' ),
+ 'domain' => $domain,
+ 'secure' => yourls_apply_filter( 'setcookie_secure', yourls_is_ssl() ),
+ 'httponly' => yourls_apply_filter( 'setcookie_httponly', true ),
+ );
+}
+
+/**
+ * Get the cookie name prefix matching the current cookie attributes
+ *
+ * Picks the strongest RFC 6265bis prefix the attributes allow, since both prefixes
+ * are mutually exclusive (a cookie has one name):
+ * __Host- : requires Secure + Path=/ + no Domain attribute (host-only cookie).
+ * On HTTPS, retires the #1673 cross-subdomain concern at the browser
+ * level: the cookie cannot leak to nor be set by sibling subdomains.
+ * __Secure- : requires Secure only. Used when a Domain attribute is set, or when
+ * the cookie path is not '/'. Blocks Set-Cookie from insecure channels.
+ * '' : on HTTP installs, no prefix is possible: the browser would reject
+ * any prefixed cookie that lacks the Secure attribute.
+ *
+ * @since 1.10.5
+ * @return string '__Host-', '__Secure-' or '' depending on cookie attributes
+ */
+function yourls_cookie_name_prefix(): string {
+ $attr = yourls_cookie_attributes();
+
+ // HTTP: browser would reject any prefixed cookie
+ if ( !$attr['secure'] ) {
+ return '';
+ }
+ // Strongest: host-only at root path
+ if ( $attr['domain'] === '' && $attr['path'] === '/' ) {
+ return '__Host-';
+ }
+ // Fallback: a Domain is set or path is not '/'
+ return '__Secure-';
+}
+
/**
* Set user name
*
@@ -525,43 +635,49 @@ function yourls_set_user( $user ) {
* Get YOURLS_COOKIE_LIFE value (ie the life span of an auth cookie in seconds)
*
* Use this function instead of directly using the constant. This way, its value can be modified by plugins
- * on a per case basis
+ * on a per case basis. Defaults to 7 days when YOURLS_COOKIE_LIFE is not defined.
*
* @since 1.7.7
- * @see includes/Config/Config.php
* @return integer cookie life span, in seconds
*/
-function yourls_get_cookie_life() {
- return yourls_apply_filter( 'get_cookie_life', YOURLS_COOKIE_LIFE );
+function yourls_get_cookie_life(): int {
+ $life = defined( 'YOURLS_COOKIE_LIFE' ) ? YOURLS_COOKIE_LIFE : 60 * 60 * 24 * 7; // 7 days
+ return yourls_apply_filter( 'get_cookie_life', $life );
}
/**
* Get YOURLS_NONCE_LIFE value (ie life span of a nonce in seconds)
*
* Use this function instead of directly using the constant. This way, its value can be modified by plugins
- * on a per case basis
+ * on a per case basis. Defaults to 12 hours when YOURLS_NONCE_LIFE is not defined.
*
* @since 1.7.7
- * @see includes/Config/Config.php
* @see https://en.wikipedia.org/wiki/Cryptographic_nonce
* @return integer nonce life span, in seconds
*/
-function yourls_get_nonce_life() {
- return yourls_apply_filter( 'get_nonce_life', YOURLS_NONCE_LIFE );
+function yourls_get_nonce_life(): int {
+ $life = defined( 'YOURLS_NONCE_LIFE' ) ? YOURLS_NONCE_LIFE : 60 * 60 * 12; // 12 hours
+ return yourls_apply_filter( 'get_nonce_life', $life );
}
/**
* Get YOURLS cookie name
*
- * The name is unique for each install, to prevent mismatch between sho.rt and very.sho.rt -- see #1673
+ * The base name is unique per install (salt of the site URL) to prevent collision between eg sho.rt
+ * and very.sho.rt - see #1673.
+ * On HTTPS, the name is additionally prefixed with __Host- or __Secure- to enable browser-side
+ * hardening against cookie injection over insecure channels and, for __Host-, cross-subdomain reads or writes.
+ * See #2785
*
* TODO: when multi user is implemented, the whole cookie stuff should be reworked to allow storing multiple users
*
* @since 1.7.1
* @return string unique cookie name for a given YOURLS site
*/
-function yourls_cookie_name() {
- return yourls_apply_filter( 'cookie_name', 'yourls_' . yourls_salt( yourls_get_yourls_site() ) );
+function yourls_cookie_name(): string {
+ $name = yourls_cookie_name_prefix() . 'yourls_' . yourls_salt( yourls_get_yourls_site() );
+
+ return yourls_apply_filter( 'cookie_name', $name );
}
/**
@@ -572,7 +688,7 @@ function yourls_cookie_name() {
* @return string cookie value
*/
function yourls_cookie_value( $user ) {
- return yourls_apply_filter( 'set_cookie_value', yourls_salt( $user ?? '' ), $user );
+ return yourls_apply_filter( 'set_cookie_value', yourls_salt( 'cookie:' . ($user ?? '') ), $user );
}
/**
@@ -586,6 +702,18 @@ function yourls_tick() {
return ceil( time() / yourls_get_nonce_life() );
}
+/**
+ * Get the cookie key (secret used for hashing), as defined in config, filtered
+ *
+ * This is the secret key used by yourls_salt() to hash cookies and nonces.
+ *
+ * @since 1.10.5
+ * @return string Cookie key
+ */
+function yourls_get_cookie_key(): string {
+ return yourls_apply_filter( 'get_cookie_key', YOURLS_COOKIEKEY );
+}
+
/**
* Return hashed string
*
@@ -595,8 +723,8 @@ function yourls_tick() {
* @param string $string string to salt
* @return string hashed string
*/
-function yourls_salt( $string ) {
- $salt = defined('YOURLS_COOKIEKEY') ? YOURLS_COOKIEKEY : md5(__FILE__) ;
+function yourls_salt(string $string ): string {
+ $salt = yourls_get_cookie_key();
return yourls_apply_filter( 'yourls_salt', hash_hmac( yourls_hmac_algo(), $string, $salt), $string );
}
@@ -692,7 +820,7 @@ function yourls_verify_nonce($action, $nonce = false, $user = false, $return = '
// What nonce should be
$valid = yourls_create_nonce( $action, $user );
- if( $nonce === $valid ) {
+ if( hash_equals( $valid, (string) $nonce ) ) {
return true;
} else {
if( $return )
@@ -716,7 +844,7 @@ function yourls_is_user_from_env() {
*
* By default, passwords are hashed. They are not if
* - there is no password in clear text in the config file (ie everything is already hashed)
- * - the user defined constant YOURLS_NO_HASH_PASSWORD is true, see https://docs.yourls.org/guide/essentials/credentials.html#i-don-t-want-to-encrypt-my-password
+ * - the user defined constant YOURLS_NO_HASH_PASSWORD is true, see https://yourls.org/docs/guide/essentials/credentials#i-don-t-want-to-encrypt-my-password
* - YOURLS_USER and YOURLS_PASSWORD are provided by the environment, not the config file
*
* @since 1.8.2
diff --git a/includes/functions-formatting.php b/includes/functions-formatting.php
index e0c7f49..b9af691 100644
--- a/includes/functions-formatting.php
+++ b/includes/functions-formatting.php
@@ -522,6 +522,79 @@ function yourls_esc_html( $text ) {
return yourls_apply_filter( 'esc_html', $safe_text, $text );
}
+/**
+ * Escape HTML text, but allow some tags and attributes.
+ *
+ * Useful when you want to allow only some HTML tags in a string (, ...)
+ * Escape everything first with yourls_esc_html(), then selectively un-escape only whitelisted
+ * tags and attributes. Attributes not listed are stripped, href/src values are sanitized.
+ *
+ * @since 1.10.5
+ * @param string $string String to escape
+ * @param array $allowed Tag => [attr => true, ...] map, or null for KSES defaults
+ * @return string
+ */
+function yourls_esc_html_with_whitelist(string $string, array $allowed = []): string {
+ $string = yourls_esc_html($string);
+
+ if (empty($allowed)) {
+ return $string;
+ }
+
+ $allowed = yourls_kses_array_lc($allowed);
+
+ return preg_replace_callback(
+ '/<(\/?)([\w]+)(.*?)>/',
+ function ($m) use ($allowed) {
+ $closing = $m[1];
+ $tag = strtolower($m[2]);
+ $raw = $m[3];
+
+ if (!isset($allowed[$tag])) {
+ return $m[0];
+ }
+
+ if ($closing === '/') {
+ return "$tag>";
+ }
+
+ $attrs = '';
+ $allowed_attrs = $allowed[$tag];
+
+ if (!empty($allowed_attrs) && $raw !== '') {
+ preg_match_all(
+ '/\s+([\w-]+)=(?:"(.*?)"|'(.*?)')/',
+ $raw,
+ $matches,
+ PREG_SET_ORDER
+ );
+
+ foreach ($matches as $match) {
+ $name = strtolower($match[1]);
+ $value = ($match[2] ?? '') !== '' ? $match[2] : ($match[3] ?? '');
+
+ if (empty($allowed_attrs[$name])) {
+ continue;
+ }
+
+ $value = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
+ $value = yourls_kses_no_null($value);
+
+ if ($name === 'href' || $name === 'src') {
+ $value = yourls_sanitize_url($value);
+ }
+
+ $value = yourls_esc_attr($value);
+ $attrs .= " $name=\"$value\"";
+ }
+ }
+
+ return "<$tag$attrs>";
+ },
+ $string
+ );
+}
+
/**
* Escaping for HTML attributes. Stolen from WP
*
diff --git a/includes/functions-html.php b/includes/functions-html.php
index 4fe0a05..8a6de56 100644
--- a/includes/functions-html.php
+++ b/includes/functions-html.php
@@ -86,7 +86,7 @@ function yourls_html_head( $context = 'index', $title = '' ) {
>
-
+
@@ -131,7 +131,7 @@ function yourls_html_head( $context = 'index', $title = '' ) {
-
+