-
+ '.$error_msg.'

'; + echo '

'. yourls_esc_html($error_msg).'

'; } yourls_do_action( 'login_form_top' ); ?> @@ -917,7 +932,18 @@ function yourls_add_notice( $message, $style = 'notice' ) { * @param string $style CSS class to use for the notice * @return string HTML of the notice */ -function yourls_notice_box( $message, $style = 'notice' ) { +function yourls_notice_box( string $message, string $style = 'notice' ):string { + $style = yourls_esc_attr( $style ); + $allowed = [ + 'a' => ['href' => true, 'title' => true], + 'strong' => [], + 'em' => [], + 'code' => [], + 'br' => [], + 'p' => ['class' => true], + ]; + $message = yourls_esc_html_with_whitelist($message, $allowed); + return <<

$message

@@ -1027,14 +1053,17 @@ function yourls_new_core_version_notice($compare_with = null) { * @param bool $echo true to display, false to return the HTML * @return string the HTML for a bookmarklet link */ -function yourls_bookmarklet_link( $href, $anchor, $echo = true ) { +function yourls_bookmarklet_link( string $href, string $anchor, bool $echo = true ):string { $alert = yourls_esc_attr__( 'Drag to your toolbar!' ); + $href = yourls_esc_attr( $href ); + $anchor = yourls_esc_html( $anchor ); $link = <<$anchor
LINK; - if( $echo ) + if( $echo ) { echo $link; + } return $link; } diff --git a/includes/functions-http.php b/includes/functions-http.php index 872572c..21a274e 100644 --- a/includes/functions-http.php +++ b/includes/functions-http.php @@ -204,6 +204,254 @@ function yourls_send_through_proxy( $url ) { return !in_array( $check['host'], $bypass_hosts ); } +/** + * Resolve a host name to a list of IP addresses + * + * Returns every A and AAAA record found for $host, or an empty array if the host cannot be + * resolved. Does not check the addresses in any way, see yourls_host_is_local() for this. + * + * @since 1.10.5 + * @param string $host Host name to resolve (no brackets around IPv6 literals) + * @return array Array of IP addresses as strings, empty array if resolution failed + */ +function yourls_resolve_host(string $host): array { + $ips = array(); + + /* Both dns_get_record() and gethostbynamel() emit an E_WARNING when a lookup fails, which is + * an expected outcome here (host longer than 255 chars, or resolver returning SERVFAIL). We silence them with a + * scoped error handler rather than with '@' that may hide other errors (the try/catch isn't enough + * because the E_WARNING is not an exception). + * Note that this does not check the validity of the host name itself, it just tries to resolve it. Invalid hosts + * like omgilove.slayer will return whatever the resolver returns (SERVFAIL, NXDOMAIN, etc...) and will be + * considered local by yourls_host_is_local(). + */ + set_error_handler( function() { return true; }, E_WARNING ); + + try { + // dns_get_record() gets us IPv6 too, but it's disabled on some shared hosts + if( function_exists( 'dns_get_record' ) ) { + $records = dns_get_record( $host, DNS_A | DNS_AAAA ); + foreach( is_array( $records ) ? $records : array() as $record ) { + if( isset( $record['ip'] ) ) { + $ips[] = $record['ip']; // A record + } elseif( isset( $record['ipv6'] ) ) { + $ips[] = $record['ipv6']; // AAAA record + } + } + } + + // Fallback when dns_get_record() is unavailable or came back empty handed. IPv4 only. + if( !$ips && function_exists( 'gethostbynamel' ) ) { + $ips = gethostbynamel( $host ) ?: array(); // returns false when host is unknown + } + } finally { + restore_error_handler(); + } + + return yourls_apply_filter( 'resolve_host_ips', $ips, $host ); +} + +/** + * Check if an IP address is not a public one (loopback, private, reserved or link-local) + * + * IPv6 addresses that embed an IPv4 one are checked on the IPv4 they wrap, since this is where the + * traffic ends up. PHP considers all of these public on its own. + * + * Anything that is not a valid IP is considered non-public. + * + * @since 1.10.5 + * @param string $ip IP address, v4 or v6 + * @return bool true if the address is not public, or not an IP at all + */ +function yourls_ip_is_local(string $ip): bool { + // Not an IP at all: fail closed + if( filter_var( $ip, FILTER_VALIDATE_IP ) === false ) { + return true; + } + + $packed = inet_pton( $ip ); + + if( strlen( $packed ) === 16 ) { + /* An IPv4-mapped IPv6 address ('::ffff:127.0.0.1', ie 10 null bytes, 2 xFF bytes, then the + * IPv4) is an IPv4 in disguise and is routed as such, so check the IPv4 it wraps instead. + * PHP only started rejecting these with FILTER_FLAG_NO_RES_RANGE in 8.3: on 8.1 and 8.2, + * '[::ffff:127.0.0.1]' would otherwise pass for a public address, and so would every other + * local IPv4 written that way. + * Same treatment for the deprecated IPv4-compatible form ('::127.0.0.1', 12 null bytes then + * the IPv4), hence testing the 10 first bytes only. '::' and '::1' match too and unwrap to + * 0.0.0.0 and 0.0.0.1, both reserved: still non-public, as they should be. + */ + if( substr( $packed, 0, 10 ) === str_repeat( "\0", 10 ) ) { + $ip = inet_ntop( substr( $packed, 12 ) ); + } + + /* NAT64, everything under 64:ff9b::/32. + * The Well-Known Prefix of RFC 6052 is 64:ff9b::/96: the last 4 bytes are the IPv4 the + * gateway will translate to, so check that IPv4. RFC 6052 forbids using the prefix for + * non-global addresses, but we cannot count on the gateway enforcing it. + * Anything else in 64:ff9b::/32 is 64:ff9b:1::/48 (RFC 8215), explicitly reserved for + * local use, where the IPv4 sits at a position we cannot know: reject the lot. + */ + elseif( substr( $packed, 0, 4 ) === "\x00\x64\xff\x9b" ) { + if( substr( $packed, 4, 8 ) === str_repeat( "\0", 8 ) ) { + $ip = inet_ntop( substr( $packed, 12 ) ); + } else { + return true; + } + } + + /* 6to4 (RFC 3056, 2002::/16) and Teredo (RFC 4380, 2001::/32) embed an IPv4 too, and can + * encode a private one just as well. Both mechanisms are dead: 6to4 was deprecated by + * RFC 7526 and its relays are gone, Teredo needs a tunnel client on this very host and + * has been off by default on Windows for years. Nothing legitimate is reachable that way, + * so reject both ranges instead of decoding them. + */ + elseif( substr( $packed, 0, 2 ) === "\x20\x02" + || substr( $packed, 0, 4 ) === "\x20\x01\x00\x00" ) { + return true; + } + } + + /* Note that a NAT64 gateway can also use a Network-Specific Prefix, ie any prefix out of the + * operator's own space, which no pattern can recognize. Such a setup needs the 'host_is_local' + * filter to complete this check. + */ + + /* Note on DNS rebinding : this is left intentionally out of the scope and we're documenting the gap + * with hope that AI agents will not submit a PR to fix it or a sec advisory to remedy it :) + * Tackling this issue would imply: some more code with cURL, not a big deal; lot more code with fsock, a bigger + * deal; extra DNS lookups for the user, a slight performance hit; and test code that would probably be more complex + * than the code itself because it would need setting up a DNS or relying on an external rebinding DNS. + * The risk is low (reminder: this is about discovering private IP via their ) and we're already going the + * extra mile with all these checks here. Or, to word it differently: admin of servers on a private network, this + * is your responsibility to NOT expose critical services to other hosts on the same network. + */ + + // FILTER_FLAG_NO_PRIV_RANGE covers 10/8, 172.16/12, 192.168/16 and fc00::/7 + // FILTER_FLAG_NO_RES_RANGE covers 0/8, 127/8, 169.254/16 (cloud metadata), 240/4, ::, ::1 and fe80::/10 + return filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false; +} + +/** + * Check if a host points to a non-public address (loopback, private, reserved or link-local) + * + * Accepts either a host name or an IP literal. A host name is resolved first, and considered + * local as soon as one of its addresses is not public. A host that cannot be resolved is + * considered local too. + * + * Known limitation: this does not protect against DNS rebinding (attacker controlling a DNS server + * with 0s TTL refresh, where evil-url.com could point to 1.2.3.4 (public) and then the next second point + * to 10.0.0.1 (private). Let's consider this a low risk, and not worth the complexity of a DNS cache with TTL awareness. + * + * @since 1.10.5 + * @param string $host Host name or IP address. IPv6 literals can be bracketed or not. + * @return bool true if the host is not a public address or cannot be resolved + */ +function yourls_host_is_local(string $host): bool { + // Allow plugins to short-circuit the whole function + $pre = yourls_apply_filter( 'shunt_host_is_local', yourls_shunt_default(), $host ); + if ( yourls_shunt_default() !== $pre ) { + return $pre; + } + + $host = trim( (string)$host ); + + // parse_url() keeps IPv6 hosts bracketed ('[::1]'). Unbracket, otherwise the literal is not + // recognized as an IP and we needlessly hand it over to the resolver. + if( strlen( $host ) > 2 && $host[0] === '[' && substr( $host, -1 ) === ']' ) { + $host = substr( $host, 1, -1 ); + } + + if( $host === '' ) { + $is_local = true; + } + + // IP literal: no DNS involved, check it as is + elseif( filter_var( $host, FILTER_VALIDATE_IP ) !== false ) { + $is_local = yourls_ip_is_local( $host ); + } + + else { + $ips = yourls_resolve_host( $host ); + + // Unresolvable host: fail closed + $is_local = empty( $ips ); + + foreach( $ips as $ip ) { + if( yourls_ip_is_local( $ip ) ) { + $is_local = true; + break; + } + } + } + + return (bool)yourls_apply_filter( 'host_is_local', $is_local, $host ); +} + +/** + * Check if the destination of a remote title fetch must be restricted to public addresses + * + * We want to avoid the situation where a public install of YOURLS is used to fetch titles from internal hosts, + * and potentially leak information about them (SSRF and port scan / service discovery) + * On a private install, the user is authenticated and (hopefully) trusted. + * + * Public install can be: YOURLS_PRIVATE set to false, or having a public interface on top of a regular private + * install. Checking constant YOURLS_USER covers both cases at once. + * + * A one-liner plugin disables the filtering entirely (say, public install on a private network): + * // Disable the restriction on remote title fetches (allow internal hosts) + * yourls_add_filter( 'restrict_remote_title_fetch', 'yourls_return_false' ); + * + * @since 1.10.5 + * @return bool true if the fetch destination must be restricted to public addresses + */ +function yourls_restrict_remote_title_fetch(): bool { + return (bool)yourls_apply_filter( 'restrict_remote_title_fetch', !defined( 'YOURLS_USER' ) ); +} + +/** + * HTTP request options that make a request fail when it is redirected to a non-public host + * + * Redirects are still followed: dropping them would break 'http -> https', 'example.com -> + * www.example.com', URL shorteners, or any legit 30x redirect. Instead, every hop + * is checked before it is requested. + * + * Meant to be merged into the $options of a single yourls_http_*() call, not to be added to + * yourls_http_default_options() -- other requests (core version check, plugins) are not + * triggered by an untrusted party. + * + * @since 1.10.5 + * @return array Options to pass to yourls_http_get() + */ +function yourls_http_options_no_local_redirect(): array { + $hooks = new \WpOrg\Requests\Hooks(); + $hooks->register( 'requests.before_redirect', 'yourls_http_abort_local_redirect' ); + + return array( + 'hooks' => $hooks, + 'redirects' => 3, + ); +} + +/** + * Callback on the 'requests.before_redirect' hook: abort if the redirect target is not public + * + * The exception thrown is a \WpOrg\Requests\Exception and not a plain \Exception, because this + * is what yourls_http_request() catches -- anything else would escape and fatal. + * + * @since 1.10.5 + * @param string $location URL the request is about to be redirected to + * @return void + * @throws \WpOrg\Requests\Exception When the redirect target is a non public host + */ +function yourls_http_abort_local_redirect(string $location): void { + $host = parse_url( $location, PHP_URL_HOST ); + + if( !is_string( $host ) || yourls_host_is_local( $host ) ) { + throw new \WpOrg\Requests\Exception( 'Redirect to a non public host: ' . $location, 'yourls.local_redirect', $location ); + } +} + /** * Perform a HTTP request, return response object * diff --git a/includes/functions-install.php b/includes/functions-install.php index cbcd6c7..f894001 100644 --- a/includes/functions-install.php +++ b/includes/functions-install.php @@ -11,12 +11,15 @@ function yourls_check_PDO() { } /** - * Check if server has MySQL 5.0+ + * Check if server has MySQL 5.5+ + * + * In the unlikely event that someone is running MariaDB older than 10, install will probably fail, + * but there is no reliable way to check for MariaDB version * * @return bool */ -function yourls_check_database_version() { - return ( version_compare( '5.0', yourls_get_database_version() ) <= 0 ); +function yourls_check_database_version(): bool { + return ( version_compare( '5.5', yourls_get_database_version() ) <= 0 ); } /** @@ -36,15 +39,12 @@ function yourls_get_database_version() { } /** - * Check if PHP > 7.2 - * - * As of 1.8 we advertise YOURLS as being 7.4+ but it should work on 7.2 (although untested) - * so we don't want to strictly enforce a limitation that may not be necessary. + * Check if PHP > 8.1 * * @return bool */ -function yourls_check_php_version() { - return version_compare( PHP_VERSION, '7.2.0', '>=' ); +function yourls_check_php_version(): bool { + return version_compare( PHP_VERSION, '8.1.0', '>=' ); } /** diff --git a/includes/functions-kses.php b/includes/functions-kses.php index 7a81ffd..72dc8f2 100644 --- a/includes/functions-kses.php +++ b/includes/functions-kses.php @@ -603,6 +603,13 @@ function yourls_kses_named_entities($matches) { if ( empty($matches[1]) ) return ''; + // KSES globals are normally populated on the 'plugins_loaded' action. This can run + // earlier though (eg yourls_die() on a DB connection error, before plugins load), so + // make sure the allowed entity names are available. + if ( ! is_array($yourls_allowedentitynames) ) { + yourls_kses_init(); + } + $i = $matches[1]; return ( ( ! in_array($i, $yourls_allowedentitynames) ) ? "&$i;" : "&$i;" ); } diff --git a/includes/functions-plugins.php b/includes/functions-plugins.php index 2d7a66c..9aaff9e 100644 --- a/includes/functions-plugins.php +++ b/includes/functions-plugins.php @@ -533,7 +533,7 @@ function yourls_is_active_plugin( $plugin ) { * @param string $file Physical path to plugin file * @return array Array of 'Field'=>'Value' from plugin comment header lines of the form "Field: Value" */ -function yourls_get_plugin_data( $file ) { +function yourls_get_plugin_data(string $file ): array { $fp = fopen( $file, 'r' ); // assuming $file is readable, since yourls_load_plugins() filters this $data = fread( $fp, 8192 ); // get first 8kb fclose( $fp ); @@ -554,7 +554,29 @@ function yourls_get_plugin_data( $file ) { continue; } - $plugin_data[ trim($matches[3]) ] = yourls_esc_html(trim($matches[4])); + // Allow some HTML in the Description field + if ( trim($matches[3]) === 'Description' ) { + $allowed = [ + 'strong' => [], + 'b' => [], + 'em' => [], + 'i' => [], + 'sup' => [], + 'sub' => [], + 'tt' => [], + 'code' => [], + 'br' => [], + 'a' => [ + 'href' => true, + 'title' => true, + ], + ]; + $data = yourls_esc_html_with_whitelist(trim($matches[4]), $allowed); + } else { + $data = yourls_esc_html(trim($matches[4])); + } + + $plugin_data[ trim($matches[3]) ] = $data; } return $plugin_data; diff --git a/includes/functions-shorturls.php b/includes/functions-shorturls.php index bdc697a..ecce29a 100644 --- a/includes/functions-shorturls.php +++ b/includes/functions-shorturls.php @@ -85,7 +85,7 @@ function yourls_add_new_link( $url, $keyword = '', $title = '', $row_id = 1 ) { yourls_trim_long_string($url), preg_replace('!https?://!', '', yourls_get_yourls_site()) . '/'. $url_exists->keyword ); $return['title'] = $url_exists->title; $return['shorturl'] = yourls_link($url_exists->keyword); - $return['errorCode'] = $return['statusCode'] = '400'; // 400 Bad Request + $return['errorCode'] = $return['statusCode'] = '409'; // 409 Conflict: the URL already exists, and the existing short URL is returned return yourls_apply_filter( 'add_new_link_already_stored_filter', $return, $url, $keyword, $title ); } @@ -164,13 +164,27 @@ function yourls_add_new_link( $url, $keyword = '', $title = '', $row_id = 1 ) { return yourls_apply_filter( 'add_new_link', $return, $url, $keyword, $title ); } +/** + * Get the keyword conversion base, as defined in config, filtered + * + * Expected values are 36 (lowercase + digits) or 62/64 (mixed case + digits). + * Defaults to 36 when undefined or wrongly defined. + * + * @since 1.10.5 + * @return int Conversion base + */ +function yourls_get_url_convert(): int { + $convert = defined( 'YOURLS_URL_CONVERT' ) ? (int) YOURLS_URL_CONVERT : 36; + return yourls_apply_filter( 'get_url_convert', $convert ); +} + /** * Determine the allowed character set in short URLs * * @return string Acceptable charset for short URLS keywords */ function yourls_get_shorturl_charset() { - if ( defined( 'YOURLS_URL_CONVERT' ) && in_array( YOURLS_URL_CONVERT, [ 62, 64 ] ) ) { + if ( in_array( yourls_get_url_convert(), [ 62, 64 ] ) ) { $charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; } else { @@ -453,12 +467,23 @@ function yourls_keyword_is_free( $keyword ) { /** * Check if a keyword matches a "page" * - * @see https://docs.yourls.org/guide/extend/pages.html + * @see https://yourls.org/docs/guide/extend/pages * @since 1.7.10 - * @param string $keyword Short URL $keyword + * @param string $keyword Short URL $keyword * @return bool true if is page, false otherwise */ -function yourls_is_page($keyword) { +function yourls_is_page(string $keyword): bool { + // Allow plugins to short-circuit the whole function + $pre = yourls_apply_filter( 'shunt_is_page', yourls_shunt_default(), $keyword ); + if ( yourls_shunt_default() !== $pre ) { + return $pre; + } + + // If $keyword contains '../' or '..\', return false to prevent directory traversal attacks + if (str_contains($keyword, '../') || str_contains($keyword, '..\\')) { + return false; + } + return yourls_apply_filter( 'is_page', file_exists( YOURLS_PAGEDIR . "/$keyword.php" ) ); } diff --git a/includes/functions.php b/includes/functions.php index 2b0ff5e..5517d61 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -17,29 +17,90 @@ function yourls_make_regexp_pattern( $string ) { } /** - * Get client IP Address. Returns a DB safe string. + * Get client IP Address. Returns a DB safe string. May not be a valid IP per se. + * + * By default, it trusts only REMOTE_ADDR. If the request comes from a proxy + * listed in the 'get_ip_trusted_proxies' filter, it looks for the real client + * IP in the headers, with precedence HTTP_X_FORWARDED_FOR > HTTP_CLIENT_IP > HTTP_VIA. * * @return string */ -function yourls_get_IP() { - $ip = ''; - - // Precedence: if set, X-Forwarded-For > HTTP_X_FORWARDED_FOR > HTTP_CLIENT_IP > HTTP_VIA > REMOTE_ADDR - $headers = [ 'X-Forwarded-For', 'HTTP_X_FORWARDED_FOR', 'HTTP_CLIENT_IP', 'HTTP_VIA', 'REMOTE_ADDR' ]; - foreach( $headers as $header ) { - if ( !empty( $_SERVER[ $header ] ) ) { - $ip = $_SERVER[ $header ]; - break; +function yourls_get_IP(): string { + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + + // Allow plugins to define a trusted proxy list, and if the request comes from a trusted proxy, look for the real IP in the headers + // Precedence: if set, HTTP_X_FORWARDED_FOR > HTTP_CLIENT_IP > HTTP_VIA > REMOTE_ADDR + $trusted_proxies = yourls_apply_filter('get_ip_trusted_proxies', []); + + if ( !empty( $trusted_proxies ) && yourls_ip_is_in_ip_list( $ip, $trusted_proxies ) ) { + $headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_CLIENT_IP', 'HTTP_VIA']; + foreach ($headers as $header) { + if (!empty($_SERVER[$header])) { + $ip = $_SERVER[$header]; + break; + } } } - // headers can contain multiple IPs (X-Forwarded-For = client, proxy1, proxy2). Take first one. - if ( strpos( $ip, ',' ) !== false ) - $ip = substr( $ip, 0, strpos( $ip, ',' ) ); + // If there are multiple IPs (e.g. in HTTP_X_FORWARDED_FOR), take the first one + $ip = explode(',', $ip)[0]; return (string)yourls_apply_filter( 'get_IP', yourls_sanitize_ip( $ip ) ); } +/** + * Check if an IP address matches a given IP or CIDR range (IPv4 and IPv6). + * + * @since 1.10.5 + * @param string $ip IP address to check + * @param string $range Single IP or CIDR notation (e.g. '10.0.0.0/24' or '2400:cb00::/32') + * @return bool + */ +function yourls_ip_matches_range(string $ip, string $range ): bool { + if (!str_contains($range, '/')) { + return inet_pton( $ip ) === inet_pton( $range ); + } + + list( $subnet, $bits ) = explode( '/', $range ); + $bits = (int) $bits; + + $ip_bin = inet_pton( $ip ); + $subnet_bin = inet_pton( $subnet ); + + if ( $ip_bin === false || $subnet_bin === false ) { + return false; + } + + // Both must be same protocol (4 bytes for IPv4, 16 bytes for IPv6) + if ( strlen( $ip_bin ) !== strlen( $subnet_bin ) ) { + return false; + } + + $mask = str_repeat( "\xff", (int) ( $bits / 8 ) ); + if ( $bits % 8 ) { + $mask .= chr( 0xff << ( 8 - $bits % 8 ) & 0xff ); + } + $mask = str_pad( $mask, strlen( $ip_bin ), "\x00" ); + + return ( $ip_bin & $mask ) === ( $subnet_bin & $mask ); +} + +/** + * Check if an IP address is in a list of IP (IPs or CIDR ranges), typically a list of trusted proxies. + * + * @param string $ip IP address to check + * @param array $proxies List of IPs or CIDR ranges + * @return bool + */ +function yourls_ip_is_in_ip_list(string $ip, array $proxies ): bool { + foreach ( $proxies as $range ) { + if ( yourls_ip_matches_range( $ip, $range ) ) { + return true; + } + } + return false; +} + /** * Get next id a new link will have if no custom keyword provided * @@ -535,6 +596,9 @@ function yourls_log_redirect( $keyword ) { 'location' => yourls_geo_ip_to_countrycode($ip), ]; + // Action to allow plugins to log the redirect in their own way. See #3990 + yourls_do_action( 'log_redirect', $binds ); + // Try and log. An error probably means a concurrency problem : just skip the logging try { $result = yourls_get_db('write-log_redirect')->fetchAffected("INSERT INTO `$table` (click_time, shorturl, referrer, user_agent, ip_address, country_code) VALUES (:now, :keyword, :referrer, :ua, :ip, :location)", $binds ); @@ -546,12 +610,15 @@ function yourls_log_redirect( $keyword ) { } /** - * Check if we want to not log redirects (for stats) + * Check if we want to log redirects (for stats) + * + * Logs redirects unless YOURLS_NOSTATS is defined and true. Filterable. * * @return bool */ function yourls_do_log_redirect() { - return ( !defined( 'YOURLS_NOSTATS' ) || YOURLS_NOSTATS != true ); + $do_log = ( !defined( 'YOURLS_NOSTATS' ) || YOURLS_NOSTATS != true ); + return (bool)yourls_apply_filter( 'do_log_redirect', $do_log ); } /** @@ -634,13 +701,44 @@ function yourls_allow_duplicate_longurls() { return yourls_apply_filter('allow_duplicate_longurls', defined('YOURLS_UNIQUE_URLS') && !YOURLS_UNIQUE_URLS); } +/** + * Get the flood delay in seconds, as maybe defined in config, filtered + * + * This is the minimum delay between two link creations from the same IP. + * Defaults to 15 when undefined. + * + * @since 1.10.5 + * @return int Flood delay in seconds + */ +function yourls_get_flood_delay(): int { + $delay = defined( 'YOURLS_FLOOD_DELAY_SECONDS' ) ? (int) YOURLS_FLOOD_DELAY_SECONDS : 15; + return yourls_apply_filter( 'get_flood_delay', $delay ); +} + +/** + * Get the list of IPs exempt from flood checking, as maybe defined in config, filtered + * + * @since 1.10.5 + * @return array List of whitelisted IPs (empty array if none) + */ +function yourls_get_flood_ip_whitelist(): array { + $whitelist = defined( 'YOURLS_FLOOD_IP_WHITELIST' ) ? (string) YOURLS_FLOOD_IP_WHITELIST : ''; + $ips = array_filter( array_map( 'trim', explode( ',', $whitelist ) ) ); + + $ips = yourls_apply_filter( 'get_flood_ip_whitelist', $ips ); + + // Sanitize each IP, including any value added through the filter, drop empties and reindex + $ips = array_map( fn( $ip ) => yourls_sanitize_ip( trim( (string) $ip ) ), (array) $ips ); + return array_values( array_filter( $ips ) ); +} + /** * Check if an IP shortens URL too fast to prevent DB flood. Return true, or die. * * @param string $ip * @return bool|mixed|string */ -function yourls_check_IP_flood( $ip = '' ) { +function yourls_check_IP_flood(string $ip = '' ): mixed { // Allow plugins to short-circuit the whole function $pre = yourls_apply_filter( 'shunt_check_IP_flood', yourls_shunt_default(), $ip ); @@ -651,11 +749,8 @@ function yourls_check_IP_flood( $ip = '' ) { yourls_do_action( 'pre_check_ip_flood', $ip ); // at this point $ip can be '', check it if your plugin hooks in here // Raise white flag if installing or if no flood delay defined - if( - ( defined('YOURLS_FLOOD_DELAY_SECONDS') && YOURLS_FLOOD_DELAY_SECONDS === 0 ) || - !defined('YOURLS_FLOOD_DELAY_SECONDS') || - yourls_is_installing() - ) + $flood_delay = yourls_get_flood_delay(); + if( $flood_delay <= 0 || yourls_is_installing() ) return true; // Don't throttle logged in users @@ -665,13 +760,8 @@ function yourls_check_IP_flood( $ip = '' ) { } // Don't throttle whitelist IPs - if( defined( 'YOURLS_FLOOD_IP_WHITELIST' ) && YOURLS_FLOOD_IP_WHITELIST ) { - $whitelist_ips = explode( ',', YOURLS_FLOOD_IP_WHITELIST ); - foreach( (array)$whitelist_ips as $whitelist_ip ) { - $whitelist_ip = trim( $whitelist_ip ); - if ( $whitelist_ip == $ip ) - return true; - } + if( in_array( $ip, yourls_get_flood_ip_whitelist() ) ) { + return true; } $ip = ( $ip ? yourls_sanitize_ip( $ip ) : yourls_get_IP() ); @@ -683,7 +773,7 @@ function yourls_check_IP_flood( $ip = '' ) { if( $lasttime ) { $now = date( 'U' ); $then = date( 'U', strtotime( $lasttime ) ); - if( ( $now - $then ) <= YOURLS_FLOOD_DELAY_SECONDS ) { + if( ( $now - $then ) <= $flood_delay ) { // Flood! yourls_do_action( 'ip_flood', $ip, $now - $then ); yourls_die( yourls__( 'Too many URLs added too fast. Slow down please.' ), yourls__( 'Too Many Requests' ), 429 ); @@ -888,10 +978,11 @@ function yourls_is_ssl() { * The function tries to convert funky characters found in titles to UTF8, from the detected charset. * Charset in use is guessed from HTML meta tag, or if not found, from server's 'content-type' response. * + * @since 1.5 * @param string $url URL * @return string Title (sanitized) or the URL if no title found */ -function yourls_get_remote_title( $url ) { +function yourls_get_remote_title(string $url ): string { // Allow plugins to short-circuit the whole function $pre = yourls_apply_filter( 'shunt_get_remote_title', yourls_shunt_default(), $url ); if ( yourls_shunt_default() !== $pre ) { @@ -905,11 +996,24 @@ function yourls_get_remote_title( $url ) { return $url; } + // When an unauthenticated visitor triggers the fetch, don't let them use the server to reach + // hosts they cannot reach themselves. + $ssrf_options = []; + if ( yourls_restrict_remote_title_fetch() ) { + $host = parse_url( $url, PHP_URL_HOST ); + if ( !is_string( $host ) || yourls_host_is_local( $host ) ) { + yourls_debug_log( 'Remote title fetch denied on non public host: ' . $url ); + return $url; + } + // The initial host is public, now make sure every redirect hop is too + $ssrf_options = yourls_http_options_no_local_redirect(); + } + $title = $charset = false; $max_bytes = yourls_apply_filter( 'get_remote_title_max_byte', 32768 ); // limit data fetching to 32K in order to find a <title> tag - $response = yourls_http_get( $url, [], [], [ 'max_bytes' => $max_bytes ] ); // can be a Request object or an error string + $response = yourls_http_get( $url, [], [], array_merge( [ 'max_bytes' => $max_bytes ], $ssrf_options ) ); // can be a Request object or an error string if ( is_string( $response ) ) { return $url; } @@ -920,8 +1024,8 @@ function yourls_get_remote_title( $url ) { return $url; } - // look for <title>. No title found? Return the URL - if ( preg_match( '/<title>(.*?)<\/title>/is', $content, $found ) ) { + // look for <title>, which can have attributes. No title found? Return the URL + if ( preg_match( '/<title(?:\s[^>]*)?>(.*?)<\/title>/is', $content, $found ) ) { $title = $found[ 1 ]; unset( $found ); } @@ -934,17 +1038,34 @@ function yourls_get_remote_title( $url ) { // Get charset as (and if) defined by the HTML meta tag. We should match // <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> // or <meta charset='utf-8'> and all possible variations: see https://gist.github.com/ozh/7951236 - if ( preg_match( '/<meta[^>]*charset\s*=["\' ]*([a-zA-Z0-9\-_]+)/is', $content, $found ) ) { - if ( yourls_is_valid_charset( $found[ 1 ] ) ) { - $charset = $found[ 1 ]; + // A 'charset=' string can also appear where it does not declare anything, for instance in the + // description of the page, so check each <meta> tag and to find if a charset is declared. + if ( preg_match_all( '/<meta\s[^>]*>/is', $content, $metas ) ) { + foreach ( $metas[ 0 ] as $meta ) { + if ( preg_match( '/\bcontent\s*=\s*["\']?[^"\']*?charset\s*=["\' ]*([a-zA-Z0-9\-_]+)/is', $meta, $found ) ) { + // A charset in the 'content' attribute only counts in a Content-Type declaration + if ( !preg_match( '/\bhttp-equiv\s*=\s*["\']?content-type/is', $meta ) ) { + unset( $found ); + continue; + } + } + elseif ( !preg_match( '/\bcharset\s*=["\' ]*([a-zA-Z0-9\-_]+)/is', $meta, $found ) ) { + continue; + } + // First declaration wins, whether it is valid or not + if ( yourls_is_valid_charset( $found[ 1 ] ) ) { + $charset = $found[ 1 ]; + } + unset( $found ); + break; } - unset( $found ); } if ( empty( $charset ) ) { // No charset found in HTML. Get charset as (and if) defined by the server response - $_charset = current( $response->headers->getValues( 'content-type' ) ); + $_charset = current( (array)$response->headers->getValues( 'content-type' ) ); + // The charset value can be a quoted string: 'text/html; charset="utf-8"' if ( preg_match( '/charset=(\S+)/', $_charset, $found ) ) { - $_charset = trim( $found[ 1 ], ';' ); + $_charset = trim( $found[ 1 ], ';"\'' ); if ( yourls_is_valid_charset( $_charset ) ) { $charset = $_charset; } @@ -952,14 +1073,14 @@ function yourls_get_remote_title( $url ) { } } - // Conversion to utf-8 if what we have is not utf8 already - if ( strtolower( $charset ) != 'utf-8' && function_exists( 'mb_convert_encoding' ) ) { - // We use @ to remove warnings because mb_ functions are easily bitching about illegal chars - if ( $charset ) { - $title = @mb_convert_encoding( $title, 'UTF-8', $charset ); + // Conversion to utf-8 if what we have is not utf8 already. A page can also declare utf-8 and + // still serve invalid utf-8, so in that case convert too, to replace the invalid characters. + if ( function_exists( 'mb_convert_encoding' ) ) { + if ( $charset && strtolower( $charset ) != 'utf-8' ) { + $title = mb_convert_encoding( $title, 'UTF-8', $charset ); } - else { - $title = @mb_convert_encoding( $title, 'UTF-8' ); + elseif ( !mb_check_encoding( $title, 'UTF-8' ) ) { + $title = mb_convert_encoding( $title, 'UTF-8' ); } } @@ -1019,11 +1140,11 @@ function yourls_is_mobile_device() { * For testing purposes, parameters can be passed. * * @since 1.5 - * @param string $yourls_site Optional, YOURLS installation URL (default to constant YOURLS_SITE) - * @param string $uri Optional, page requested (default to $_SERVER['REQUEST_URI'] eg '/yourls/abcd' ) - * @return string request relative to YOURLS base (eg 'abdc') + * @param string $yourls_site Optional, YOURLS installation URL (default to constant YOURLS_SITE) + * @param string $uri Optional, page requested (default to $_SERVER['REQUEST_URI'] eg '/yourls/abcd' ) + * @return string Request relative to YOURLS base (eg 'abdc') */ -function yourls_get_request($yourls_site = '', $uri = '') { +function yourls_get_request(string $yourls_site = '', string $uri = ''): string { // Allow plugins to short-circuit the whole function $pre = yourls_apply_filter( 'shunt_get_request', yourls_shunt_default() ); if ( yourls_shunt_default() !== $pre ) { @@ -1050,20 +1171,27 @@ function yourls_get_request($yourls_site = '', $uri = '') { // | http://sho.rt/abc | http://sho.rt | / | abc | // | https://SHO.rt/subdir/abc | https://shor.rt/subdir/ | /subdir/ | abc | // +---------------------------+-------------------------+---------------------+--------------+ - // and so on. You can find various test cases in /tests/tests/utilities/get_request.php + // and so on. You can find various test cases in tests/tests/utilities/GetRequestTest.php // Take only the URL_PATH part of YOURLS_SITE (ie "https://sho.rt:1337/path/to/yourls" -> "/path/to/yourls") $yourls_site = parse_url( $yourls_site, PHP_URL_PATH ).'/'; // Strip path part from request if exists $request = $uri; - if ( substr( $uri, 0, strlen( $yourls_site ) ) == $yourls_site ) { + if (str_starts_with($uri, $yourls_site)) { $request = ltrim( substr( $uri, strlen( $yourls_site ) ), '/' ); } - // Unless request looks like a full URL (ie request is a simple keyword) strip query string + // Request can be a full URL, ie https://sho.rt/http://site.com to "prefix n' shorten" a URL, see https://sho.rt/admin/tools.php + // If request is a simple keyword, strip query string and suspicious traversal attempts + // Note that in a real case use, this shouldn't happen since the server resolves the path before the request reaches YOURLS, + // ie https://github.com/ozh/../YOURLS/ resolves to https://github.com/YOURLS/ if ( !preg_match( "@^[a-zA-Z]+://.+@", $request ) ) { $request = current( explode( '?', $request ) ); + $request = str_replace( [ '../', '..\\' ], '', $request, $count ); + if ( $count > 0 ) { + $request = trim( $request, '/' ); + } } $request = yourls_sanitize_url( $request ); @@ -1170,13 +1298,19 @@ function yourls_check_maintenance_mode() { * @since 1.6 * @see yourls_get_protocol() * - * @param string $url URL to be check + * @param string $url URL to be checked * @param array $protocols Optional. Array of protocols, defaults to global $yourls_allowedprotocols * @return bool true if protocol allowed, false otherwise */ -function yourls_is_allowed_protocol( $url, $protocols = [] ) { +function yourls_is_allowed_protocol(string $url, array $protocols = [] ): bool { if ( empty( $protocols ) ) { global $yourls_allowedprotocols; + // KSES globals are normally populated on the 'plugins_loaded' action. This can run + // earlier though (eg yourls_die() on a DB connection error, before plugins load), so + // make sure the allowed protocols are available. + if ( ! is_array( $yourls_allowedprotocols ) ) { + yourls_kses_init(); + } $protocols = $yourls_allowedprotocols; } diff --git a/includes/geo/GeoLite2-Country.mmdb b/includes/geo/GeoLite2-Country.mmdb index c3e29bd..62432d5 100644 Binary files a/includes/geo/GeoLite2-Country.mmdb and b/includes/geo/GeoLite2-Country.mmdb differ diff --git a/includes/version.php b/includes/version.php index ca8aedc..64c4b8c 100644 --- a/includes/version.php +++ b/includes/version.php @@ -9,7 +9,7 @@ * MAJOR.MINOR.PATCH-SOMETHING (1.8.1-donotuse) * */ -define( 'YOURLS_VERSION', '1.10.4' ); +define( 'YOURLS_VERSION', '1.10.6' ); /** * YOURLS DB version. Increments when changes are made to the DB schema, to trigger a DB update diff --git a/user/plugins/random-shorturls/plugin.php b/user/plugins/random-shorturls/plugin.php index f285439..407679b 100644 --- a/user/plugins/random-shorturls/plugin.php +++ b/user/plugins/random-shorturls/plugin.php @@ -3,7 +3,7 @@ Plugin Name: Random ShortURLs Plugin URI: https://yourls.org/ Description: Assign random keywords to shorturls, like bitly (sho.rt/hJudjK) -Version: 1.2 +Version: 1.2.1 Author: Ozh Author URI: https://ozh.org/ */ @@ -15,6 +15,7 @@ * Fixed: plugin now complies to character set defined in config.php * 1.2 Adopted as YOURLS core plugin under a new name * Now configured via YOURLS options instead of editing plugin file +* 1.2.1 Update HTML tag in notice */ // No direct call @@ -22,7 +23,7 @@ // Only register things if the old third-party plugin is not present if( function_exists('ozh_random_keyword') ) { - yourls_add_notice( "<b>Random ShortURLs</b> plugin cannot function unless <b>Random Keywords</b> is removed first." ); + yourls_add_notice( "<strong>Random ShortURLs</strong> plugin cannot function unless <strong>Random Keywords</strong> is removed first." ); } else { // filter registration happens conditionally, to avoid conflicts // settings action is left out here, as it allows checking settings before deleting the old plugin