`s that make a browser load something: a style sheet, or a * hint to fetch or connect ahead of time. A canonical address, a feed or * a profile is a link the browser never follows. */ private const LINKS = array( 'stylesheet' => 'style', 'preload' => 'link', 'modulepreload' => 'link', 'prefetch' => 'link', 'prerender' => 'link', 'preconnect' => 'link', 'dns-prefetch' => 'link', ); /** * The scan's token. * * @var string */ private $token; /** * The page's place in the scan. * * @var int */ private $index; /** * Output flushed before the page was complete. * * @var string */ private $pending = ''; /** * Constructor. * * @param string $token The scan's token. * @param int $index The page's place in the scan. */ public function __construct( string $token, int $index ) { $this->token = $token; $this->index = $index; } /** * Start watching the page. * * @return void */ public function start(): void { ob_start( array( $this, 'handle' ) ); } /** * The buffer's handler: notes what goes out, changes nothing. * * @param string $chunk Output. * @param int $phase PHP_OUTPUT_HANDLER_* flags. * @return string */ public function handle( string $chunk, int $phase ): string { if ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) { $this->pending = ''; return ''; } $this->pending .= $chunk; if ( $phase & PHP_OUTPUT_HANDLER_FINAL ) { try { Session::note( $this->token, $this->index, 'server', array( 'status' => (int) http_response_code(), 'cookies' => self::cookies( headers_list() ), ) + self::tags( $this->pending, self::signatures() ) ); } catch ( Throwable $failure ) { // A scan that learns nothing from this page is the lesser evil // to a page that does not arrive. unset( $failure ); } $this->pending = ''; } return $chunk; } /** * The texts by which known services are recognised in inline code. * * @return array */ public static function signatures(): array { $signatures = array(); foreach ( Registry::all() as $service ) { foreach ( (array) ( $service['scripts'] ?? array() ) as $script ) { if ( is_array( $script ) && 'inline' === ( $script['target'] ?? 'url' ) && isset( $script['match'] ) && is_string( $script['match'] ) ) { $signatures[] = $script['match']; } } } return array_values( array_unique( $signatures ) ); } /** * The cookies a response sets. * * @param array $headers Response headers, as headers_list() gives them. * @return array `expires` is a Unix time; 0 for a cookie that ends with the browser session. */ public static function cookies( array $headers ): array { $cookies = array(); foreach ( $headers as $header ) { if ( 0 !== stripos( $header, 'set-cookie:' ) ) { continue; } $parts = array_map( 'trim', explode( ';', substr( $header, 11 ) ) ); // What stands before the first "=" (strtok() would skip an empty name). $name = trim( explode( '=', (string) array_shift( $parts ), 2 )[0] ); if ( '' === $name ) { continue; } $cookie = array( 'name' => Text::cut( $name, 100 ), 'domain' => '', 'expires' => 0, 'http_only' => false, ); $max_age = null; foreach ( $parts as $part ) { $pair = explode( '=', $part, 2 ); $key = strtolower( trim( $pair[0] ) ); $value = isset( $pair[1] ) ? trim( $pair[1] ) : ''; if ( 'httponly' === $key ) { $cookie['http_only'] = true; } elseif ( 'domain' === $key ) { $cookie['domain'] = Text::cut( strtolower( $value ), 120 ); } elseif ( 'max-age' === $key && is_numeric( $value ) ) { $max_age = (int) $value; } elseif ( 'expires' === $key ) { $cookie['expires'] = (int) strtotime( $value ); } } // Max-Age wins over Expires, as it does in a browser. if ( null !== $max_age ) { $cookie['expires'] = time() + $max_age; } // A cookie that is being deleted (an end in the past) is not one the site sets. if ( 0 !== $cookie['expires'] && $cookie['expires'] <= time() ) { unset( $cookies[ $name ] ); continue; } $cookies[ $name ] = $cookie; }//end foreach return array_values( $cookies ); } /** * The scripts, frames, images and links of a page's markup, and the * known signatures its inline scripts carry. * * @param string $html The page. * @param array $signatures Texts to look for in inline code. * @return array{tags: array, inline: array} Tags as [ kind, address ]; kind is script, iframe, img, style (a style sheet) or link (a hint to load or connect ahead). */ public static function tags( string $html, array $signatures ): array { $tags = array(); $inline = array(); $page = new WP_HTML_Tag_Processor( $html ); $found = 0; while ( $found < self::MAX_TAGS && $page->next_tag() ) { $tag = $page->get_tag(); if ( ! in_array( $tag, array( 'SCRIPT', 'IFRAME', 'IMG', 'LINK' ), true ) ) { continue; } $kind = strtolower( $tag ); if ( 'LINK' === $tag ) { $kind = ''; $rels = $page->get_attribute( 'rel' ); foreach ( is_string( $rels ) ? preg_split( '/\s+/', strtolower( trim( $rels ) ) ) : array() as $rel ) { if ( isset( self::LINKS[ $rel ] ) ) { $kind = self::LINKS[ $rel ]; break; } } if ( '' === $kind ) { continue; } } // A page seen through this plugin's own blocking keeps its addresses under other names. $address = $page->get_attribute( 'LINK' === $tag ? 'href' : 'src' ); $address = is_string( $address ) && '' !== $address ? $address : $page->get_attribute( 'LINK' === $tag ? 'data-wmcc-href' : 'data-wmcc-src' ); if ( is_string( $address ) && '' !== $address ) { $tags[] = array( $kind, Text::cut( $address, 500 ) ); ++$found; continue; } if ( 'SCRIPT' !== $tag ) { continue; } // Data for scripts (JSON, templates) is not code that runs. $type = $page->get_attribute( 'type' ); if ( is_string( $type ) && '' !== $type && 1 !== preg_match( '#^(module|text/plain|(text|application)/(x-)?(java|ecma)script)$#i', trim( $type ) ) ) { continue; } $code = strtolower( $page->get_modifiable_text() ); foreach ( $signatures as $signature ) { if ( '' !== $signature && false !== strpos( $code, strtolower( $signature ) ) ) { $inline[ $signature ] = $signature; } } }//end while return array( 'tags' => $tags, 'inline' => array_values( $inline ), ); } }