, inline: array, stylesheets: string[], policies: string[]} */ public function parse_html( $html, $page_url ) { $result = array( 'resources' => array(), 'inline' => array( 'script-src-elem' => 0, 'script-src-attr' => 0, 'style-src-elem' => 0, 'style-src-attr' => 0, ), 'stylesheets' => array(), 'policies' => array(), ); if ( '' === trim( (string) $html ) ) { return $result; } $doc = new \DOMDocument(); $previous = libxml_use_internal_errors( true ); $doc->loadHTML( '' . $html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $base = $page_url; foreach ( $doc->getElementsByTagName( 'base' ) as $element ) { if ( $element->hasAttribute( 'href' ) ) { $base = UrlResolver::resolve( $element->getAttribute( 'href' ), $page_url ); break; } } $add = static function ( $directive, $raw ) use ( &$result, $base ) { $url = UrlResolver::resolve( $raw, $base ); if ( '' !== $url ) { $result['resources'][] = array( 'directive' => $directive, 'url' => $url, ); } return $url; }; foreach ( $doc->getElementsByTagName( '*' ) as $element ) { /** Element. @var \DOMElement $element */ $tag = strtolower( $element->tagName ); foreach ( $element->attributes as $attribute ) { $name = strtolower( $attribute->name ); if ( 0 === strpos( $name, 'on' ) && strlen( $name ) > 2 ) { ++$result['inline']['script-src-attr']; } elseif ( 'style' === $name && '' !== trim( $attribute->value ) ) { ++$result['inline']['style-src-attr']; $this->css_urls( $attribute->value, $base, $add ); } elseif ( in_array( $name, array( 'href', 'src', 'action', 'formaction' ), true ) && preg_match( '/^\s*javascript:/i', $attribute->value ) ) { ++$result['inline']['script-src-attr']; } } switch ( $tag ) { case 'script': $type = strtolower( trim( $element->getAttribute( 'type' ) ) ); if ( in_array( $type, self::DATA_SCRIPT_TYPES, true ) ) { break; } if ( $element->hasAttribute( 'src' ) ) { $add( 'script-src-elem', $element->getAttribute( 'src' ) ); } elseif ( '' !== trim( $element->textContent ) ) { ++$result['inline']['script-src-elem']; } break; case 'link': $this->link( $element, $add, $result ); break; case 'meta': // Browsers only apply a policy meta tag in the head. if ( 'content-security-policy' === strtolower( trim( $element->getAttribute( 'http-equiv' ) ) ) && 'head' === strtolower( $element->parentNode->nodeName ) ) { $result['policies'][] = $element->getAttribute( 'content' ); } break; case 'style': if ( '' !== trim( $element->textContent ) ) { ++$result['inline']['style-src-elem']; $this->css_urls( $element->textContent, $base, $add ); } break; case 'img': case 'source': case 'input': $directive = 'img-src'; if ( 'source' === $tag && $element->parentNode instanceof \DOMElement && in_array( strtolower( $element->parentNode->tagName ), array( 'video', 'audio' ), true ) ) { $directive = 'media-src'; } if ( 'input' === $tag && 'image' !== strtolower( $element->getAttribute( 'type' ) ) ) { break; } if ( $element->hasAttribute( 'src' ) ) { $add( $directive, $element->getAttribute( 'src' ) ); } foreach ( $this->srcset( $element->getAttribute( 'srcset' ) ) as $candidate ) { $add( $directive, $candidate ); } break; case 'video': case 'audio': case 'track': if ( $element->hasAttribute( 'src' ) ) { $add( 'media-src', $element->getAttribute( 'src' ) ); } if ( $element->hasAttribute( 'poster' ) ) { $add( 'img-src', $element->getAttribute( 'poster' ) ); } break; case 'iframe': case 'frame': if ( $element->hasAttribute( 'src' ) ) { $add( 'frame-src', $element->getAttribute( 'src' ) ); } break; case 'object': if ( $element->hasAttribute( 'data' ) ) { $add( 'object-src', $element->getAttribute( 'data' ) ); } break; case 'embed': if ( $element->hasAttribute( 'src' ) ) { $add( 'object-src', $element->getAttribute( 'src' ) ); } break; case 'form': // A form without action submits to the page itself. $add( 'form-action', $element->hasAttribute( 'action' ) && '' !== trim( $element->getAttribute( 'action' ) ) ? $element->getAttribute( 'action' ) : $page_url ); break; } } $result['resources'] = $this->unique( $result['resources'] ); return $result; } /** * Handle a link element. * * @param \DOMElement $element Element. * @param callable $add Adds a resource. * @param array $result Result (stylesheets are recorded). * @return void */ private function link( \DOMElement $element, callable $add, array &$result ) { if ( ! $element->hasAttribute( 'href' ) ) { return; } $rel = preg_split( '/\s+/', strtolower( trim( $element->getAttribute( 'rel' ) ) ) ); $href = $element->getAttribute( 'href' ); if ( in_array( 'stylesheet', $rel, true ) ) { $url = $add( 'style-src-elem', $href ); if ( '' !== $url ) { $result['stylesheets'][] = $url; } } elseif ( array_intersect( $rel, array( 'icon', 'apple-touch-icon', 'shortcut', 'mask-icon' ) ) ) { $add( 'img-src', $href ); } elseif ( in_array( 'manifest', $rel, true ) ) { $add( 'manifest-src', $href ); } elseif ( array_intersect( $rel, array( 'preload', 'modulepreload' ) ) ) { $map = array( 'script' => 'script-src-elem', 'style' => 'style-src-elem', 'font' => 'font-src', 'image' => 'img-src', 'fetch' => 'connect-src', 'audio' => 'media-src', 'video' => 'media-src', ); $as = strtolower( $element->getAttribute( 'as' ) ); if ( in_array( 'modulepreload', $rel, true ) ) { $as = 'script'; } if ( isset( $map[ $as ] ) ) { $add( $map[ $as ], $href ); } } } /** * Find url() and @import references in CSS. * * @param string $css CSS. * @param string $base Base URL. * @param callable $add Adds a resource. * @return void */ public function css_urls( $css, $base, callable $add ) { $css = (string) preg_replace( '~/\*.*?\*/~s', '', (string) $css ); if ( preg_match_all( '~@import\s+(?:url\(\s*)?["\']?([^"\')\s;]+)~i', $css, $imports ) ) { foreach ( $imports[1] as $import ) { $add( 'style-src-elem', $import ); } } // Font files are fonts wherever they appear (in practice, @font-face src). if ( preg_match_all( '~(? */ public function parse_css( $css, $url ) { $resources = array(); $this->css_urls( $css, $url, static function ( $directive, $raw ) use ( &$resources, $url ) { $resolved = UrlResolver::resolve( $raw, $url ); if ( '' !== $resolved ) { $resources[] = array( 'directive' => $directive, 'url' => $resolved, ); } return $resolved; } ); return $this->unique( $resources ); } /** * URLs from a srcset attribute. * * @param string $srcset Attribute value. * @return string[] */ private function srcset( $srcset ) { $urls = array(); foreach ( explode( ',', (string) $srcset ) as $candidate ) { $parts = preg_split( '/\s+/', trim( $candidate ) ); if ( ! empty( $parts[0] ) ) { $urls[] = $parts[0]; } } return $urls; } /** * Remove duplicate resources. * * @param array $resources Resources. * @return array */ private function unique( array $resources ) { $seen = array(); $unique = array(); foreach ( $resources as $resource ) { $key = $resource['directive'] . ' ' . $resource['url']; if ( ! isset( $seen[ $key ] ) ) { $seen[ $key ] = true; $unique[] = $resource; } } return $unique; } }