settings = $settings;
$this->classifier = new Classifier( $settings );
}
/**
* Last scan result.
*
* @return array{time:int,pages:string[],errors:string[],items:array}
*/
public function last() {
$r = get_option( self::OPTION, array() );
return wp_parse_args(
is_array( $r ) ? $r : array(),
array(
'time' => 0,
'pages' => array(),
'errors' => array(),
'reason' => '',
'items' => array(),
)
);
}
/**
* Whether a request carries a valid scan token (the page is then served unfiltered).
*
* @return bool
*/
public static function is_scan_request() {
if ( ! isset( $_GET['goconsent_scan'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token checked below.
return false;
}
$token = get_transient( self::TOKEN_KEY );
$given = sanitize_text_field( wp_unslash( $_GET['goconsent_scan'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return $token && hash_equals( (string) $token, $given );
}
/**
* Scan the site now.
*
* @return array
*/
public function run() {
// One scan at a time: each page fetch holds a PHP worker on this server.
if ( get_transient( 'goconsent_scan_lock' ) ) {
return $this->last();
}
set_transient( 'goconsent_scan_lock', 1, 5 * MINUTE_IN_SECONDS );
$token = wp_generate_password( 24, false );
set_transient( self::TOKEN_KEY, $token, 5 * MINUTE_IN_SECONDS );
$items = array();
$errors = array();
$reason = '';
$pages = array();
$pp = $this->settings->privacy_page_id();
$pp_url = $pp ? get_permalink( $pp ) : '';
$fails = 0;
$started = microtime( true );
foreach ( $this->urls() as $url ) {
/*
* Loopback safety: on small hosting plans every page fetch needs a free PHP
* worker. Stop after two failed or slow fetches, or after 45 seconds, so the
* scan can never tie up the site.
*/
if ( $fails >= 2 || microtime( true ) - $started > 45 ) {
$errors[] = $url;
continue;
}
$t0 = microtime( true );
$res = self::fetch( add_query_arg( 'goconsent_scan', $token, $url ) );
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
$errors[] = $url;
if ( '' === $reason ) {
$reason = self::explain( $res );
}
++$fails;
continue;
}
if ( microtime( true ) - $t0 > 6 ) {
++$fails; // Slow response: the server is busy, back off.
}
$pages[] = $url;
$html = (string) wp_remote_retrieve_body( $res );
$this->parse( $html, $url, $items );
if ( $pp_url && $url === $pp_url ) {
update_option(
self::POLICY_LIVE,
array(
'text' => self::page_text( $html ),
'time' => time(),
'page' => $pp,
),
false
);
}
}
delete_transient( self::TOKEN_KEY );
delete_transient( 'goconsent_scan_lock' );
$result = array(
'time' => time(),
'pages' => $pages,
'errors' => $errors,
'reason' => substr( sanitize_text_field( $reason ), 0, 300 ),
'items' => $items,
);
update_option( self::OPTION, $result, false );
return $result;
}
/**
* Visible main text of a page, as visitors read it (theme templates included).
*
* @param string $html Page HTML.
* @return string
*/
public static function page_text( $html ) {
$html = preg_replace( '#<(script|style|noscript|template|svg|header|footer|nav|dialog)\b[^>]*>.*?\1>#is', ' ', $html );
$html = preg_replace( '#
]+class="[^"]*\bgc-(banner|footer-link)\b[^"]*"[^>]*>.*?
#is', ' ', $html );
if ( preg_match( '#]*>(.*)#is', $html, $m ) ) {
$html = $m[1];
} elseif ( preg_match( '#]*>(.*)#is', $html, $m ) ) {
$html = $m[1];
}
$text = html_entity_decode( wp_strip_all_tags( str_replace( '<', ' <', $html ) ), ENT_QUOTES, 'UTF-8' );
return substr( trim( preg_replace( '/\s+/u', ' ', $text ) ), 0, 100000 );
}
/**
* Re-read only the privacy page as visitors see it (after someone edits it).
*
* @return bool Whether the page was read.
*/
public function refresh_policy() {
$pp = $this->settings->privacy_page_id();
if ( ! $pp ) {
return false;
}
$token = wp_generate_password( 24, false );
set_transient( self::TOKEN_KEY, $token, MINUTE_IN_SECONDS );
$res = self::fetch( add_query_arg( 'goconsent_scan', $token, get_permalink( $pp ) ) );
delete_transient( self::TOKEN_KEY );
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
// Don't keep checking an old copy: fall back to the saved page text.
delete_option( self::POLICY_LIVE );
return false;
}
$text = self::page_text( (string) wp_remote_retrieve_body( $res ) );
$title = trim( wp_strip_all_tags( html_entity_decode( get_the_title( $pp ), ENT_QUOTES, 'UTF-8' ) ) );
// A maintenance, coming-soon or login page answered instead of the privacy page.
if ( '' !== $title && false === stripos( $text, $title ) ) {
delete_option( self::POLICY_LIVE );
return false;
}
update_option(
self::POLICY_LIVE,
array(
'text' => $text,
'time' => time(),
'page' => $pp,
),
false
);
return true;
}
/**
* Plain-English reason a page couldn't be read, with the technical detail after it.
*
* @param array|\WP_Error $res Response.
* @return string
*/
public static function explain( $res ) {
if ( is_wp_error( $res ) ) {
$msg = (string) $res->get_error_message();
$map = array(
'/cURL error 28|timed out/i' => __( 'The site took too long to answer itself (server busy or slow).', 'goconsent' ),
'/cURL error (35|51|58|59|60|77)|SSL|certificate/i' => __( 'This server can’t verify its own SSL certificate.', 'goconsent' ),
'/cURL error (6|7)\b|resolve|couldn.t connect|refused/i' => __( 'This server can’t connect to its own web address (hosting or DNS setting).', 'goconsent' ),
'/blocked|WP_HTTP_BLOCK_EXTERNAL/i' => __( 'Outgoing requests are blocked in wp-config.php (WP_HTTP_BLOCK_EXTERNAL).', 'goconsent' ),
);
foreach ( $map as $re => $text ) {
if ( preg_match( $re, $msg ) ) {
return $text . ' (' . $msg . ')';
}
}
return $msg;
}
$code = (int) wp_remote_retrieve_response_code( $res );
$page = self::block_text( $res );
if ( in_array( $code, array( 403, 406, 429, 503 ), true ) ) {
$who = self::blocker( $res );
if ( $who ) {
/* translators: 1: firewall name and fix, 2: HTTP status code. */
return sprintf( __( '%1$s (HTTP %2$d)', 'goconsent' ), $who, $code ) . $page;
}
}
$map = array(
401 => __( 'The site is password protected.', 'goconsent' ),
403 => __( 'A firewall or security plugin blocked the scanner.', 'goconsent' ),
404 => __( 'The page wasn’t found.', 'goconsent' ),
429 => __( 'A firewall rate-limited the scanner.', 'goconsent' ),
503 => __( 'The site is in maintenance mode or overloaded.', 'goconsent' ),
);
$text = isset( $map[ $code ] ) ? $map[ $code ] : ( $code >= 500 ? __( 'The page had a server error.', 'goconsent' ) : ( $code >= 300 && $code < 400 ? __( 'The page redirects too many times.', 'goconsent' ) : __( 'The page didn’t load normally.', 'goconsent' ) ) );
/* translators: %d: HTTP status code. */
return $text . ' ' . sprintf( __( '(HTTP %d)', 'goconsent' ), $code ) . $page;
}
/**
* The visible text of an error page, quoted, so the person can tell who blocked the scan.
*
* @param array $res Response.
* @return string
*/
private static function block_text( $res ) {
$html = (string) wp_remote_retrieve_body( $res );
$html = preg_replace( '#<(script|style|noscript)\b[^>]*>.*?\1>#is', ' ', substr( $html, 0, 20000 ) );
$text = trim( preg_replace( '/\s+/u', ' ', html_entity_decode( wp_strip_all_tags( str_replace( '<', ' <', $html ) ), ENT_QUOTES, 'UTF-8' ) ) );
$srv = sanitize_text_field( (string) wp_remote_retrieve_header( $res, 'server' ) );
if ( '' === $text && '' === $srv ) {
return '';
}
$text = function_exists( 'mb_substr' ) ? mb_substr( $text, 0, 160 ) : substr( $text, 0, 160 );
/* translators: 1: error page text, 2: web server name. */
return ' ' . sprintf( __( 'Error page: “%1$s” (server: %2$s).', 'goconsent' ), sanitize_text_field( $text ), $srv ? $srv : '?' );
}
/**
* Name the firewall that blocked the scanner, from the block page and headers, plus what to allow.
*
* @param array $res Response.
* @return string
*/
private static function blocker( $res ) {
$body = strtolower( substr( (string) wp_remote_retrieve_body( $res ), 0, 20000 ) );
$server = strtolower( (string) wp_remote_retrieve_header( $res, 'server' ) );
$ip = isset( $_SERVER['SERVER_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_ADDR'] ) ) : '';
/* translators: %s: IP address. */
$allow = $ip ? ' ' . sprintf( __( 'Allowlist this server’s IP address (%s) there.', 'goconsent' ), $ip ) : '';
$has = static function ( $needles ) use ( $body ) {
foreach ( (array) $needles as $n ) {
if ( false !== strpos( $body, $n ) ) {
return true;
}
}
return false;
};
if ( $has( array( 'defender', 'wpdef', 'wd_lockout', 'wp-defender' ) ) ) {
$why = $has( array( 'country', 'location', 'region' ) ) ? __( 'its Location banning (country block)', 'goconsent' ) : ( $has( array( 'user agent', 'user-agent', 'bot' ) ) ? __( 'its bot or User Agent rules', 'goconsent' ) : __( 'its firewall', 'goconsent' ) );
/* translators: %s: which Defender feature. */
return sprintf( __( 'Defender (Site Defender) blocked the scanner with %s.', 'goconsent' ), $why ) . $allow;
}
if ( $has( 'wordfence' ) ) {
return __( 'Wordfence blocked the scanner.', 'goconsent' ) . $allow;
}
if ( $has( array( 'ithemes', 'solid security', 'itsec' ) ) ) {
return __( 'Solid Security (iThemes) blocked the scanner.', 'goconsent' ) . $allow;
}
if ( $has( 'sucuri' ) || wp_remote_retrieve_header( $res, 'x-sucuri-id' ) ) {
return __( 'The Sucuri firewall blocked the scanner.', 'goconsent' ) . $allow;
}
if ( false !== strpos( $server, 'cloudflare' ) || wp_remote_retrieve_header( $res, 'cf-ray' ) ) {
return __( 'Cloudflare blocked the scanner (a WAF rule or Bot Fight Mode).', 'goconsent' ) . $allow;
}
if ( $has( array( 'mod_security', 'modsecurity', 'not acceptable' ) ) ) {
return __( 'The web host’s ModSecurity firewall blocked the scanner.', 'goconsent' ) . $allow;
}
if ( $has( array( 'all in one wp security', 'aiowps' ) ) ) {
return __( 'All In One WP Security blocked the scanner.', 'goconsent' ) . $allow;
}
if ( strlen( $body ) < 1500 && ( false !== strpos( $server, 'nginx' ) || false !== strpos( $server, 'apache' ) || false !== strpos( $server, 'litespeed' ) ) ) {
/* translators: %s: web server name. */
return sprintf( __( 'The web host’s firewall blocked the scanner before WordPress loaded (%s).', 'goconsent' ), $server ) . ( $ip ? ' ' . sprintf( /* translators: %s: IP address. */ __( 'Allowlist this server’s IP address (%s) in the hosting panel’s firewall.', 'goconsent' ), $ip ) : '' );
}
if ( defined( 'DEFENDER_VERSION' ) || defined( 'WP_DEFENDER_FILE' ) || class_exists( 'WP_Defender\\Bootstrap', false ) ) {
return __( 'Defender (Site Defender) likely blocked the scanner. Check Firewall > Local Firewall > Malicious bot detector and Banning locations.', 'goconsent' ) . $allow;
}
if ( preg_match( '#(.{1,80}?)#', $body, $m ) ) {
/* translators: %s: block page title. */
return sprintf( __( 'A firewall blocked the scanner. Its block page says “%s”.', 'goconsent' ), sanitize_text_field( $m[1] ) ) . $allow;
}
return '';
}
/**
* Fetch one of this site's own pages, the way WordPress core's own loopback checks do.
*
* SSL is checked as core does for requests to itself ("https_local_ssl_verify", off by
* default): many hosts route a site's requests to itself internally, where the
* certificate can't be verified even though visitors see a valid one.
*
* @param string $url URL on this site.
* @return array|\WP_Error
*/
private static function fetch( $url ) {
return wp_remote_get(
$url,
array(
'timeout' => 8,
'redirection' => 3,
'sslverify' => (bool) apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core filter.
// WordPress's own user agent, as its loopback checks use: some host firewalls block
// anything that calls itself a "scanner". Scan requests are recognised by the token.
'user-agent' => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . home_url(),
// Standard request headers: security plugins (e.g. Defender's "Empty headers") block requests without them.
'headers' => array(
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.9',
'Cache-Control' => 'no-cache',
'Referer' => home_url( '/' ),
),
'cookies' => array(),
)
);
}
/**
* Pages to scan: home, key flows, privacy page, two most recently edited pages, one item per custom post type.
*
* @return string[]
*/
public function urls() {
$urls = array( home_url( '/' ) );
foreach ( array( 'book-now', 'booking', 'contact', 'contact-us', 'checkout', 'shop', 'cart' ) as $slug ) {
$p = get_page_by_path( $slug );
if ( $p && 'publish' === $p->post_status ) {
$urls[] = get_permalink( $p );
}
}
$pp = $this->settings->privacy_page_id();
if ( $pp ) {
$urls[] = get_permalink( $pp );
}
$recent = get_posts(
array(
'post_type' => 'page',
'posts_per_page' => 2,
'orderby' => 'modified',
'post_status' => 'publish',
'fields' => 'ids',
)
);
foreach ( $recent as $id ) {
$urls[] = get_permalink( $id );
}
// One recent item from up to three custom post types (listings, vehicles, products...),
// where maps and booking widgets usually live.
$types = array_slice(
array_values(
get_post_types(
array(
'public' => true,
'_builtin' => false,
)
)
),
0,
3
);
foreach ( $types as $type ) {
$one = get_posts(
array(
'post_type' => $type,
'posts_per_page' => 1,
'post_status' => 'publish',
'fields' => 'ids',
)
);
if ( $one ) {
$urls[] = get_permalink( $one[0] );
}
}
/**
* Filter the URLs the scanner checks.
*
* @param string[] $urls URLs.
*/
$urls = apply_filters( 'goconsent_scan_urls', $urls );
return array_slice( array_values( array_unique( array_filter( $urls ) ) ), 0, 10 );
}
/**
* Record what one page loads.
*
* @param string $html Page HTML.
* @param string $url Page URL.
* @param array $items Items found so far (by reference).
*/
public function parse( $html, $url, array &$items ) {
// Scripts. One tag can match several services (e.g. GA + Ads in one block).
if ( preg_match_all( '##is', $html, $m, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) {
$source = new Source( $html, $url );
foreach ( $m as $mm ) {
$s = array( $mm[0][0], $mm[1][0], $mm[2][0] );
if ( false !== stripos( $s[1], 'data-goconsent-skip' ) || preg_match( '#\btype\s*=\s*["\']?(application/ld\+json|text/template|text/x-template)#i', $s[1] ) ) {
continue;
}
$hay = $s[1] . ' ' . $s[2];
$found = false;
$from = null;
foreach ( Signatures::all() as $id => $sig ) {
foreach ( $sig[2] as $needle ) {
if ( false !== stripos( $hay, $needle ) ) {
$from = null === $from ? $source->detect( $s[1], $mm[0][1] ) : $from;
$this->add( $items, $id, $sig[0], $sig[1], 'script', $needle, $url, true, $from );
$found = true;
break;
}
}
}
if ( $found ) {
continue;
}
$src = Classifier::attr( $s[1], 'src' );
if ( $src && $this->classifier->is_third_party( $src ) ) {
$host = strtolower( (string) wp_parse_url( ( 0 === strpos( $src, '//' ) ? 'https:' : '' ) . html_entity_decode( $src, ENT_QUOTES | ENT_HTML5 ), PHP_URL_HOST ) );
$this->add( $items, 'host:' . $host, $host, Classifier::guess( $src ), 'script', $host, $url, false, $source->detect( $s[1], $mm[0][1] ) );
}
}
}
// Google Fonts: copied to the site only when "Host Google Fonts on this site" is on.
if ( preg_match_all( '#(?:href\s*=\s*["\']|@import\s+(?:url\()?\s*["\']?)((?:https?:)?//fonts\.googleapis\.com/css[^"\')\s]*)#i', $html, $fm ) ) {
foreach ( array_unique( $fm[1] ) as $href ) {
$local = LocalFonts::localize( $href, 15 );
$this->add( $items, 'fonts:google', 'Google Fonts', 'necessary', $local ? 'fonts-local' : 'fonts-remote', 'fonts.googleapis.com', $url, true );
if ( ! $local ) {
$items['fonts:google']['kind'] = 'fonts-remote';
}
}
}
// Embeds.
if ( preg_match_all( '#