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();