diff --git a/public/app/mu-plugins/moj-auth/verify.php b/public/app/mu-plugins/moj-auth/verify.php index ad03d203..45070c52 100644 --- a/public/app/mu-plugins/moj-auth/verify.php +++ b/public/app/mu-plugins/moj-auth/verify.php @@ -8,6 +8,12 @@ http_response_code(401) && exit(); } +// Return 200 if MOJ_AUTH_ENABLED is exactly equal to false, useful when working locally. +if (isset($_ENV['MOJ_AUTH_ENABLED']) && $_ENV['MOJ_AUTH_ENABLED'] === 'false') { + error_log('MOJ_AUTH_ENABLED is false - skipping auth check.'); + http_response_code(200) && exit(); +} + define('DOING_STANDALONE_VERIFY', true); $autoload = '../../../../vendor/autoload.php'; diff --git a/public/app/themes/justice/inc/search.php b/public/app/themes/justice/inc/search.php index 61b48a82..5e55d624 100644 --- a/public/app/themes/justice/inc/search.php +++ b/public/app/themes/justice/inc/search.php @@ -15,28 +15,28 @@ public function __construct() public function addHooks() { // Add a rewrite rule to handle an empty search. - add_action('init', fn () => add_rewrite_rule('search/?$', 'index.php?s=', 'bottom')); + add_action('init', fn() => add_rewrite_rule('search/?$', 'index.php?s=', 'bottom')); // Add a rewrite rule to handle the old search urls. add_action('template_redirect', [$this, 'redirectOldSearchUrls']); // Add a rewrite rule to handle the search string. add_filter('posts_search', [$this, 'handleEmptySearch'], 10, 2); // Add a query var for the parent page. This will be handled in relevanssiParentFilter. - add_filter('query_vars', fn ($qv) => array_merge($qv, array('parent'))); + add_filter('query_vars', fn($qv) => array_merge($qv, array('parent'))); // Update the search query. add_action('pre_get_posts', [$this, 'searchFilter']); // Relevanssi - prevent sending documents to the Relevanssi API. - add_filter('option_relevanssi_do_not_call_home', fn () => 'on'); - add_filter('default_option_relevanssi_do_not_call_home', fn () => 'on'); + add_filter('option_relevanssi_do_not_call_home', fn() => 'on'); + add_filter('default_option_relevanssi_do_not_call_home', fn() => 'on'); // Relevanssi - prevent click tracking. We don't need it and it makes the search results url messy. - add_filter('option_relevanssi_click_tracking', fn () => 'off'); - add_filter('default_option_relevanssi_click_tracking', fn () => 'off'); + add_filter('option_relevanssi_click_tracking', fn() => 'off'); + add_filter('default_option_relevanssi_click_tracking', fn() => 'off'); // Relevanssi - filters the did you mean url, to use /search instead of s=. add_filter('relevanssi_didyoumean_url', [$this, 'didYouMeanUrl'], 10, 3); // Relevanssi - add numbers to the did you mean alphabet. - add_filter('relevanssi_didyoumean_alphabet', fn ($alphabet) => $alphabet . '0123456789'); + add_filter('relevanssi_didyoumean_alphabet', fn($alphabet) => $alphabet . '0123456789'); // Relevanssi - filters the search results to only include the descendants. add_filter('relevanssi_hits_filter', [$this, 'relevanssiParentFilter']); @@ -47,6 +47,12 @@ public function addHooks() // Relevanssi - remove searches submenus for non-admins. add_filter('admin_menu', [$this, 'removeSearchesSubMenus'], 999); + + // Redirect the user to the search page if the URI contains multiple pages. + add_action('init', [$this, 'redirectMultiplePageInURI'], 1); + + // Redirect the user to the search page if there are arrays in the the query string. + add_action('init', [$this, 'redirectIfQueryStringHasArrays'], 1); } /** @@ -54,7 +60,6 @@ public function addHooks() * * @return bool True if the search query is empty, false otherwise. */ - public function hasEmptyQuery(): bool { return empty(get_search_query()); @@ -65,7 +70,6 @@ public function hasEmptyQuery(): bool * * @return int|null The number of search results. */ - public function getResultCount(): ?int { if (empty(get_search_query())) { @@ -83,7 +87,6 @@ public function getResultCount(): ?int * @param array $args An array of query parameters to add or modify. * @return string The URL for the search results. */ - public function getSearchUrl($search, $args = []) { $url_append = ''; @@ -121,7 +124,6 @@ public function getSearchUrl($search, $args = []) * * @return array An array of sort options. */ - public function getSortOptions(): array { $orderby = get_query_var('orderby'); @@ -145,7 +147,6 @@ public function getSortOptions(): array * * @return void */ - public function redirectOldSearchUrls() { // Don't redirect if we're in the admin. @@ -178,7 +179,6 @@ public function redirectOldSearchUrls() * @param \WP_Query $q The main WordPress query. * @return string The modified search query. */ - public function handleEmptySearch($search, \WP_Query $q) { if (!is_admin() && empty($search) && $q->is_search() && $q->is_main_query()) { @@ -197,7 +197,6 @@ public function handleEmptySearch($search, \WP_Query $q) * @param \WP_Query $query The main WordPress query. * @return void */ - public function searchFilter($query) { if (!is_admin() && $query->is_main_query() && $query->is_search) { @@ -211,7 +210,6 @@ public function searchFilter($query) * @param string $url The URL to format. * @return string The formatted URL. */ - public function formattedUrl(string $url): string { $split_length = 80; @@ -241,7 +239,6 @@ public function formattedUrl(string $url): string * @param string $suggestion The suggested search query. * @return string The filtered URL. */ - public function didYouMeanUrl($url, $query, $suggestion): string { return empty($suggestion) ? $url : $this->getSearchUrl($suggestion); @@ -257,7 +254,6 @@ public function didYouMeanUrl($url, $query, $suggestion): string * @param array $hits The search results. * @return array The filtered search results. */ - public function relevanssiParentFilter(array $hits): array { global $wp_query; @@ -300,7 +296,6 @@ public function relevanssiParentFilter(array $hits): array * @param array $columns The columns for the admin screen. * @return array The columns after removing any un-necessary ones. */ - public function removeColumns(array $columns): array { if (!current_user_can('manage_options')) { @@ -319,7 +314,6 @@ public function removeColumns(array $columns): array * * @return void */ - public function removeSearchesSubMenus() { if (!current_user_can('manage_options')) { @@ -327,4 +321,70 @@ public function removeSearchesSubMenus() remove_submenu_page('index.php', 'relevanssi_admin_search'); } } + + /** + * Handle malformed search URLs where the path has multiple pages. + * + * e.g /search/the/page/page/11 + * + * @return void + */ + public function redirectMultiplePageInURI(): void + { + // Trim the first and last slash + $uri = trim($_SERVER['REQUEST_URI'], '/'); + // Split the URI by '/' + $uri_parts = explode('/', $uri); + + // Check if the URI has at least 4 parts and the first part is 'search' + if (sizeof($uri_parts) < 4 || $uri_parts[0] !== 'search') { + return; + } + + // Remove the first 2 from the array + $uri_parts = array_slice($uri_parts, 2); + + // Count the number of times 'page' appears in the $uri_parts + $pages_count = array_count_values($uri_parts)['page']; + + if ($pages_count > 1) { + // Redirect to the search page + $url = home_url('/search'); + wp_redirect($url); + exit; + } + } + + + /** + * Handle malformed search URLs with arrays in the query string. + * + * This function will redirect the user to the search page if the query string contains arrays. + * e.g. /search?audience[$testing]=1 or /search?audience%5B%24testing%5D=1 + * + * @return void + */ + public function redirectIfQueryStringHasArrays(): void + { + // Are we on a search page? The URI starts with /search + if (strpos($_SERVER['REQUEST_URI'], '/search') === false) { + return; + } + + $query_string = explode('&', $_SERVER['QUERY_STRING'] ?? ''); + + foreach ($query_string as $query) { + error_log($query); + // Get key and value + [$key] = explode('=', $query); + + // Use regex to see if the key contains any of the invalid strings + if (preg_match('/(%5B|%5D|\[|\])/', $key)) { + // Redirect to the search page + $url = home_url('/search'); + wp_redirect($url); + exit; + } + } + } } diff --git a/public/app/themes/justice/inc/security.php b/public/app/themes/justice/inc/security.php index 254b6f07..a4fd344f 100644 --- a/public/app/themes/justice/inc/security.php +++ b/public/app/themes/justice/inc/security.php @@ -2,30 +2,63 @@ namespace MOJ\Justice; +use WP_Error; + /** * Add a little security for WordPress */ class Security { + + private $wp_version; + private $hashed_wp_version; + /** - * Loads up actions that are called when WordPress initialises + * Set properties and run actions. */ public function __construct() { + // Get the WordPress version. + $this->wp_version = get_bloginfo('version'); + // Hash the WP version number with a salt - let's borrow AUTH_SALT for this. + // This way a we get a unique hash per WP version but it's not reversible. + $this->hashed_wp_version = substr(hash('sha256', $this->wp_version . AUTH_SALT), 0, 6); + $this->actions(); } /** + * Loads up actions that are called when WordPress initialises + * * @return void */ public function actions(): void { - // no generator meta tag in the head + // No generator meta tag in the head remove_action('wp_head', 'wp_generator'); add_filter('redirect_canonical', [$this, 'noRedirect404']); + add_filter('xmlrpc_enabled', '__return_false'); add_filter('wp_headers', [$this, 'headerMods']); add_filter('auth_cookie_expiration', [$this, 'setLoginPeriod'], 10, 0); + + // Handle malformed URLs with arrays in the query string. + add_filter('login_init', [$this, 'validateLoginRequest'], 10, 0); + + // Remove emoji support. + remove_action('wp_head', 'print_emoji_detection_script', 7); + remove_action('wp_print_styles', 'print_emoji_styles'); + + // Strip the WP version number from enqueued asset URLs. + add_filter('style_loader_tag', [$this, 'filterAssetQueryString'], 10, 1); + // change the url with script_loader_tag + add_filter('script_loader_tag', [$this, 'filterAssetQueryString'], 10, 1); + + // Hide the WP version number from the feeds. + add_filter('the_generator', '__return_empty_string'); + + // Disable REST API for non-logged in users. + add_filter('rest_authentication_errors', [$this, 'restAuth']); } /** @@ -69,4 +102,48 @@ public function setLoginPeriod(): float|int { return 7 * DAY_IN_SECONDS; // Cookies set to expire in 7 days. } + + /** + * Change the URL of the script or style tags. + * + * @see https://developer.wordpress.org/reference/hooks/style_loader_tag/ + * + * @param $tag string The HTML string of a link or script tag. + * @return string The modified HTML string. + */ + public function filterAssetQueryString(string $tag): string + { + return str_replace('ver=' . $this->wp_version, 'ver=' . $this->hashed_wp_version, $tag); + } + + /** + * Disable REST API for non-logged in users. + * + * @see https://developer.wordpress.org/reference/hooks/rest_authentication_errors/ + * + * @param WP_Error|null|true $result + * @return WP_Error|null|true + */ + public function restAuth(WP_Error|null|true $result): WP_Error|null|true + { + // If a previous authentication check was applied, + // pass that result along without modification. + if (true === $result || is_wp_error($result)) { + return $result; + } + + // No authentication has been performed yet. + // Return an error if user is not logged in. + if (! is_user_logged_in()) { + return new WP_Error( + 'rest_not_logged_in', + __('You are not currently logged in.'), + array('status' => 401) + ); + } + + // Our custom authentication check should have no effect + // on logged-in requests + return $result; + } }