is_html_response() ) { return $html; } $dry_run = (bool) Helper::get( 'dry_run', false ); if ( Helper::get( 'buffer_gating', true ) ) { $filtered = preg_replace_callback( '#]*)>(.*?)#is', function ( $match ) use ( $dry_run ) { return $this->process( $match, $dry_run ); }, $html ); // A backtrack limit or a catastrophic pattern returns null. // Serving the original page ungated is bad; serving an empty page // is worse. $html = null === $filtered ? $html : $filtered; } if ( Helper::get( 'embed_gating', true ) ) { $html = $this->gate_elements( $html, $dry_run ); } return $html; } /** * The iframe and pixel pass. * * Runs on everything *between* the script elements rather than on the whole * document. An inline script is perfectly entitled to contain the text * `` — in a template string, in a JSON blob, * in an example — and rewriting it there would not gate a request, it would * corrupt the script. Splitting on script blocks and skipping the captured * halves costs one more pass and removes the whole class of problem. * * @param string $html The document. * @param bool $dry_run Report instead of rewrite. * @return string */ private function gate_elements( $html, $dry_run ) { $has_iframe = false !== stripos( $html, ']*>.*?)#is', $html, -1, PREG_SPLIT_DELIM_CAPTURE ); if ( ! is_array( $parts ) ) { return $html; } foreach ( $parts as $index => $part ) { // Odd indices are the captured script blocks themselves. if ( 1 === $index % 2 || '' === $part ) { continue; } if ( $has_iframe ) { $done = preg_replace_callback( '#]*)>(.*?)#is', function ( $match ) use ( $dry_run ) { return $this->process_embed( $match, $dry_run ); }, $part ); $part = null === $done ? $part : $done; } if ( $has_img ) { $done = preg_replace_callback( '#]*?)/?>#is', function ( $match ) use ( $dry_run ) { return $this->process_pixel( $match, $dry_run ); }, $part ); $part = null === $done ? $part : $done; } $parts[ $index ] = $part; } return implode( '', $parts ); } /** * One iframe: leave it, or stand a consent card where it was. * * @param array $match Regex match: 0 whole, 1 attributes, 2 contents. * @param bool $dry_run Report instead of rewrite. * @return string */ private function process_embed( $match, $dry_run ) { $whole = $match[0]; $attrs = $match[1]; if ( false !== stripos( $attrs, 'data-ablocks-consent' ) ) { return $whole; } $src = $this->attribute( $attrs, 'src' ); if ( '' === (string) $src ) { return $whole; } $rule = Embeds::match( $src, 'embed' ); if ( ! $rule ) { // An unrecognised third-party iframe is the same kind of gap as an // unrecognised third-party script, and worth the same report. if ( Gating::is_third_party( $src ) ) { Report::add( $src, '', 'iframe' ); } return $whole; } if ( $dry_run ) { Report::add( $src, $rule['category'], 'iframe' ); return $whole; } // An invisible beacon is stripped the way a pixel is. It occupies no // space, so there is no gap to explain and nothing to offer to load. if ( ! empty( $rule['beacon'] ) ) { return $this->strip_source( $whole, 'iframe', $rule['category'] ); } return Embeds::placeholder( $whole, $rule ); } /** * One image: leave it, or take its source away. * * No placeholder and no announcement. A tracking pixel is not content the * visitor is missing, and drawing a consent card where a 1×1 beacon used to * be would invent a loss to apologise for. * * @param array $match Regex match: 0 whole, 1 attributes. * @param bool $dry_run Report instead of rewrite. * @return string */ private function process_pixel( $match, $dry_run ) { $whole = $match[0]; $attrs = $match[1]; if ( false !== stripos( $attrs, 'data-ablocks-consent' ) ) { return $whole; } $src = $this->attribute( $attrs, 'src' ); if ( '' === (string) $src ) { return $whole; } $rule = Embeds::match( $src, 'pixel' ); if ( ! $rule ) { return $whole; } if ( $dry_run ) { Report::add( $src, $rule['category'], 'pixel' ); return $whole; } return $this->strip_source( $whole, 'img', $rule['category'] ); } /** * Move an element's `src` aside and label it with its category. * * Renaming the attribute is the whole mechanism: a browser does not fetch * `data-ablocks-src`, and the element stays exactly where the author put * it, keeping whatever size and styling it had. * * @param string $tag The whole element. * @param string $name Tag name, `img` or `iframe`. * @param string $category Category slug. * @return string */ private function strip_source( $tag, $name, $category ) { $rewritten = preg_replace( '/\ssrc=/i', ' data-ablocks-src=', $tag, 1 ); // `\b`, not `\s`: the same trap that made `ScriptGate` silently skip a // `'; } /** * Whether this script would run if left alone. * * @param string $attrs Raw attribute string. * @return bool */ private function is_executable( $attrs ) { $type = strtolower( trim( (string) $this->attribute( $attrs, 'type' ) ) ); return in_array( $type, self::$executable_types, true ); } /** * Pull one attribute's value out of a raw attribute string. * * @param string $attrs Raw attribute string. * @param string $name Attribute name. * @return string */ private function attribute( $attrs, $name ) { if ( preg_match( '/\s' . preg_quote( $name, '/' ) . '\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', ' ' . $attrs, $found ) ) { foreach ( [ 2, 3, 4 ] as $group ) { if ( isset( $found[ $group ] ) && '' !== $found[ $group ] ) { return $found[ $group ]; } } } return ''; } /** * Only rewrite documents that are actually HTML. A plugin that hijacks the * request to return XML or a file download still passes through this * buffer, and must come out the other side untouched. * * @return bool */ private function is_html_response() { foreach ( headers_list() as $header ) { if ( 0 === stripos( $header, 'content-type:' ) ) { return (bool) stripos( $header, 'text/html' ); } } // No explicit header means PHP's default, which is text/html. return true; } }