admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE_ACTION ), 'i18n' => array( 'running' => esc_html__( 'Checking response headers…', 'aiutocomputerhelp-http-header-audit' ), 'error' => esc_html__( 'The audit could not be completed.', 'aiutocomputerhelp-http-header-audit' ), 'pass' => esc_html__( 'Pass', 'aiutocomputerhelp-http-header-audit' ), 'review' => esc_html__( 'Review', 'aiutocomputerhelp-http-header-audit' ), 'warning' => esc_html__( 'Warning', 'aiutocomputerhelp-http-header-audit' ), 'info' => esc_html__( 'Info', 'aiutocomputerhelp-http-header-audit' ), 'high' => esc_html__( 'High', 'aiutocomputerhelp-http-header-audit' ), 'recommended' => esc_html__( 'Recommended', 'aiutocomputerhelp-http-header-audit' ), 'contextual' => esc_html__( 'Context-dependent', 'aiutocomputerhelp-http-header-audit' ), 'informational' => esc_html__( 'Informational', 'aiutocomputerhelp-http-header-audit' ), 'redirects' => esc_html__( 'Redirects', 'aiutocomputerhelp-http-header-audit' ), ), ) ); } /** * Render the admin page. * * @return void */ public static function render_admin_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } ?>

esc_html__( 'You are not allowed to run this audit.', 'aiutocomputerhelp-http-header-audit' ) ), 403 ); } check_ajax_referer( self::NONCE_ACTION, 'nonce' ); $target = isset( $_POST['target'] ) ? esc_url_raw( wp_unslash( $_POST['target'] ) ) : ''; if ( '' === $target ) { $target = home_url( '/' ); } if ( ! self::is_allowed_target( $target ) ) { wp_send_json_error( array( 'message' => esc_html__( 'The target must use HTTP or HTTPS and belong to this WordPress site.', 'aiutocomputerhelp-http-header-audit' ) ), 400 ); } $fetched = self::fetch_same_site( $target ); if ( is_wp_error( $fetched ) ) { wp_send_json_error( array( 'message' => esc_html( $fetched->get_error_message() ) ), 502 ); } $response = $fetched['response']; $final_url = $fetched['final_url']; $rows = self::analyze_response( $response, $final_url, $target, $fetched ); $summary = array( 'pass' => 0, 'review' => 0, 'warning' => 0, 'info' => 0, ); foreach ( $rows as $row ) { if ( isset( $summary[ $row['status'] ] ) ) { ++$summary[ $row['status'] ]; } } wp_send_json_success( array( 'finalUrl' => esc_url_raw( $final_url ), 'httpCode' => (int) wp_remote_retrieve_response_code( $response ), 'redirects' => array_map( 'esc_url_raw', $fetched['redirects'] ), 'rows' => $rows, 'summary' => $summary, ) ); } /** * Ensure the requested URL stays on the current site's host and permitted ports. * * @param string $url URL to validate. * @return bool */ private static function is_allowed_target( $url ) { $target = wp_parse_url( $url ); $home = wp_parse_url( home_url( '/' ) ); if ( ! is_array( $target ) || ! is_array( $home ) ) { return false; } if ( empty( $target['scheme'] ) || empty( $target['host'] ) || empty( $home['host'] ) ) { return false; } $scheme = strtolower( $target['scheme'] ); if ( 'http' !== $scheme && 'https' !== $scheme ) { return false; } if ( strtolower( $target['host'] ) !== strtolower( $home['host'] ) ) { return false; } if ( isset( $target['user'] ) || isset( $target['pass'] ) ) { return false; } $target_port = isset( $target['port'] ) ? (int) $target['port'] : 0; $home_port = isset( $home['port'] ) ? (int) $home['port'] : 0; if ( $home_port > 0 ) { return 0 === $target_port || $home_port === $target_port; } return 0 === $target_port || 80 === $target_port || 443 === $target_port; } /** * Fetch a URL and follow a bounded number of same-site redirects. * * wp_safe_remote_get() rejects localhost/private addresses, which prevents legitimate * local WordPress testing. Targets are therefore restricted to the current site and * redirects are disabled at the transport layer and validated here before following. * Redirects that leave the current host, use an unsupported scheme, form a loop, or * exceed the hard hop limit are recorded but never followed. * * @param string $url Initial URL. * @return array|WP_Error */ private static function fetch_same_site( $url ) { $current = $url; $redirects = array(); $chain = array(); $visited = array(); $max_redirects = 4; $last_response = null; for ( $i = 0; $i <= $max_redirects; $i++ ) { if ( ! self::is_allowed_target( $current ) ) { return new WP_Error( 'achsha_invalid_redirect', esc_html__( 'A redirect attempted to leave the current WordPress site.', 'aiutocomputerhelp-http-header-audit' ) ); } $visit_key = self::redirect_visit_key( $current ); if ( isset( $visited[ $visit_key ] ) ) { if ( null === $last_response ) { return new WP_Error( 'achsha_redirect_loop', esc_html__( 'A redirect loop was detected before a response could be analyzed.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::redirect_stop_result( $last_response, $current, $redirects, $chain, 'loop', $current ); } $visited[ $visit_key ] = true; $response = wp_remote_get( $current, array( 'timeout' => 12, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => 4096, 'user-agent' => 'AiutoComputerHelp HTTP Header Audit/' . self::VERSION . '; ' . home_url( '/' ), 'headers' => array( 'Cache-Control' => 'no-cache', ), ) ); if ( is_wp_error( $response ) ) { return $response; } $last_response = $response; $code = (int) wp_remote_retrieve_response_code( $response ); $location = wp_remote_retrieve_header( $response, 'location' ); $location = is_scalar( $location ) ? trim( (string) $location ) : ''; $chain[] = array( 'url' => $current, 'code' => $code, 'location' => $location, ); if ( $code < 300 || $code > 399 || 304 === $code ) { return self::redirect_stop_result( $response, $current, $redirects, $chain, '', '' ); } if ( '' === $location ) { return self::redirect_stop_result( $response, $current, $redirects, $chain, 'missing_location', '' ); } if ( preg_match( '/^[a-z][a-z0-9+.-]*:/i', $location ) && ! preg_match( '#^https?://#i', $location ) ) { return self::redirect_stop_result( $response, $current, $redirects, $chain, 'unsupported_scheme', $location ); } $next = self::resolve_redirect_url( $current, $location ); if ( ! $next ) { return self::redirect_stop_result( $response, $current, $redirects, $chain, 'invalid_location', $location ); } if ( ! self::is_allowed_target( $next ) ) { $reason = self::same_host( $current, $next ) ? 'disallowed_target' : 'cross_host'; return self::redirect_stop_result( $response, $current, $redirects, $chain, $reason, $next ); } if ( isset( $visited[ self::redirect_visit_key( $next ) ] ) ) { return self::redirect_stop_result( $response, $current, $redirects, $chain, 'loop', $next ); } if ( $i >= $max_redirects ) { return self::redirect_stop_result( $response, $current, $redirects, $chain, 'too_many', $next ); } $redirects[] = $next; $current = $next; } return new WP_Error( 'achsha_redirect_trace_failed', esc_html__( 'The redirect trace could not be completed.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Build a successful or safely stopped redirect fetch result. * * @param array $response Last same-site HTTP response. * @param string $final_url Last URL that was actually requested. * @param array $redirects Same-site redirects that were followed. * @param array $chain Observed request/response hops. * @param string $stop_reason Empty for a normal terminal response. * @param string $blocked_target Redirect target that was deliberately not followed. * @return array */ private static function redirect_stop_result( $response, $final_url, $redirects, $chain, $stop_reason, $blocked_target ) { return array( 'response' => $response, 'final_url' => $final_url, 'redirects' => $redirects, 'chain' => $chain, 'stop_reason' => (string) $stop_reason, 'blocked_target' => (string) $blocked_target, ); } /** * Normalize a URL for redirect-loop detection. * * @param string $url URL. * @return string */ private static function redirect_visit_key( $url ) { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) ) { return strtolower( (string) $url ); } $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : ''; $host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : ''; $port = isset( $parts['port'] ) ? ':' . (int) $parts['port'] : ''; $path = isset( $parts['path'] ) && '' !== $parts['path'] ? $parts['path'] : '/'; $query = isset( $parts['query'] ) && '' !== $parts['query'] ? '?' . $parts['query'] : ''; return $scheme . '://' . $host . $port . $path . $query; } /** * Compare URL hosts case-insensitively. * * @param string $left First URL. * @param string $right Second URL. * @return bool */ private static function same_host( $left, $right ) { $left_host = strtolower( (string) wp_parse_url( $left, PHP_URL_HOST ) ); $right_host = strtolower( (string) wp_parse_url( $right, PHP_URL_HOST ) ); return '' !== $left_host && $left_host === $right_host; } /** * Resolve an HTTP Location value against the current URL. * * @param string $base_url Current URL. * @param string $location Location header. * @return string|false */ private static function resolve_redirect_url( $base_url, $location ) { $location = trim( (string) $location ); if ( '' === $location ) { return false; } if ( preg_match( '#^https?://#i', $location ) ) { return esc_url_raw( $location ); } $base = wp_parse_url( $base_url ); if ( ! is_array( $base ) || empty( $base['scheme'] ) || empty( $base['host'] ) ) { return false; } $origin = $base['scheme'] . '://' . $base['host']; if ( isset( $base['port'] ) ) { $origin .= ':' . (int) $base['port']; } if ( 0 === strpos( $location, '//' ) ) { return esc_url_raw( $base['scheme'] . ':' . $location ); } if ( 0 === strpos( $location, '/' ) ) { return esc_url_raw( $origin . $location ); } $path = isset( $base['path'] ) ? $base['path'] : '/'; $directory = preg_replace( '#/[^/]*$#', '/', $path ); return esc_url_raw( $origin . $directory . $location ); } /** * Analyze the response and selected related HTTP behavior. * * @param array $response WordPress HTTP API response. * @param string $url Final URL. * @param string $original_url User-requested URL. * @param array $main_fetched Main fetch metadata. * @return array */ private static function analyze_response( $response, $url, $original_url, $main_fetched ) { $rows = array(); $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); $is_https = 'https' === $scheme; $rows[] = self::row( 'HTTPS', $is_https ? 'pass' : 'warning', 'high', $is_https ? 'HTTPS' : strtoupper( $scheme ? $scheme : 'unknown' ), $is_https ? __( 'The audited response is delivered over HTTPS.', 'aiutocomputerhelp-http-header-audit' ) : __( 'The audited response is not using HTTPS. Security headers cannot replace transport encryption.', 'aiutocomputerhelp-http-header-audit' ) ); $rows[] = self::analyze_http_upgrade( $original_url, $main_fetched ); $rows[] = self::analyze_redirect_chain_integrity( $main_fetched ); $hsts = self::get_header( $response, 'strict-transport-security' ); if ( ! $is_https ) { $rows[] = self::row( 'Strict-Transport-Security', 'info', 'recommended', $hsts, __( 'HSTS is only effective when received over HTTPS.', 'aiutocomputerhelp-http-header-audit' ) ); } elseif ( '' === $hsts ) { $rows[] = self::row( 'Strict-Transport-Security', 'review', 'recommended', '', __( 'HSTS is not enabled. This may be an intentional compatibility choice; enabling it protects future visits from HTTP downgrade and certificate-bypass scenarios.', 'aiutocomputerhelp-http-header-audit' ) ); } else { $rows[] = self::analyze_hsts( $hsts ); } $csp = self::get_header( $response, 'content-security-policy' ); $csp_report = self::get_header( $response, 'content-security-policy-report-only' ); $rows[] = self::analyze_csp( $csp, $csp_report ); if ( '' !== $csp_report ) { $rows[] = self::row( 'CSP Report-Only', 'info', 'contextual', $csp_report, __( 'A report-only CSP is present. It can be useful for testing, but it does not enforce restrictions.', 'aiutocomputerhelp-http-header-audit' ) ); } $xfo = self::get_header( $response, 'x-frame-options' ); $frame_ancestors = self::csp_directive( $csp, 'frame-ancestors' ); $rows[] = self::analyze_clickjacking( $xfo, $frame_ancestors ); $xcto = self::get_header( $response, 'x-content-type-options' ); $rows[] = self::row( 'X-Content-Type-Options', 'nosniff' === strtolower( trim( $xcto ) ) ? 'pass' : 'warning', 'recommended', $xcto, 'nosniff' === strtolower( trim( $xcto ) ) ? __( 'MIME sniffing is disabled.', 'aiutocomputerhelp-http-header-audit' ) : __( 'Set this header to nosniff to prevent MIME type sniffing.', 'aiutocomputerhelp-http-header-audit' ) ); $rows[] = self::analyze_content_type( self::get_header( $response, 'content-type' ) ); $referrer = self::get_header( $response, 'referrer-policy' ); $rows[] = self::analyze_referrer_policy( $referrer ); $permissions = self::get_header( $response, 'permissions-policy' ); $rows[] = self::analyze_permissions_policy( $permissions ); $coop = self::get_header( $response, 'cross-origin-opener-policy' ); $coep = self::get_header( $response, 'cross-origin-embedder-policy' ); $corp = self::get_header( $response, 'cross-origin-resource-policy' ); $rows[] = self::analyze_coop( $coop ); $rows[] = self::analyze_coep( $coep ); $rows[] = self::analyze_corp( $corp ); $rows[] = self::analyze_cross_origin_isolation( $coop, $coep, $permissions, $url ); $rows[] = self::analyze_cors_exposure( self::get_header( $response, 'access-control-allow-origin' ), self::get_header( $response, 'access-control-allow-credentials' ), self::get_header( $response, 'access-control-allow-methods' ), self::get_header( $response, 'access-control-allow-headers' ), self::get_header( $response, 'vary' ) ); $rows[] = self::analyze_cache_control( self::get_header( $response, 'cache-control' ), self::get_header( $response, 'set-cookie' ) ); $rows[] = self::analyze_cookie_security( self::get_header_values( $response, 'set-cookie' ), $url ); $powered = self::get_header( $response, 'x-powered-by' ); $rows[] = self::row( 'X-Powered-By', '' === $powered ? 'pass' : 'review', 'informational', $powered, '' === $powered ? __( 'No X-Powered-By disclosure was detected.', 'aiutocomputerhelp-http-header-audit' ) : __( 'Technology disclosure is present. Removing unnecessary platform or version detail reduces passive fingerprinting.', 'aiutocomputerhelp-http-header-audit' ) ); $server = self::get_header( $response, 'server' ); if ( '' === $server ) { $rows[] = self::row( 'Server', 'pass', 'informational', '', __( 'The Server header was not exposed.', 'aiutocomputerhelp-http-header-audit' ) ); } elseif ( preg_match( '/\b\d+(?:\.\d+)+\b/', $server ) ) { $rows[] = self::row( 'Server', 'review', 'informational', $server, __( 'The Server header appears to expose version detail. Consider reducing unnecessary fingerprinting information when you control this header.', 'aiutocomputerhelp-http-header-audit' ) ); } else { $rows[] = self::row( 'Server', 'info', 'informational', $server, __( 'The server identifies itself generically. This is informational unless unnecessary version detail is also exposed.', 'aiutocomputerhelp-http-header-audit' ) ); } $xxss = self::get_header( $response, 'x-xss-protection' ); if ( '' !== $xxss ) { $status = '0' === trim( $xxss ) ? 'info' : 'review'; $assessment = '0' === trim( $xxss ) ? __( 'The legacy browser XSS filter is explicitly disabled. Modern protection should rely on safe output handling and CSP.', 'aiutocomputerhelp-http-header-audit' ) : __( 'This legacy header is deprecated and should not be relied on as an XSS defense. Some historical filter modes could introduce security problems.', 'aiutocomputerhelp-http-header-audit' ); $rows[] = self::row( 'X-XSS-Protection', $status, 'informational', $xxss, $assessment ); } $hpkp = self::get_header( $response, 'public-key-pins' ); $hpkp_report = self::get_header( $response, 'public-key-pins-report-only' ); if ( '' !== $hpkp ) { $rows[] = self::row( 'Public-Key-Pins', 'warning', 'informational', $hpkp, __( 'HPKP is obsolete and risky to deploy. Remove this header rather than treating it as a current security control.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( '' !== $hpkp_report ) { $rows[] = self::row( 'Public-Key-Pins-Report-Only', 'review', 'informational', $hpkp_report, __( 'HPKP report-only is obsolete. Review whether this legacy header is still needed.', 'aiutocomputerhelp-http-header-audit' ) ); } $expect_ct = self::get_header( $response, 'expect-ct' ); if ( '' !== $expect_ct ) { $rows[] = self::row( 'Expect-CT', 'info', 'informational', $expect_ct, __( 'Expect-CT is obsolete in modern browsers and should not be treated as a current security control.', 'aiutocomputerhelp-http-header-audit' ) ); } $rows[] = self::analyze_header_consistency( $hsts, $xfo, $frame_ancestors ); return $rows; } /** * Analyze whether an HTTP request upgrades to HTTPS on the same host. * * @param string $original_url Original audit target. * @param array $main_fetched Main fetch metadata. * @return array */ private static function analyze_http_upgrade( $original_url, $main_fetched ) { $parts = wp_parse_url( $original_url ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return self::row( 'HTTP → HTTPS redirect', 'info', 'high', '', __( 'The HTTP upgrade path could not be determined for this target.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( 'http' === strtolower( $parts['scheme'] ) ) { $probe = $main_fetched; $http_url = $original_url; } else { $http_url = self::with_http_scheme( $original_url ); if ( ! $http_url || ! self::is_allowed_target( $http_url ) ) { return self::row( 'HTTP → HTTPS redirect', 'info', 'high', '', __( 'The HTTP upgrade path could not be tested safely for this target.', 'aiutocomputerhelp-http-header-audit' ) ); } $probe = self::fetch_same_site( $http_url ); } if ( is_wp_error( $probe ) ) { return self::row( 'HTTP → HTTPS redirect', 'info', 'high', $http_url, __( 'The server-side HTTP probe could not be completed. This result is informational and does not prove that browser upgrades are absent.', 'aiutocomputerhelp-http-header-audit' ) ); } $final_scheme = strtolower( (string) wp_parse_url( $probe['final_url'], PHP_URL_SCHEME ) ); $observed = self::format_chain( $probe['chain'] ); if ( 'https' === $final_scheme ) { return self::row( 'HTTP → HTTPS redirect', 'pass', 'high', $observed, __( 'A plain HTTP request is redirected to HTTPS on the same host.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'HTTP → HTTPS redirect', 'review', 'high', $observed, __( 'The tested HTTP URL did not end on HTTPS. Review whether plain HTTP should remain reachable for this site.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Analyze the redirect chain of the audited URL without following unsafe targets. * * @param array $fetched Main fetch metadata. * @return array */ private static function analyze_redirect_chain_integrity( $fetched ) { $chain = isset( $fetched['chain'] ) && is_array( $fetched['chain'] ) ? $fetched['chain'] : array(); $stop_reason = isset( $fetched['stop_reason'] ) ? (string) $fetched['stop_reason'] : ''; $blocked_target = isset( $fetched['blocked_target'] ) ? (string) $fetched['blocked_target'] : ''; if ( empty( $chain ) ) { return self::redirect_integrity_row( 'info', '', __( 'No redirect-chain metadata was available for this response.', 'aiutocomputerhelp-http-header-audit' ) ); } $redirect_count = 0; $resolved_targets = array(); $downgrade = false; $previous_url = ''; foreach ( $chain as $hop ) { if ( ! is_array( $hop ) || empty( $hop['url'] ) ) { continue; } $current_url = (string) $hop['url']; if ( '' !== $previous_url ) { $previous_scheme = strtolower( (string) wp_parse_url( $previous_url, PHP_URL_SCHEME ) ); $current_scheme = strtolower( (string) wp_parse_url( $current_url, PHP_URL_SCHEME ) ); if ( 'https' === $previous_scheme && 'http' === $current_scheme ) { $downgrade = true; } } $previous_url = $current_url; $code = isset( $hop['code'] ) ? (int) $hop['code'] : 0; $location = isset( $hop['location'] ) ? trim( (string) $hop['location'] ) : ''; if ( $code >= 300 && $code <= 399 && 304 !== $code && '' !== $location ) { ++$redirect_count; $resolved = self::resolve_redirect_url( $current_url, $location ); if ( $resolved ) { $resolved_targets[] = $resolved; $from_scheme = strtolower( (string) wp_parse_url( $current_url, PHP_URL_SCHEME ) ); $to_scheme = strtolower( (string) wp_parse_url( $resolved, PHP_URL_SCHEME ) ); if ( 'https' === $from_scheme && 'http' === $to_scheme ) { $downgrade = true; } } } } $observed = self::format_chain( $chain ); if ( '' !== $blocked_target ) { $observed .= ( '' !== $observed ? ' → ' : '' ) . __( 'not followed:', 'aiutocomputerhelp-http-header-audit' ) . ' ' . $blocked_target; } if ( $downgrade ) { return self::redirect_integrity_row( 'warning', $observed, __( 'The redirect chain contains an HTTPS-to-HTTP downgrade. Redirect chains should not move a secure request back to plaintext HTTP.', 'aiutocomputerhelp-http-header-audit' ) ); } switch ( $stop_reason ) { case 'loop': return self::redirect_integrity_row( 'warning', $observed, __( 'A redirect loop was detected. The repeated target was not requested again.', 'aiutocomputerhelp-http-header-audit' ) ); case 'unsupported_scheme': case 'invalid_location': return self::redirect_integrity_row( 'warning', $observed, __( 'A redirect points to an invalid or unsupported non-HTTP(S) target. The target was not followed.', 'aiutocomputerhelp-http-header-audit' ) ); case 'cross_host': return self::redirect_integrity_row( 'review', $observed, __( 'The redirect chain leaves the current WordPress host. Cross-host redirects can be intentional, but the external target was not followed and should be reviewed.', 'aiutocomputerhelp-http-header-audit' ) ); case 'disallowed_target': return self::redirect_integrity_row( 'review', $observed, __( 'A redirect target is outside the ports or target shape permitted by this self-audit. It was not followed.', 'aiutocomputerhelp-http-header-audit' ) ); case 'too_many': return self::redirect_integrity_row( 'review', $observed, __( 'The redirect chain exceeded the audit hop limit. Long chains add latency and can hide unintended routing behavior.', 'aiutocomputerhelp-http-header-audit' ) ); case 'missing_location': return self::redirect_integrity_row( 'review', $observed, __( 'A redirect-class response was returned without a usable Location target, so the chain could not be completed deterministically.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( $redirect_count >= 4 ) { return self::redirect_integrity_row( 'review', $observed, __( 'The redirect chain completed, but four or more redirects were observed. Review whether the chain can be simplified.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( 0 === $redirect_count ) { return self::redirect_integrity_row( 'info', $observed, __( 'No redirect chain was observed for the audited URL.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::redirect_integrity_row( 'pass', $observed, __( 'The observed redirect chain stayed on the permitted host, used HTTP(S), completed within a short number of hops, and contained no HTTPS-to-HTTP downgrade.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Build a Redirect-chain integrity row and guarantee an explanatory assessment. * * @param string $status Result status. * @param string $observed Observed redirect chain. * @param string $assessment Human-readable assessment. * @return array */ private static function redirect_integrity_row( $status, $observed, $assessment ) { $assessment = trim( (string) $assessment ); if ( '' === $assessment ) { $assessment = __( 'Redirect-chain integrity requires review, but no detailed diagnostic text was produced. Re-run the audit and review the observed chain.', 'aiutocomputerhelp-http-header-audit' ); } return self::row( 'Redirect-chain integrity', $status, 'recommended', $observed, $assessment ); } /** * Convert a same-site URL to an HTTP probe URL. * * @param string $url HTTPS URL. * @return string|false */ private static function with_http_scheme( $url ) { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return false; } $result = 'http://' . $parts['host']; if ( isset( $parts['port'] ) && 443 !== (int) $parts['port'] ) { $result .= ':' . (int) $parts['port']; } $result .= isset( $parts['path'] ) ? $parts['path'] : '/'; if ( isset( $parts['query'] ) && '' !== $parts['query'] ) { $result .= '?' . $parts['query']; } return esc_url_raw( $result ); } /** * Analyze HSTS directives. * * @param string $hsts HSTS header. * @return array */ private static function analyze_hsts( $hsts ) { $max_ages = self::hsts_max_ages( $hsts ); if ( empty( $max_ages ) ) { return self::row( 'Strict-Transport-Security', 'warning', 'recommended', $hsts, __( 'HSTS is present but no valid max-age directive was detected.', 'aiutocomputerhelp-http-header-audit' ) ); } $unique = array_values( array_unique( $max_ages ) ); if ( count( $unique ) > 1 ) { return self::row( 'Strict-Transport-Security', 'warning', 'recommended', $hsts, __( 'Conflicting max-age directives were detected. Keep a single unambiguous HSTS policy.', 'aiutocomputerhelp-http-header-audit' ) ); } $max_age = (int) $unique[0]; if ( 0 === $max_age ) { return self::row( 'Strict-Transport-Security', 'review', 'recommended', $hsts, __( 'HSTS is explicitly disabled with max-age=0.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( $max_age < 15768000 ) { return self::row( 'Strict-Transport-Security', 'review', 'recommended', $hsts, __( 'HSTS max-age is shorter than six months. This can be intentional during staged deployment; review the chosen duration.', 'aiutocomputerhelp-http-header-audit' ) ); } $note = __( 'HSTS is present with a substantial max-age.', 'aiutocomputerhelp-http-header-audit' ); if ( false === stripos( $hsts, 'includesubdomains' ) ) { $note .= ' ' . __( 'includeSubDomains is not set; add it only if every subdomain is ready for HTTPS.', 'aiutocomputerhelp-http-header-audit' ); } if ( false !== stripos( $hsts, 'preload' ) && ( $max_age < 31536000 || false === stripos( $hsts, 'includesubdomains' ) ) ) { return self::row( 'Strict-Transport-Security', 'review', 'recommended', $hsts, $note . ' ' . __( 'The preload token is present, but the usual preload prerequisites are not all visible in this header.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Strict-Transport-Security', 'pass', 'recommended', $hsts, $note ); } /** * Analyze CSP with emphasis on XSS-relevant directives. * * @param string $csp Enforced CSP. * @param string $csp_report Report-only CSP. * @return array */ private static function analyze_csp( $csp, $csp_report ) { if ( '' === $csp ) { if ( '' !== $csp_report ) { return self::row( 'Content-Security-Policy', 'review', 'high', 'Report-Only only', __( 'A report-only policy is present, but no enforced CSP was detected.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Content-Security-Policy', 'review', 'high', '', __( 'No enforced Content-Security-Policy header was detected. CSP can provide an important additional mitigation against script injection when deployed carefully.', 'aiutocomputerhelp-http-header-audit' ) ); } $notes = array(); $status = 'pass'; $script = self::effective_csp_directive( $csp, 'script-src' ); $object = self::csp_directive( $csp, 'object-src' ); $base = self::csp_directive( $csp, 'base-uri' ); if ( '' === $script ) { $status = 'warning'; $notes[] = __( 'Neither script-src nor default-src restricts script sources.', 'aiutocomputerhelp-http-header-audit' ); } else { if ( self::csp_value_has_token( $script, "'unsafe-eval'" ) ) { $status = 'warning'; $notes[] = __( "The effective script policy allows 'unsafe-eval'.", 'aiutocomputerhelp-http-header-audit' ); } if ( self::csp_value_has_wildcard( $script ) ) { $status = 'warning'; $notes[] = __( 'The effective script policy contains a wildcard source.', 'aiutocomputerhelp-http-header-audit' ); } if ( self::csp_value_has_token( $script, "'unsafe-inline'" ) ) { if ( self::csp_value_has_nonce_or_hash( $script ) ) { if ( 'pass' === $status ) { $status = 'review'; } $notes[] = __( "'unsafe-inline' is present alongside a nonce or hash. Modern CSP processing can ignore it for scripts, but review compatibility intent.", 'aiutocomputerhelp-http-header-audit' ); } else { $status = 'warning'; $notes[] = __( "The effective script policy allows 'unsafe-inline' without a nonce or hash.", 'aiutocomputerhelp-http-header-audit' ); } } } if ( '' === $object ) { if ( 'pass' === $status ) { $status = 'review'; } $notes[] = __( 'object-src is not explicitly defined.', 'aiutocomputerhelp-http-header-audit' ); } elseif ( "'none'" !== trim( strtolower( $object ) ) ) { if ( 'pass' === $status ) { $status = 'review'; } $notes[] = __( "object-src is not restricted to 'none'.", 'aiutocomputerhelp-http-header-audit' ); } if ( '' === $base ) { if ( 'pass' === $status ) { $status = 'review'; } $notes[] = __( 'base-uri is not explicitly defined.', 'aiutocomputerhelp-http-header-audit' ); } if ( empty( $notes ) ) { $notes[] = __( 'An enforced CSP was detected without the high-signal weaknesses checked by this version.', 'aiutocomputerhelp-http-header-audit' ); } return self::row( 'Content-Security-Policy', $status, 'high', $csp, implode( ' ', $notes ) ); } /** * Analyze clickjacking controls. * * @param string $xfo X-Frame-Options value. * @param string $frame_ancestors CSP frame-ancestors value. * @return array */ private static function analyze_clickjacking( $xfo, $frame_ancestors ) { if ( '' !== $frame_ancestors ) { $status = self::csp_value_has_wildcard( $frame_ancestors ) ? 'warning' : 'pass'; $assessment = self::csp_value_has_wildcard( $frame_ancestors ) ? __( 'CSP frame-ancestors allows a wildcard source. Confirm that broad framing is intentional.', 'aiutocomputerhelp-http-header-audit' ) : __( 'CSP frame-ancestors controls which sites may embed this page.', 'aiutocomputerhelp-http-header-audit' ); return self::row( 'Clickjacking protection', $status, 'recommended', 'CSP frame-ancestors ' . $frame_ancestors, $assessment ); } if ( preg_match( '/^(DENY|SAMEORIGIN)$/i', trim( $xfo ) ) ) { return self::row( 'Clickjacking protection', 'pass', 'recommended', 'X-Frame-Options: ' . $xfo, __( 'X-Frame-Options provides clickjacking protection. CSP frame-ancestors offers more flexible modern control.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( '' !== $xfo ) { return self::row( 'Clickjacking protection', 'warning', 'recommended', 'X-Frame-Options: ' . $xfo, __( 'A framing header is present, but its value is not DENY or SAMEORIGIN. ALLOW-FROM is obsolete in modern browsers.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Clickjacking protection', 'review', 'recommended', '', __( 'Neither CSP frame-ancestors nor a recognized X-Frame-Options policy was detected.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Analyze Content-Type and charset. * * @param string $content_type Content-Type header. * @return array */ private static function analyze_content_type( $content_type ) { if ( '' === $content_type ) { return self::row( 'Content-Type / charset', 'warning', 'recommended', '', __( 'No Content-Type header was detected on the final response.', 'aiutocomputerhelp-http-header-audit' ) ); } $lower = strtolower( $content_type ); $is_textual = 0 === strpos( $lower, 'text/' ) || false !== strpos( $lower, 'application/json' ) || false !== strpos( $lower, 'application/xml' ) || false !== strpos( $lower, 'application/javascript' ); $has_charset = false !== stripos( $content_type, 'charset=' ); if ( $is_textual && ! $has_charset ) { return self::row( 'Content-Type / charset', 'review', 'recommended', $content_type, __( 'A textual response was detected without an explicit charset parameter. Review whether the server should declare one.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( $is_textual ) { return self::row( 'Content-Type / charset', 'pass', 'recommended', $content_type, __( 'A textual Content-Type with an explicit charset was detected.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Content-Type / charset', 'info', 'contextual', $content_type, __( 'The response is not a textual content type, so an explicit charset is not expected.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Analyze Referrer-Policy syntax and determine the effective recognized policy. * * The HTTP header may contain a comma-separated fallback list. User agents use * the last policy token they recognize, while unknown tokens are ignored. * * @param string $referrer Referrer-Policy header value. * @return array */ private static function analyze_referrer_policy( $referrer ) { if ( '' === trim( $referrer ) ) { return self::row( 'Referrer-Policy', 'review', 'recommended', '', __( 'No explicit referrer policy was detected. Define one deliberately rather than relying only on browser defaults.', 'aiutocomputerhelp-http-header-audit' ) ); } $recognized = array( 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin', 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url', ); $tokens = array(); foreach ( explode( ',', strtolower( $referrer ) ) as $token ) { $token = trim( $token ); if ( '' !== $token ) { $tokens[] = $token; } } $valid = array(); $unknown = array(); foreach ( $tokens as $token ) { if ( in_array( $token, $recognized, true ) ) { $valid[] = $token; } else { $unknown[] = $token; } } if ( empty( $valid ) ) { return self::row( 'Referrer-Policy', 'warning', 'recommended', $referrer, __( 'The header is present, but no recognized Referrer-Policy value was detected.', 'aiutocomputerhelp-http-header-audit' ) ); } $effective = $valid[ count( $valid ) - 1 ]; if ( 'unsafe-url' === $effective ) { $assessment = __( 'The effective policy is unsafe-url, which can disclose full URLs in cross-origin requests.', 'aiutocomputerhelp-http-header-audit' ); if ( ! empty( $unknown ) ) { $assessment .= ' ' . __( 'Unrecognized policy tokens were also detected and should be reviewed.', 'aiutocomputerhelp-http-header-audit' ); } return self::row( 'Referrer-Policy', 'warning', 'recommended', $referrer, $assessment ); } if ( ! empty( $unknown ) ) { return self::row( 'Referrer-Policy', 'review', 'recommended', $referrer, sprintf( /* translators: %s: effective Referrer-Policy token. */ __( 'A recognized effective policy (%s) was detected, but the header also contains unrecognized policy tokens. Review whether they are intentional fallbacks for newer user agents.', 'aiutocomputerhelp-http-header-audit' ), $effective ) ); } if ( count( $valid ) > 1 ) { return self::row( 'Referrer-Policy', 'pass', 'recommended', $referrer, sprintf( /* translators: %s: effective Referrer-Policy token. */ __( 'A valid fallback list was detected. The last recognized policy, %s, is effective for supporting user agents.', 'aiutocomputerhelp-http-header-audit' ), $effective ) ); } return self::row( 'Referrer-Policy', 'pass', 'recommended', $referrer, __( 'A recognized referrer policy was detected.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Analyze Permissions-Policy syntax conservatively. * * This validates the HTTP response header grammar without maintaining a * hard-coded registry of feature names. Unknown/experimental directives may * be valid, so the directive token is syntax-checked rather than allowlisted. * * @param string $permissions Permissions-Policy header value. * @return array */ private static function analyze_permissions_policy( $permissions ) { $permissions = trim( (string) $permissions ); if ( '' === $permissions ) { return self::row( 'Permissions-Policy', 'info', 'contextual', '', __( 'Not set. This policy is application-specific; consider restricting browser features that the site does not need.', 'aiutocomputerhelp-http-header-audit' ) ); } $directives = self::split_permissions_policy_directives( $permissions ); $valid_count = 0; $invalid = array(); $wildcard = false; foreach ( $directives as $directive_text ) { $directive_text = trim( $directive_text ); if ( '' === $directive_text || false === strpos( $directive_text, '=' ) ) { $invalid[] = $directive_text; continue; } list( $feature, $allowlist ) = array_map( 'trim', explode( '=', $directive_text, 2 ) ); if ( ! preg_match( '/^[A-Za-z][A-Za-z0-9-]*$/', $feature ) || '' === $allowlist ) { $invalid[] = $directive_text; continue; } if ( '*' === $allowlist ) { $valid_count++; $wildcard = true; continue; } if ( ! self::valid_permissions_allowlist( $allowlist ) ) { $invalid[] = $directive_text; continue; } $valid_count++; } if ( 0 === $valid_count ) { return self::row( 'Permissions-Policy', 'warning', 'contextual', $permissions, __( 'The header is present, but no syntactically valid Permissions-Policy directive was detected.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( ! empty( $invalid ) ) { return self::row( 'Permissions-Policy', 'review', 'contextual', $permissions, __( 'At least one valid Permissions-Policy directive was detected, but one or more directives or allowlists are malformed. Review the policy before relying on it.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( $wildcard ) { return self::row( 'Permissions-Policy', 'review', 'contextual', $permissions, __( 'The policy syntax is valid, but a wildcard allowlist grants a browser feature to all origins. Confirm that this broad access is intentional.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Permissions-Policy', 'pass', 'contextual', $permissions, __( 'A syntactically valid browser feature policy was detected. Verify that the selected features and origins match what the site actually needs.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Split Permissions-Policy directives on commas outside quoted strings and * parenthesized allowlists. * * @param string $header Header value. * @return array */ private static function split_permissions_policy_directives( $header ) { $parts = array(); $current = ''; $depth = 0; $quoted = false; $length = strlen( $header ); for ( $i = 0; $i < $length; $i++ ) { $char = $header[ $i ]; if ( '"' === $char && ( 0 === $i || '\\\\' !== $header[ $i - 1 ] ) ) { $quoted = ! $quoted; } if ( ! $quoted ) { if ( '(' === $char ) { $depth++; } elseif ( ')' === $char && $depth > 0 ) { $depth--; } elseif ( ',' === $char && 0 === $depth ) { $parts[] = trim( $current ); $current = ''; continue; } } $current .= $char; } $parts[] = trim( $current ); return $parts; } /** * Validate a Permissions-Policy HTTP-header allowlist. * * Accepted forms are (), (self), quoted HTTP(S) origins, combinations of * self plus quoted origins, or a standalone wildcard handled by the caller. * * @param string $allowlist Allowlist expression. * @return bool */ private static function valid_permissions_allowlist( $allowlist ) { $allowlist = trim( $allowlist ); if ( strlen( $allowlist ) < 2 || '(' !== $allowlist[0] || ')' !== $allowlist[ strlen( $allowlist ) - 1 ] ) { return false; } $inner = trim( substr( $allowlist, 1, -1 ) ); if ( '' === $inner ) { return true; } $tokens = array(); if ( ! preg_match_all( '/(?:self|"(?:[^"\\\\]|\\\\.)+")(?=\\s|$)/', $inner, $matches ) ) { return false; } $tokens = $matches[0]; $rebuilt = trim( implode( ' ', $tokens ) ); $normalized_inner = trim( preg_replace( '/\\s+/', ' ', $inner ) ); if ( $rebuilt !== $normalized_inner ) { return false; } foreach ( $tokens as $token ) { if ( 'self' === $token ) { continue; } $origin = stripcslashes( substr( $token, 1, -1 ) ); if ( ! preg_match( '#^https?://(?:\\*\\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|\\[[0-9A-Fa-f:]+\\])(?::(?:[0-9]{1,5}|\\*))?$#', $origin ) ) { return false; } } return true; } /** * Analyze Cross-Origin-Opener-Policy (COOP). * * @param string $coop Header value. * @return array */ private static function analyze_coop( $coop ) { $coop = trim( (string) $coop ); if ( '' === $coop ) { return self::row( 'Cross-Origin-Opener-Policy', 'info', 'contextual', '', __( 'Not set. COOP is application-specific; it is mainly useful when opener isolation or cross-origin isolation is required.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( false !== strpos( $coop, ',' ) || false !== strpos( $coop, ';' ) ) { return self::row( 'Cross-Origin-Opener-Policy', 'warning', 'contextual', $coop, __( 'The COOP header should contain one recognized policy token. Multiple or parameterized values are not treated as a valid COOP policy by this audit.', 'aiutocomputerhelp-http-header-audit' ) ); } $value = strtolower( $coop ); $recognized = array( 'unsafe-none', 'same-origin', 'same-origin-allow-popups', 'noopener-allow-popups' ); if ( ! in_array( $value, $recognized, true ) ) { return self::row( 'Cross-Origin-Opener-Policy', 'warning', 'contextual', $coop, __( 'An unrecognized Cross-Origin-Opener-Policy value was detected.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( 'unsafe-none' === $value ) { return self::row( 'Cross-Origin-Opener-Policy', 'info', 'contextual', $coop, __( 'COOP is explicitly set to unsafe-none, which keeps the default opener-sharing behavior and does not provide opener isolation.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( 'same-origin-allow-popups' === $value ) { return self::row( 'Cross-Origin-Opener-Policy', 'pass', 'contextual', $coop, __( 'A recognized COOP policy is present. same-origin-allow-popups intentionally relaxes popup isolation for integrations such as OAuth or payment flows.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( 'noopener-allow-popups' === $value ) { return self::row( 'Cross-Origin-Opener-Policy', 'pass', 'contextual', $coop, __( 'A recognized COOP policy is present. noopener-allow-popups severs opener relationships more aggressively, including between some same-origin documents.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Cross-Origin-Opener-Policy', 'pass', 'contextual', $coop, __( 'COOP same-origin is enabled and can contribute to opener isolation and the prerequisites for cross-origin isolation.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Analyze Cross-Origin-Embedder-Policy (COEP). * * @param string $coep Header value. * @return array */ private static function analyze_coep( $coep ) { $coep = trim( (string) $coep ); if ( '' === $coep ) { return self::row( 'Cross-Origin-Embedder-Policy', 'info', 'contextual', '', __( 'Not set. COEP is application-specific and is mainly needed when a document requires cross-origin isolation or stricter embedding rules.', 'aiutocomputerhelp-http-header-audit' ) ); } $parsed = self::parse_coep( $coep ); if ( false === $parsed ) { return self::row( 'Cross-Origin-Embedder-Policy', 'warning', 'contextual', $coep, __( 'The COEP header is malformed, contains multiple policy tokens, or uses an unrecognized policy value or parameter.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( 'unsafe-none' === $parsed['policy'] ) { return self::row( 'Cross-Origin-Embedder-Policy', 'info', 'contextual', $coep, __( 'COEP is explicitly set to unsafe-none, which keeps the default cross-origin embedding behavior and does not contribute to cross-origin isolation.', 'aiutocomputerhelp-http-header-audit' ) ); } $assessment = 'credentialless' === $parsed['policy'] ? __( 'COEP credentialless is enabled. Cross-origin no-cors requests are made without credentials, which can support cross-origin isolation while changing resource-loading behavior.', 'aiutocomputerhelp-http-header-audit' ) : __( 'COEP require-corp is enabled. Cross-origin no-cors resources must explicitly permit embedding through CORP or be requested using an appropriate CORS mode.', 'aiutocomputerhelp-http-header-audit' ); if ( $parsed['has_report_to'] ) { $assessment .= ' ' . __( 'A report-to endpoint parameter is also present.', 'aiutocomputerhelp-http-header-audit' ); } return self::row( 'Cross-Origin-Embedder-Policy', 'pass', 'contextual', $coep, $assessment ); } /** * Parse one COEP header value. * * COEP accepts a single policy token and an optional report-to parameter. * Multiple field values or policy tokens are deliberately rejected by this audit. * * @param string $coep Header value. * @return array|false */ private static function parse_coep( $coep ) { if ( false !== strpos( $coep, ',' ) ) { return false; } $parts = array_map( 'trim', explode( ';', $coep ) ); if ( empty( $parts ) || '' === $parts[0] ) { return false; } $policy = strtolower( array_shift( $parts ) ); if ( ! in_array( $policy, array( 'unsafe-none', 'require-corp', 'credentialless' ), true ) ) { return false; } $has_report_to = false; foreach ( $parts as $parameter ) { if ( '' === $parameter || false === strpos( $parameter, '=' ) ) { return false; } list( $name, $value ) = array_map( 'trim', explode( '=', $parameter, 2 ) ); if ( 'report-to' !== strtolower( $name ) || $has_report_to || '' === $value ) { return false; } if ( strlen( $value ) >= 2 && '"' === $value[0] && '"' === $value[ strlen( $value ) - 1 ] ) { $value = substr( $value, 1, -1 ); if ( '' === $value || preg_match( '/(? $policy, 'has_report_to' => $has_report_to, ); } /** * Analyze Cross-Origin-Resource-Policy (CORP). * * @param string $corp Header value. * @return array */ private static function analyze_corp( $corp ) { $corp = trim( (string) $corp ); if ( '' === $corp ) { return self::row( 'Cross-Origin-Resource-Policy', 'info', 'contextual', '', __( 'Not set. CORP is resource- and application-specific; public resources may intentionally remain loadable cross-origin.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( false !== strpos( $corp, ',' ) || false !== strpos( $corp, ';' ) ) { return self::row( 'Cross-Origin-Resource-Policy', 'warning', 'contextual', $corp, __( 'The CORP header should contain one recognized policy token.', 'aiutocomputerhelp-http-header-audit' ) ); } $value = strtolower( $corp ); if ( ! in_array( $value, array( 'same-origin', 'same-site', 'cross-origin' ), true ) ) { return self::row( 'Cross-Origin-Resource-Policy', 'warning', 'contextual', $corp, __( 'An unrecognized Cross-Origin-Resource-Policy value was detected.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( 'cross-origin' === $value ) { return self::row( 'Cross-Origin-Resource-Policy', 'review', 'contextual', $corp, __( 'CORP explicitly allows cross-origin loading. This is valid for public resources and some COEP deployments, but the broad policy should be intentional.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( 'same-site' === $value ) { return self::row( 'Cross-Origin-Resource-Policy', 'pass', 'contextual', $corp, __( 'CORP same-site is enabled. It restricts no-cors loading to the same site, which is broader than same-origin and should match the resource-sharing design.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Cross-Origin-Resource-Policy', 'pass', 'contextual', $corp, __( 'CORP same-origin is enabled, restricting no-cors loading of this response to the same origin.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Summarize whether the response headers satisfy the main header prerequisites * for cross-origin isolation. This is not a browser-runtime proof; callers should * still verify Window.crossOriginIsolated where the application depends on it. * * @param string $coop COOP header value. * @param string $coep COEP header value. * @param string $permissions Permissions-Policy value. * @param string $url Final audited URL. * @return array */ private static function analyze_cross_origin_isolation( $coop, $coep, $permissions, $url ) { $coop_value = strtolower( trim( (string) $coop ) ); $coep_parsed = self::parse_coep( trim( (string) $coep ) ); $coep_value = false === $coep_parsed ? '' : $coep_parsed['policy']; $pair_ready = 'same-origin' === $coop_value && in_array( $coep_value, array( 'require-corp', 'credentialless' ), true ); $permission_state = self::cross_origin_isolated_permission_state( $permissions, $url ); $observed_bits = array(); if ( '' !== trim( (string) $coop ) ) { $observed_bits[] = 'COOP=' . trim( (string) $coop ); } if ( '' !== trim( (string) $coep ) ) { $observed_bits[] = 'COEP=' . trim( (string) $coep ); } if ( 'blocked' === $permission_state ) { $observed_bits[] = 'Permissions-Policy blocks cross-origin-isolated'; } elseif ( 'unknown' === $permission_state ) { $observed_bits[] = 'Permissions-Policy state unclear'; } $observed = implode( '; ', $observed_bits ); if ( $pair_ready ) { if ( 'blocked' === $permission_state ) { return self::row( 'Cross-origin isolation', 'review', 'contextual', $observed, __( 'COOP and COEP satisfy the usual isolation header pair, but Permissions-Policy blocks the cross-origin-isolated feature. Browser APIs that depend on crossOriginIsolated will remain unavailable.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( 'unknown' === $permission_state ) { return self::row( 'Cross-origin isolation', 'review', 'contextual', $observed, __( 'COOP and COEP satisfy the usual isolation header pair, but the relevant Permissions-Policy state could not be determined confidently.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Cross-origin isolation', 'pass', 'contextual', $observed, __( 'The response headers satisfy the main header prerequisites for cross-origin isolation. Verify the runtime state with window.crossOriginIsolated if the application depends on isolated-only APIs.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( '' === trim( (string) $coop ) && '' === trim( (string) $coep ) ) { return self::row( 'Cross-origin isolation', 'info', 'contextual', '', __( 'Cross-origin isolation is not requested by these response headers. Most sites do not require it unless they use APIs or isolation properties that depend on COOP and COEP.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Cross-origin isolation', 'info', 'contextual', $observed, __( 'The full COOP same-origin plus COEP require-corp/credentialless header pair is not present. Individual COOP or COEP policies may still be intentional and useful on their own.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Determine whether Permissions-Policy allows cross-origin-isolated for the * audited top-level origin. The directive defaults to self when omitted. * * @param string $permissions Permissions-Policy header value. * @param string $url Audited URL. * @return string allowed|blocked|unknown */ private static function cross_origin_isolated_permission_state( $permissions, $url ) { $permissions = trim( (string) $permissions ); if ( '' === $permissions ) { return 'allowed'; } $directives = self::split_permissions_policy_directives( $permissions ); $found = false; foreach ( $directives as $directive_text ) { if ( false === strpos( $directive_text, '=' ) ) { continue; } list( $feature, $allowlist ) = array_map( 'trim', explode( '=', $directive_text, 2 ) ); if ( 'cross-origin-isolated' !== strtolower( $feature ) ) { continue; } $found = true; if ( '*' === $allowlist ) { return 'allowed'; } if ( ! self::valid_permissions_allowlist( $allowlist ) ) { return 'unknown'; } $inner = trim( substr( $allowlist, 1, -1 ) ); if ( '' === $inner ) { return 'blocked'; } if ( preg_match( '/(?:^|\s)self(?:\s|$)/', $inner ) ) { return 'allowed'; } $origin = self::url_origin( $url ); if ( '' === $origin ) { return 'unknown'; } if ( preg_match_all( '/(?:self|"(?:[^"\\\\]|\\\\.)+")(?=\\s|$)/', $inner, $token_matches ) ) { foreach ( $token_matches[0] as $token ) { if ( strlen( $token ) >= 2 && '"' === $token[0] && '"' === $token[ strlen( $token ) - 1 ] ) { $candidate = stripcslashes( substr( $token, 1, -1 ) ); if ( strtolower( $candidate ) === strtolower( $origin ) ) { return 'allowed'; } } } } return 'blocked'; } return $found ? 'unknown' : 'allowed'; } /** * Return scheme + host + explicit non-default port for an audited URL. * * @param string $url URL. * @return string */ private static function url_origin( $url ) { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return ''; } $scheme = strtolower( $parts['scheme'] ); $host = strtolower( $parts['host'] ); $origin = $scheme . '://' . $host; if ( isset( $parts['port'] ) ) { $port = (int) $parts['port']; if ( ! ( 'http' === $scheme && 80 === $port ) && ! ( 'https' === $scheme && 443 === $port ) ) { $origin .= ':' . $port; } } return $origin; } /** * Analyze basic CORS response exposure conservatively. * * This is intentionally not a full CORS authorization test. It validates the * main response-header syntax and flags a few objective incompatibilities, but * it does not assume that a wildcard or a specific allowed origin is unsafe by * itself. Whether an origin should be authorized is application-specific. * * @param string $allow_origin Access-Control-Allow-Origin value. * @param string $allow_credentials Access-Control-Allow-Credentials value. * @param string $allow_methods Access-Control-Allow-Methods value. * @param string $allow_headers Access-Control-Allow-Headers value. * @param string $vary Vary value. * @return array */ private static function analyze_cors_exposure( $allow_origin, $allow_credentials, $allow_methods, $allow_headers, $vary ) { $allow_origin = trim( (string) $allow_origin ); $allow_credentials = trim( (string) $allow_credentials ); $allow_methods = trim( (string) $allow_methods ); $allow_headers = trim( (string) $allow_headers ); $vary = trim( (string) $vary ); $has_cors = '' !== $allow_origin || '' !== $allow_credentials || '' !== $allow_methods || '' !== $allow_headers; if ( ! $has_cors ) { return self::row( 'CORS exposure', 'info', 'contextual', '', __( 'No explicit CORS sharing headers were detected on this response. This does not prevent normal same-origin browser access.', 'aiutocomputerhelp-http-header-audit' ) ); } $warnings = array(); $reviews = array(); $notes = array(); $observed = array(); $origin_kind = 'missing'; if ( '' !== $allow_origin ) { $observed[] = 'Origin=' . $allow_origin; if ( '*' === $allow_origin ) { $origin_kind = 'wildcard'; $notes[] = __( 'Access-Control-Allow-Origin uses the wildcard, which is valid for public non-credentialed CORS access.', 'aiutocomputerhelp-http-header-audit' ); } elseif ( 'null' === $allow_origin ) { $origin_kind = 'null'; $reviews[] = __( 'Access-Control-Allow-Origin is set to null. This value is valid syntax but is risky because sandboxed or non-hierarchical documents can also have a null origin.', 'aiutocomputerhelp-http-header-audit' ); } elseif ( false !== strpos( $allow_origin, ',' ) || ! self::valid_cors_origin( $allow_origin ) ) { $origin_kind = 'invalid'; $warnings[] = __( 'Access-Control-Allow-Origin must contain a single serialized origin, the wildcard *, or null. The observed value is not valid for this audit.', 'aiutocomputerhelp-http-header-audit' ); } else { $origin_kind = 'explicit'; $notes[] = __( 'A single explicit CORS origin was detected. Whether that origin should be authorized depends on the application.', 'aiutocomputerhelp-http-header-audit' ); } } $credentials_enabled = false; if ( '' !== $allow_credentials ) { $observed[] = 'Credentials=' . $allow_credentials; if ( 'true' !== $allow_credentials ) { $warnings[] = __( 'Access-Control-Allow-Credentials only accepts the case-sensitive value true; otherwise the header should be omitted.', 'aiutocomputerhelp-http-header-audit' ); } else { $credentials_enabled = true; $notes[] = __( 'Credentialed CORS access is enabled and should only be paired with origins that are deliberately trusted.', 'aiutocomputerhelp-http-header-audit' ); } } $method_tokens = array(); if ( '' !== $allow_methods ) { $observed[] = 'Methods=' . $allow_methods; $method_tokens = self::parse_cors_token_list( $allow_methods ); if ( false === $method_tokens ) { $warnings[] = __( 'Access-Control-Allow-Methods contains an empty or invalid HTTP method token.', 'aiutocomputerhelp-http-header-audit' ); $method_tokens = array(); } } $header_tokens = array(); if ( '' !== $allow_headers ) { $observed[] = 'Headers=' . $allow_headers; $header_tokens = self::parse_cors_token_list( $allow_headers ); if ( false === $header_tokens ) { $warnings[] = __( 'Access-Control-Allow-Headers contains an empty or invalid HTTP field-name token.', 'aiutocomputerhelp-http-header-audit' ); $header_tokens = array(); } } if ( 'missing' === $origin_kind && ( '' !== $allow_credentials || '' !== $allow_methods || '' !== $allow_headers ) ) { $reviews[] = __( 'CORS auxiliary response headers are present without Access-Control-Allow-Origin. Browsers will not expose a cross-origin response based on these headers alone.', 'aiutocomputerhelp-http-header-audit' ); } if ( $credentials_enabled && 'wildcard' === $origin_kind ) { $warnings[] = __( 'Access-Control-Allow-Origin: * is incompatible with credentialed CORS requests. Credentialed sharing requires an explicit origin.', 'aiutocomputerhelp-http-header-audit' ); } if ( $credentials_enabled && in_array( '*', $method_tokens, true ) ) { $reviews[] = __( 'Access-Control-Allow-Methods uses * while credentials are enabled. For credentialed requests the wildcard does not have wildcard semantics.', 'aiutocomputerhelp-http-header-audit' ); } if ( $credentials_enabled && in_array( '*', $header_tokens, true ) ) { $reviews[] = __( 'Access-Control-Allow-Headers uses * while credentials are enabled. For credentialed requests the wildcard does not have wildcard semantics.', 'aiutocomputerhelp-http-header-audit' ); } if ( 'explicit' === $origin_kind ) { $vary_tokens = self::parse_vary_tokens( $vary ); if ( in_array( 'origin', $vary_tokens, true ) ) { $notes[] = __( 'Vary: Origin was detected, which is appropriate when Access-Control-Allow-Origin is selected dynamically from the request Origin.', 'aiutocomputerhelp-http-header-audit' ); } else { $notes[] = __( 'Vary: Origin was not detected. This is not necessarily an error for a fixed single-origin policy, but it is normally needed when Access-Control-Allow-Origin changes according to the request Origin.', 'aiutocomputerhelp-http-header-audit' ); } } $observed_text = implode( '; ', $observed ); if ( ! empty( $warnings ) ) { $assessment = implode( ' ', array_values( array_unique( $warnings ) ) ); if ( ! empty( $reviews ) ) { $assessment .= ' ' . implode( ' ', array_values( array_unique( $reviews ) ) ); } return self::row( 'CORS exposure', 'warning', 'contextual', $observed_text, $assessment ); } if ( ! empty( $reviews ) ) { $assessment = implode( ' ', array_values( array_unique( $reviews ) ) ); if ( ! empty( $notes ) ) { $assessment .= ' ' . implode( ' ', array_values( array_unique( $notes ) ) ); } return self::row( 'CORS exposure', 'review', 'contextual', $observed_text, $assessment ); } $assessment = __( 'A syntactically coherent basic CORS response policy was detected. This audit does not determine whether the allowed origin is authorized by the application.', 'aiutocomputerhelp-http-header-audit' ); if ( ! empty( $notes ) ) { $assessment .= ' ' . implode( ' ', array_values( array_unique( $notes ) ) ); } return self::row( 'CORS exposure', 'info', 'contextual', $observed_text, $assessment ); } /** * Validate one serialized HTTP(S) origin for Access-Control-Allow-Origin. * * @param string $origin Origin value. * @return bool */ private static function valid_cors_origin( $origin ) { $parts = wp_parse_url( $origin ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return false; } if ( ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) { return false; } if ( isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['query'] ) || isset( $parts['fragment'] ) ) { return false; } if ( isset( $parts['path'] ) && '' !== $parts['path'] ) { return false; } if ( isset( $parts['port'] ) && ( (int) $parts['port'] < 1 || (int) $parts['port'] > 65535 ) ) { return false; } return true; } /** * Parse a comma-separated CORS token list. * * @param string $value Header value. * @return array|false */ private static function parse_cors_token_list( $value ) { $parts = array_map( 'trim', explode( ',', (string) $value ) ); if ( empty( $parts ) ) { return false; } $tokens = array(); foreach ( $parts as $part ) { if ( '' === $part || ! self::valid_http_token( $part ) ) { return false; } $tokens[] = $part; } return $tokens; } /** * Parse Vary as lower-case field-name tokens. Invalid entries are ignored here * because Vary syntax is not itself the subject of this CORS check. * * @param string $vary Vary header value. * @return array */ private static function parse_vary_tokens( $vary ) { $tokens = array(); foreach ( array_map( 'trim', explode( ',', (string) $vary ) ) as $part ) { if ( '' !== $part && self::valid_http_token( $part ) ) { $tokens[] = strtolower( $part ); } } return array_values( array_unique( $tokens ) ); } /** * Analyze caching at a deliberately conservative level. * * @param string $cache_control Cache-Control header. * @param string $set_cookie Set-Cookie header if present. * @return array */ private static function analyze_cache_control( $cache_control, $set_cookie ) { if ( '' === $cache_control ) { return self::row( 'Cache-Control', 'info', 'contextual', '', __( 'No Cache-Control header was detected. Public pages do not universally require no-store; caching policy should match the sensitivity of the response.', 'aiutocomputerhelp-http-header-audit' ) ); } $directives = self::split_cache_control_directives( $cache_control ); $recognized = array(); $extensions = array(); $invalid = array(); $no_value = array( 'must-revalidate', 'proxy-revalidate', 'no-store', 'public', 'must-understand', 'no-transform', 'immutable', ); $numeric = array( 'max-age', 's-maxage', 'stale-while-revalidate', 'stale-if-error', ); $optional_field_list = array( 'no-cache', 'private' ); $request_numeric = array( 'min-fresh' ); $request_optional_numeric = array( 'max-stale' ); $request_no_value = array( 'only-if-cached' ); $request_only = array(); foreach ( $directives as $directive_text ) { $parsed = self::parse_cache_control_directive( $directive_text ); if ( false === $parsed ) { $invalid[] = $directive_text; continue; } $name = $parsed['name']; $has_value = $parsed['has_value']; $value = $parsed['value']; $is_quoted = $parsed['quoted']; if ( in_array( $name, $no_value, true ) ) { if ( $has_value ) { $invalid[] = $directive_text; } else { $recognized[] = $name; } continue; } if ( in_array( $name, $numeric, true ) ) { if ( ! $has_value || ! preg_match( '/^[0-9]+$/', $value ) ) { $invalid[] = $directive_text; } else { $recognized[] = $name; } continue; } if ( in_array( $name, $optional_field_list, true ) ) { if ( ! $has_value ) { $recognized[] = $name; } elseif ( $is_quoted && self::valid_cache_field_name_list( $value ) ) { $recognized[] = $name; } else { $invalid[] = $directive_text; } continue; } if ( in_array( $name, $request_numeric, true ) ) { if ( ! $has_value || $is_quoted || ! preg_match( '/^[0-9]+$/', $value ) ) { $invalid[] = $directive_text; } else { $request_only[] = $name; } continue; } if ( in_array( $name, $request_optional_numeric, true ) ) { if ( $has_value && ( $is_quoted || ! preg_match( '/^[0-9]+$/', $value ) ) ) { $invalid[] = $directive_text; } else { $request_only[] = $name; } continue; } if ( in_array( $name, $request_no_value, true ) ) { if ( $has_value ) { $invalid[] = $directive_text; } else { $request_only[] = $name; } continue; } $extensions[] = $name; } if ( ! empty( $invalid ) ) { return self::row( 'Cache-Control', 'warning', 'contextual', $cache_control, __( 'One or more Cache-Control directives are malformed or use an invalid value for a standard directive. Review the header before relying on its caching behavior.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( empty( $recognized ) && empty( $request_only ) && ! empty( $extensions ) ) { return self::row( 'Cache-Control', 'review', 'contextual', $cache_control, __( 'Only unrecognized Cache-Control extension directives were detected. Extensions are permitted, but no standard response caching behavior can be inferred from this header.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( ! empty( $request_only ) ) { return self::row( 'Cache-Control', 'review', 'contextual', $cache_control, __( 'The response contains one or more Cache-Control directives normally defined for requests. Confirm that they are intentional and not the result of a configuration mistake.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( ! empty( $extensions ) ) { return self::row( 'Cache-Control', 'review', 'contextual', $cache_control, __( 'Standard Cache-Control directives were detected together with unrecognized extension directives. Extension directives are allowed, but their effect should be reviewed separately.', 'aiutocomputerhelp-http-header-audit' ) ); } if ( '' !== $set_cookie && ( in_array( 'public', $recognized, true ) || in_array( 's-maxage', $recognized, true ) ) ) { return self::row( 'Cache-Control', 'review', 'contextual', $cache_control, __( 'The response sets a cookie while explicitly allowing shared/public caching. Confirm that this behavior is intentional and does not expose personalized content.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Cache-Control', 'info', 'contextual', $cache_control, __( 'A syntactically valid standard cache policy is present. Whether it is appropriate depends on the sensitivity and personalization of this response.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Split Cache-Control directives on commas outside quoted strings. * * @param string $header Header value. * @return array */ private static function split_cache_control_directives( $header ) { $parts = array(); $current = ''; $quoted = false; $escaped = false; $length = strlen( $header ); for ( $i = 0; $i < $length; $i++ ) { $char = $header[ $i ]; if ( $quoted && '\\' === $char && ! $escaped ) { $escaped = true; $current .= $char; continue; } if ( '"' === $char && ! $escaped ) { $quoted = ! $quoted; } $escaped = false; if ( ',' === $char && ! $quoted ) { $parts[] = trim( $current ); $current = ''; continue; } $current .= $char; } $parts[] = trim( $current ); return $parts; } /** * Parse one Cache-Control directive using HTTP token / quoted-string syntax. * * @param string $directive Directive text. * @return array|false */ private static function parse_cache_control_directive( $directive ) { $directive = trim( $directive ); if ( '' === $directive ) { return false; } if ( false === strpos( $directive, '=' ) ) { if ( ! self::valid_http_token( $directive ) ) { return false; } return array( 'name' => strtolower( $directive ), 'has_value' => false, 'value' => '', 'quoted' => false, ); } list( $name, $raw_value ) = array_map( 'trim', explode( '=', $directive, 2 ) ); if ( ! self::valid_http_token( $name ) || '' === $raw_value ) { return false; } $is_quoted = strlen( $raw_value ) >= 2 && '"' === $raw_value[0] && '"' === $raw_value[ strlen( $raw_value ) - 1 ]; if ( $is_quoted ) { $inner = substr( $raw_value, 1, -1 ); if ( preg_match( '/(? strtolower( $name ), 'has_value' => true, 'value' => $value, 'quoted' => $is_quoted, ); } /** * Validate an HTTP token. * * @param string $value Token candidate. * @return bool */ private static function valid_http_token( $value ) { return 1 === preg_match( "/^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/", $value ); } /** * Validate the quoted field-name list accepted by no-cache/private. * * @param string $value Unquoted quoted-string content. * @return bool */ private static function valid_cache_field_name_list( $value ) { $names = array_map( 'trim', explode( ',', $value ) ); if ( empty( $names ) ) { return false; } foreach ( $names as $name ) { if ( '' === $name || ! self::valid_http_token( $name ) ) { return false; } } return true; } /** * Analyze Set-Cookie security attributes without exposing cookie values. * * The audit reports only cookie names and selected attributes. Generic absence * of Secure, HttpOnly, or SameSite is informational because the correct choice * depends on the cookie's purpose. Objective attribute/prefix violations are * reported as warnings. * * @param array $set_cookie_headers Individual Set-Cookie header field values. * @param string $url Final audited URL. * @return array */ private static function analyze_cookie_security( $set_cookie_headers, $url ) { $headers = array(); foreach ( (array) $set_cookie_headers as $header ) { if ( is_scalar( $header ) && '' !== trim( (string) $header ) ) { $headers[] = trim( (string) $header ); } } if ( empty( $headers ) ) { return self::row( 'Cookie security', 'info', 'contextual', '', __( 'No Set-Cookie headers were detected on the audited response.', 'aiutocomputerhelp-http-header-audit' ) ); } $is_https = 'https' === strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); $is_localhost = self::is_localhost_url( $url ); $cookie_summaries = array(); $warnings = array(); $reviews = array(); $informational = array(); foreach ( $headers as $header ) { $parsed = self::parse_set_cookie_header( $header ); if ( false === $parsed ) { $warnings[] = __( 'A Set-Cookie header could not be parsed safely.', 'aiutocomputerhelp-http-header-audit' ); $cookie_summaries[] = __( 'Unparseable cookie', 'aiutocomputerhelp-http-header-audit' ); continue; } $name = $parsed['name']; $attrs = $parsed['attributes']; $duplicates = $parsed['duplicates']; $has_secure = isset( $attrs['secure'] ); $has_httponly = isset( $attrs['httponly'] ); $has_partitioned = isset( $attrs['partitioned'] ); $has_domain = isset( $attrs['domain'] ); $path = isset( $attrs['path'] ) && $attrs['path']['has_value'] ? $attrs['path']['value'] : null; $samesite = isset( $attrs['samesite'] ) && $attrs['samesite']['has_value'] ? strtolower( $attrs['samesite']['value'] ) : ''; $summary_bits = array(); $summary_bits[] = $has_secure ? 'Secure=yes' : 'Secure=no'; $summary_bits[] = $has_httponly ? 'HttpOnly=yes' : 'HttpOnly=no'; $summary_bits[] = '' !== $samesite ? 'SameSite=' . ucfirst( $samesite ) : 'SameSite=not set'; if ( $has_partitioned ) { $summary_bits[] = 'Partitioned=yes'; } $cookie_summaries[] = $name . ' [' . implode( '; ', $summary_bits ) . ']'; foreach ( array( 'secure', 'httponly', 'partitioned' ) as $flag_attribute ) { if ( isset( $attrs[ $flag_attribute ] ) && $attrs[ $flag_attribute ]['has_value'] ) { $warnings[] = sprintf( /* translators: 1: cookie name, 2: cookie attribute. */ __( 'Cookie %1$s uses %2$s with an unexpected value; this attribute is a flag.', 'aiutocomputerhelp-http-header-audit' ), $name, $flag_attribute ); } } if ( isset( $attrs['samesite'] ) ) { if ( ! $attrs['samesite']['has_value'] || ! in_array( $samesite, array( 'lax', 'strict', 'none' ), true ) ) { $warnings[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s has an invalid SameSite value.', 'aiutocomputerhelp-http-header-audit' ), $name ); } elseif ( 'none' === $samesite && ! $has_secure ) { $warnings[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s uses SameSite=None without Secure.', 'aiutocomputerhelp-http-header-audit' ), $name ); } } else { $informational[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s does not explicitly set SameSite.', 'aiutocomputerhelp-http-header-audit' ), $name ); } if ( $has_partitioned && ! $has_secure ) { $warnings[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s uses Partitioned without Secure.', 'aiutocomputerhelp-http-header-audit' ), $name ); } $is_secure_prefix = 0 === strpos( $name, '__Secure-' ); $is_host_prefix = 0 === strpos( $name, '__Host-' ); $is_http_prefix = 0 === strpos( $name, '__Http-' ); $is_host_http_prefix = 0 === strpos( $name, '__Host-Http-' ); if ( $is_secure_prefix || $is_host_prefix || $is_http_prefix || $is_host_http_prefix ) { if ( ! $has_secure ) { $warnings[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s uses a secure cookie-name prefix but does not set the required Secure attribute.', 'aiutocomputerhelp-http-header-audit' ), $name ); } elseif ( ! $is_https && ! $is_localhost ) { $warnings[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s uses a secure cookie-name prefix on a non-HTTPS response.', 'aiutocomputerhelp-http-header-audit' ), $name ); } } if ( $is_host_prefix || $is_host_http_prefix ) { if ( $has_domain ) { $warnings[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s uses a __Host- style prefix but also sets Domain.', 'aiutocomputerhelp-http-header-audit' ), $name ); } if ( '/' !== $path ) { $warnings[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s uses a __Host- style prefix but Path is not explicitly set to /.', 'aiutocomputerhelp-http-header-audit' ), $name ); } } if ( ( $is_http_prefix || $is_host_http_prefix ) && ! $has_httponly ) { $warnings[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s uses an __Http- style prefix without HttpOnly.', 'aiutocomputerhelp-http-header-audit' ), $name ); } if ( ! empty( $duplicates ) ) { $security_duplicates = array_intersect( $duplicates, array( 'secure', 'httponly', 'samesite', 'partitioned', 'domain', 'path' ) ); if ( ! empty( $security_duplicates ) ) { $reviews[] = sprintf( /* translators: 1: cookie name, 2: comma-separated attribute names. */ __( 'Cookie %1$s repeats security-relevant attributes (%2$s). Review which value user agents will apply.', 'aiutocomputerhelp-http-header-audit' ), $name, implode( ', ', array_unique( $security_duplicates ) ) ); } } if ( ! $has_secure ) { $informational[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s does not set Secure; whether this is appropriate depends on its purpose and transport requirements.', 'aiutocomputerhelp-http-header-audit' ), $name ); } if ( ! $has_httponly ) { $informational[] = sprintf( /* translators: %s: cookie name. */ __( 'Cookie %s does not set HttpOnly; this can be legitimate when JavaScript access is required.', 'aiutocomputerhelp-http-header-audit' ), $name ); } } $observed = implode( ' | ', $cookie_summaries ); if ( ! empty( $warnings ) ) { $assessment = implode( ' ', array_values( array_unique( $warnings ) ) ); if ( ! empty( $reviews ) ) { $assessment .= ' ' . implode( ' ', array_values( array_unique( $reviews ) ) ); } return self::row( 'Cookie security', 'warning', 'contextual', $observed, $assessment ); } if ( ! empty( $reviews ) ) { return self::row( 'Cookie security', 'review', 'contextual', $observed, implode( ' ', array_values( array_unique( $reviews ) ) ) ); } $assessment = __( 'No objective Set-Cookie attribute conflicts checked by this version were detected.', 'aiutocomputerhelp-http-header-audit' ); if ( ! empty( $informational ) ) { $assessment .= ' ' . implode( ' ', array_values( array_unique( $informational ) ) ); } return self::row( 'Cookie security', 'info', 'contextual', $observed, $assessment ); } /** * Whether a URL uses a loopback/localhost host suitable for local development. * * Browsers commonly treat localhost and loopback addresses as trustworthy local * contexts even when the scheme is HTTP. This exception is used only to avoid * reporting a transport-origin warning for secure cookie-name prefixes; it does * not waive the Secure attribute requirement itself. * * @param string $url URL being audited. * @return bool */ private static function is_localhost_url( $url ) { $host = strtolower( trim( (string) wp_parse_url( $url, PHP_URL_HOST ), '[]' ) ); return in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ); } /** * Parse one Set-Cookie field value without retaining the cookie value. * * @param string $header Set-Cookie field value. * @return array|false */ private static function parse_set_cookie_header( $header ) { $parts = array_map( 'trim', explode( ';', (string) $header ) ); if ( empty( $parts ) || '' === $parts[0] || false === strpos( $parts[0], '=' ) ) { return false; } list( $name ) = explode( '=', $parts[0], 2 ); $name = trim( $name ); if ( '' === $name || ! self::valid_http_token( $name ) ) { return false; } $attributes = array(); $duplicates = array(); array_shift( $parts ); foreach ( $parts as $part ) { if ( '' === $part ) { continue; } $has_value = false !== strpos( $part, '=' ); if ( $has_value ) { list( $raw_name, $raw_value ) = explode( '=', $part, 2 ); $attribute_name = strtolower( trim( $raw_name ) ); $attribute_value = trim( $raw_value ); } else { $attribute_name = strtolower( trim( $part ) ); $attribute_value = ''; } if ( '' === $attribute_name || ! self::valid_http_token( $attribute_name ) ) { return false; } if ( isset( $attributes[ $attribute_name ] ) ) { $duplicates[] = $attribute_name; } $attributes[ $attribute_name ] = array( 'has_value' => $has_value, 'value' => $attribute_value, ); } return array( 'name' => $name, 'attributes' => $attributes, 'duplicates' => $duplicates, ); } /** * Retrieve every response header field value without joining repeated fields. * * This is important for Set-Cookie because Expires values contain commas and * repeated Set-Cookie fields must not be parsed as a comma-separated list. * * @param array $response HTTP response. * @param string $name Header name. * @return array */ private static function get_header_values( $response, $name ) { $value = wp_remote_retrieve_header( $response, $name ); if ( is_array( $value ) ) { return array_values( array_map( 'strval', $value ) ); } if ( is_scalar( $value ) && '' !== trim( (string) $value ) ) { return array( trim( (string) $value ) ); } return array(); } /** * Look for a small set of obvious conflicting singleton security policies. * * @param string $hsts HSTS value. * @param string $xfo X-Frame-Options value. * @param string $frame_ancestors CSP frame-ancestors value. * @return array */ private static function analyze_header_consistency( $hsts, $xfo, $frame_ancestors ) { $issues = array(); $max_ages = self::hsts_max_ages( $hsts ); if ( count( array_unique( $max_ages ) ) > 1 ) { $issues[] = __( 'Conflicting HSTS max-age directives.', 'aiutocomputerhelp-http-header-audit' ); } if ( false !== strpos( $xfo, ',' ) ) { $values = array_filter( array_map( 'trim', explode( ',', strtoupper( $xfo ) ) ) ); if ( count( array_unique( $values ) ) > 1 ) { $issues[] = __( 'Multiple different X-Frame-Options values.', 'aiutocomputerhelp-http-header-audit' ); } } if ( '' !== $frame_ancestors && '' !== $xfo ) { $fa = strtolower( trim( $frame_ancestors ) ); $xfo_upper = strtoupper( trim( $xfo ) ); if ( ( "'none'" === $fa && 'SAMEORIGIN' === $xfo_upper ) || ( self::csp_value_has_wildcard( $fa ) && in_array( $xfo_upper, array( 'DENY', 'SAMEORIGIN' ), true ) ) ) { $issues[] = __( 'CSP frame-ancestors and X-Frame-Options express different framing intentions.', 'aiutocomputerhelp-http-header-audit' ); } } if ( empty( $issues ) ) { return self::row( 'Header consistency', 'pass', 'recommended', '', __( 'No obvious conflicting singleton security header values were detected by this version.', 'aiutocomputerhelp-http-header-audit' ) ); } return self::row( 'Header consistency', 'warning', 'recommended', implode( ' ', $issues ), __( 'Conflicting security policies can produce browser-dependent or difficult-to-understand behavior. Review the listed headers.', 'aiutocomputerhelp-http-header-audit' ) ); } /** * Retrieve a response header as a printable string. * * @param array $response HTTP response. * @param string $name Header name. * @return string */ private static function get_header( $response, $name ) { $value = wp_remote_retrieve_header( $response, $name ); if ( is_array( $value ) ) { return implode( ', ', array_map( 'strval', $value ) ); } return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * Extract every HSTS max-age directive found. * * @param string $header HSTS value. * @return array */ private static function hsts_max_ages( $header ) { $values = array(); if ( preg_match_all( '/(?:^|[;,])\s*max-age\s*=\s*([0-9]+)/i', $header, $matches ) ) { foreach ( $matches[1] as $value ) { $values[] = (int) $value; } } return $values; } /** * Get a CSP directive value. * * @param string $csp CSP value. * @param string $name Directive name. * @return string */ private static function csp_directive( $csp, $name ) { $name = preg_quote( $name, '/' ); if ( '' !== $csp && preg_match( '/(?:^|;)\s*' . $name . '\s+([^;]+)/i', $csp, $matches ) ) { return trim( $matches[1] ); } return ''; } /** * Return a CSP directive or its default-src fallback where applicable. * * @param string $csp CSP value. * @param string $name Directive name. * @return string */ private static function effective_csp_directive( $csp, $name ) { $value = self::csp_directive( $csp, $name ); if ( '' !== $value ) { return $value; } return self::csp_directive( $csp, 'default-src' ); } /** * Check for a CSP token in a directive value. * * @param string $value Directive value. * @param string $token Token to locate. * @return bool */ private static function csp_value_has_token( $value, $token ) { return (bool) preg_match( '/(^|\s)' . preg_quote( $token, '/' ) . '(\s|$)/i', trim( $value ) ); } /** * Check for a standalone wildcard CSP source. * * @param string $value Directive value. * @return bool */ private static function csp_value_has_wildcard( $value ) { return (bool) preg_match( '/(^|\s)\*(\s|$)/', trim( $value ) ); } /** * Check for nonce or hash expressions in a CSP source list. * * @param string $value Directive value. * @return bool */ private static function csp_value_has_nonce_or_hash( $value ) { return (bool) preg_match( "/'nonce-[^']+'|'sha(?:256|384|512)-[^']+'/i", $value ); } /** * Format a redirect chain for display. * * @param array $chain Fetch chain. * @return string */ private static function format_chain( $chain ) { $parts = array(); foreach ( $chain as $hop ) { if ( ! is_array( $hop ) || empty( $hop['url'] ) ) { continue; } $parts[] = (int) $hop['code'] . ' ' . $hop['url']; } return implode( ' → ', $parts ); } /** * Build a result row. * * @param string $name Check name. * @param string $status pass, review, warning, or info. * @param string $priority high, recommended, contextual, or informational. * @param string $observed Observed header/value. * @param string $assessment Human-readable assessment. * @return array */ private static function row( $name, $status, $priority, $observed, $assessment ) { $allowed_statuses = array( 'pass', 'review', 'warning', 'info' ); $allowed_priorities = array( 'high', 'recommended', 'contextual', 'informational' ); return array( 'name' => sanitize_text_field( $name ), 'status' => in_array( $status, $allowed_statuses, true ) ? $status : 'info', 'priority' => in_array( $priority, $allowed_priorities, true ) ? $priority : 'informational', 'observed' => sanitize_textarea_field( (string) $observed ), 'assessment' => sanitize_textarea_field( (string) $assessment ), ); } } ACHSHA_Security_Headers_Audit::init();