HTML-Defang

 view release on metacpan or  search on metacpan

lib/HTML/Defang.pm  view on Meta::CPAN

  mtd => 1,
  maligngroup => 1,
  malignmark => 1,
  mlabeledtr => 1,
  maction => 1,
);

# Some entity conversions for attributes
my $CtrlChars = qr/[\x00-\x08\x0b-\x1f]/;
my %EntityToChar = (quot => '"', apos => "'", amp => '&', 'lt' => '<', 'gt' => '>');
my %CharToEntity = ((reverse %EntityToChar), ' ' => '#x20', '/' => '#x2f', "\x09" => '#x09', "\x0a" => '#x0a');
my %QuoteRe = ('"' => qr/(["&<>\x09\x0a])/, "'" => qr/(['&<>\x09\x0a])/, "" => qr/(['"&<> \/\x09\x0a])/);

# When fixing mismatched tags, sometimes a close tag
#  shouldn't close all the way out
# For example, consider:
#   <table><tr><td><table><tr></td>
# A naive version would see the ending </td>, and thus
#  try to fix the mismatched tags by doing:
#   <table><tr><td><table><tr></tr></table></td>
# This is not what a browser does. So given a tag, we
#  give a list of closing tags which cause us to stop
#  and not close any more
my %MismatchedTagNest = (
  table => [ qw(tbody thead tfoot tr th td caption colgroup col) ],
  tbody => [ qw(tr th td) ],
  tr => [ qw(th td) ],
  font => [ '' ],
);
# Convert to hash of hashes
$_ = { map { $_ => 1 } @$_ } for values %MismatchedTagNest;

# If we see a table, we should expect to see a tbody
#  next. If not, we need to add it because the browser
#  will implicitly open it!
# For each tag, give list of tags that should follow. If
#  we don't find one of them following, we open a new
#  implicit tag of the first one in the list
#  eg. <table><td> -> <table><tr><td>
my %ImplicitOpenTags = (
  table => [ qw(tr tbody thead tfoot caption colgroup col) ],
  thead => [ qw(tr) ],
  tbody => [ qw(tr) ],
  tr => [ qw(td th) ],
);
# Convert to hash of hashes
$_ = { default => $_->[0], map { $_ => 1 } @$_ } for values %ImplicitOpenTags;

my %TableTags = map { $_ => 1 } qw(table tbody thead tfoot tr td th caption colgroup col);
my %BlockTags = map { $_ => 1 } qw(h1 h2 h3 h4 h5 h6 p div pre plaintext address blockquote center form table tbody thead tfoot tr td th caption colgroup col dl ul ol li fieldset);
my %InlineTags = map { $_ => 1 } qw(span abbr acronym q sub sup cite code em kbd samp strong var dfn strike b i u s tt small big nobr a font);
my %NestInlineTags = map { $_ => 1 } qw(span abbr acronym q sub sup cite code em kbd samp strong var dfn strike b i u s tt small big nobr);

# Default list of mismatched tags to track
my %MismatchedTags = (%BlockTags, %InlineTags);

# Elements whose contents are RCDATA (title, textarea) or RAWTEXT (noembed,
#  noframes): the browser does not parse comments or nested tags inside them,
#  only a matching end tag. We must consume their content as opaque text
#  rather than parsing it as HTML, or an attacker can smuggle a literal end
#  tag + active markup past us. See defang_rcdata_content(). (<script>,
#  <style> and the defanged raw-text tags like <xmp>/<iframe> are handled
#  separately.)
my %RawTextTags = map { $_ => 1 } qw(title textarea noembed noframes);

=head1 CONSTRUCTOR

=over 4

=cut

=item I<HTML::Defang-E<gt>new(%Options)>

Constructs a new HTML::Defang object. The following options are supported:

=over 4

=item B<Options>

=over 4

=item B<tags_to_callback>

Array reference of tags for which a call back should be made. If a tag in this array is parsed, the subroutine tags_callback() is invoked.

=item B<attribs_to_callback>

Array reference of tag attributes for which a call back should be made. If an attribute in this array is parsed, the subroutine attribs_callback() is invoked.

=item B<tags_callback>

Subroutine reference to be invoked when a tag listed in @$tags_to_callback is parsed.

=item B<attribs_callback>

Subroutine reference to be invoked when an attribute listed in @$attribs_to_callback is parsed.

=item B<url_callback>

Subroutine reference to be invoked when a URL is detected in an HTML tag attribute or a CSS property.

=item B<css_callback>

Subroutine reference to be invoked when CSS data is found either as the contents of a 'style' attribute in an HTML tag, or as the contents of a <style> HTML tag.

=item B<content_callback>

Subroutine reference to be invoked when standard content between HTML tags in found.

=item B<fix_mismatched_tags>

This property, if set, fixes mismatched tags in the HTML input. By default, tags present in the default %mismatched_tags_to_fix hash are fixed. This set of tags can be overridden by passing in an array reference $mismatched_tags_to_fix to the constru...

=item B<mismatched_tags_to_fix>

Array reference of tags for which the code would check for matching opening and closing tags. See the property $fix_mismatched_tags.

=item B<context>

You can pass an arbitrary scalar as a 'context' value that's then passed as the first parameter to all callback functions. Most commonly this is something like '$Self'

lib/HTML/Defang.pm  view on Meta::CPAN


        # Skip attribute parsing if none
        my @Attributes;
        goto NoParseAttributes if $I =~ m{\G>}gcso;

        # Pull off any trailing component after the tag
        # Now match all key=value attributes
        while ($I =~ m{\G(?:($AttrKeyStartLineRE)(\s*))?(?:(=\s*)($AttrValRE)(\s*))?}gcso) {

          last if !defined($1) && !defined($4);
          my ($Attribute, $AttrTrail, $Equals, $AttrVal, $AttrValTrail) = ($1, $2, $3, $4, $5);
          my ($AttrQuote, $AttrValWithoutQuote) = '';
          if (defined($4) && $4 =~ /^([`"']?)(.*)\1$/s) {
            # IE supports `, but nothing else does, turn it into "
            $AttrQuote = $1 eq '`' ? '"' : $1;
            $AttrValWithoutQuote = $2;
          }

          # Turn on utf-8 for things that might be
          Encode::_utf8_on($Attribute) if $UTF8Input;
          Encode::_utf8_on($AttrValWithoutQuote) if $UTF8Input;

          push @Attributes, [ $Attribute, $AttrTrail, $Equals, $AttrQuote, $AttrValWithoutQuote, $AttrQuote, $AttrValTrail ];
          warn "defang AttributeKey=$1 AttrQuote=$AttrQuote AttributeValue=$Attribute" if $Debug;
        }

        # Better be at end of attributes, or attach our own ending tag
        if ($I =~ m{\G(?:(\s*[/\\]*\s*(?:--)?\s*)?>|([\s/-]*))}gcs) {
          $CloseAngle = $1 ? $1 . '>' : ($2 ? $2 . '>' : '>');
        }

        NoParseAttributes:
        my $Defang = DEFANG_ALWAYS;

        my $lcTag = lc $Tag;
        my $TagOps = $Tags{$lcTag};

        # Process this tag
        if (!exists $Self->{tags_to_callback}->{$lcTag} && ref $TagOps eq "CODE") {

          warn "process_tag Found CODE reference" if $Debug;
          $Defang = $Self->${TagOps}($OutR, \$I, $TagOps, \$OpenAngle, $IsEndTag, $lcTag, $TagTrail, \@Attributes, \$CloseAngle);

        } else {

          warn "process_tag Found regular tag" if $Debug;
          $Defang = $Self->defang_attributes($OutR, \$I, $TagOps, \$OpenAngle, $IsEndTag, $lcTag, $TagTrail, \@Attributes, \$CloseAngle);

        }
        die "Callback reset pos on Tag=$Tag IsEndTag=$IsEndTag" if !defined pos($I);
        warn "defang Defang=$Defang" if $Debug;

        # RCDATA/RAWTEXT elements (title, textarea, noembed, noframes): their
        #  content is opaque text to a browser, not HTML. Consume it verbatim
        #  up to the matching end tag so we don't parse comments/tags inside
        #  it (which would let an attacker smuggle a literal end tag + active
        #  markup past the defanger). Skipped if a user tags_callback owns the
        #  tag, since it is then responsible for the content.
        if (!$IsEndTag && $RawTextTags{$lcTag}
            && !exists $Self->{tags_to_callback}->{$lcTag}) {
          $Self->defang_rcdata_content(\$I, $lcTag);
        }

        # Build tag content, because if we defang, we have to remove --'s within it

        # @Attributes can have unicode values, but we're within "use bytes", so it's flattened ok
        my $TagContent = $TagTrail . join("", grep { defined } map { @$_ } @Attributes);

        if ($Self->{fix_mismatched_tags} && ($Defang == DEFANG_NONE)) {
          if (!$IsEndTag) {
            $Defang = $Self->open_tag(0, $OutR, \$I, $lcTag, \$TagContent);
          } else {
            $Defang = $Self->close_tag(0, $OutR, \$I, $lcTag);
            goto SkipOutput if $Defang == DEFANG_ALWAYS;
          }
        }

        # defang unknown tags
        if ($Defang != DEFANG_NONE) {
          warn "defang Defanging $Tag" if $Debug;
          if ($Self->{delete_defang_content}) {
            $OpenAngle = $IsEndTag = $Tag = $TagContent = $CloseAngle = '';
          } else {
            $Tag = $Self->{defang_string} . $Tag
              if $Self->{allow_double_defang} || $Tag !~ $Self->{defang_re};
            $TagContent =~ s/--//g;
            $Tag =~ s/--//g;
            $OpenAngle =~ s/^</<!--/;
            $CloseAngle =~ s/>$/-->/;
          }
        }

        # And put it all back together into the output string
        $$OutR .= $OpenAngle . $IsEndTag . $Tag . $TagContent . $CloseAngle;
        SkipOutput:

      # It's a comment of some sort. We are looking for regular HTML comment, XML CDATA section
      } elsif ($I =~ m{\G(!)((?:\[CDATA\[|--)?)}gcis) {

        my ($Comment, $CommentDelim) = ($1, $2);
        warn "defang Comment=$Comment CommentDelim=$CommentDelim" if $Debug;

        # Find the appropriate closing delimiter
        my $IsCDATA = $CommentDelim eq "[CDATA[";
        my $ClosingCommentDelim = $IsCDATA ? "]]" : $CommentDelim;

        warn "defang ClosingCommentDelim=$ClosingCommentDelim" if $Debug;

        my ($CommentStartText, $CommentEndText) = ("--/*SC*/", "/*EC*/--");

        # Convert to regular HTML comment
        if (!$Self->{delete_defang_content}) {
          $$OutR .= $OpenAngle . $Comment . $CommentStartText;
        }

        # Find closing comment
        if ($I =~ m{\G(.*?)(\Q$ClosingCommentDelim\E!?\s*)(>)}gcis || $I =~ m{\G(.*?)(--)(>)}gcis) {

          my ( $StartTag, $CommentData, $ClosingTag, $CloseAngle ) =
            ( $CommentDelim, $1, $2, $3 );

lib/HTML/Defang.pm  view on Meta::CPAN


=item I<$OpenAngle>

Opening angle(<) sign of the current tag.

=item I<$IsEndTag>

Has the value '/' if the current tag is a closing tag.

=item I<$Tag>

The HTML tag that is currently being parsed.

=item I<$TagTrail>

Any space after the tag, but before attributes.

=item I<$Attributes>

A reference to an array of the attributes and their values, including any surrouding spaces. Each element of the array is added by 'push' calls like below.

  push @$Attributes, [ $AttributeName, $SpaceBeforeEquals, $EqualsAndSubsequentSpace, $QuoteChar, $AttributeValue, $QuoteChar, $SpaceAfterAtributeValue ];

=item I<$CloseAngle>

Anything after the end of last attribute including the closing HTML angle(>)

=back

=back

=cut
sub defang_script_tag {
  my $Self = shift;
  my ($OutR, $HtmlR, $TagOps, $OpenAngle, $IsEndTag, $lcTag, $TagTrail, $Attributes, $CloseAngle) = @_;
  warn "defang_script Processing <script> tag" if $Self->{Debug};

  if (!$IsEndTag) {

    # If we just parsed a starting <script> tag, find up to end tag
    #  There's all sort of possible mess around this:
    #   </script<foo> - not really an end tag
    #   </script foo="bar > yes, still in a attribute"> - a valid end tag
    #  For weird cases, we end script tag early and end up defanging script
    #  content as HTML content, which is still safe
    if ($$HtmlR =~ m{\G(.*?)(?=</script\b)}gcsi) {
      my $ScriptTagContents = $1;
      warn "defang_script ScriptTagContents $ScriptTagContents" if $Self->{Debug};
      if (!$Self->{delete_defang_content}) {
        1 while $ScriptTagContents =~ s/<!--|-->|--//g;
        $ScriptTagContents = "<!-- " . $ScriptTagContents . " -->";
        $Self->add_to_output($ScriptTagContents);
      }
    }
  }

  # Also defang tag
  return DEFANG_ALWAYS;
}

=item I<defang_rcdata_content($HtmlR, $lcTag)>

Consume the RCDATA/RAWTEXT content of a "text" element and pass it straight
through to the output as opaque text.

The elements this applies to are C<< <title> >> and C<< <textarea> >>
(RCDATA) and C<< <noembed> >> and C<< <noframes> >> (RAWTEXT) - see the
listing in C<%RawTextTags>. Per the HTML5 tree construction/tokenization
spec, a start tag for one of these switches the tokenizer into a raw text
mode in which the browser does I<not> parse comments or nested tags - the
only markup it recognises is a matching C<< </tag >> end tag, everything
else is text (RCDATA additionally decodes character references). Comments in
particular are not a thing inside these elements.

If we instead let the normal HTML parser loose on the content it would
happily interpret C<< <!-- ... --> >> comments and nested tags inside. That
diverges from the browser and is exploitable: an attacker can hide a literal
end tag plus active markup inside what we think is an inert comment or
attribute value, e.g.

  <title><!-- </title> <img src=x onerror=alert(1)> --></title>

We would emit the C<< <img> >> unchanged (believing it comment data), but a
browser closes the title at the literal C<< </title> >> and then runs the
C<< <img> >> as live markup.

So, like <script> and <style>, we consume everything up to the matching
C<< </tag >> (whose name is C<$lcTag>) as opaque text. That content contains
no C<< </tag >> (we stop before the first one), so re-parsed as raw text it
stays inert right up to the end tag that follows it in the output.

=cut
sub defang_rcdata_content {
  my ($Self, $HtmlR, $lcTag) = @_;

  warn "defang_rcdata_content Tag=$lcTag" if $Self->{Debug};

  # Do the raw text scan in byte mode (see defang())
  use bytes;

  # Consume raw content verbatim up to the matching end tag (or EOF if
  #  there's no closing tag, just as a browser would leave the element open)
  if ($$HtmlR =~ m{\G(.*?)(?=</\Q$lcTag\E\b)}gcis || $$HtmlR =~ m{\G(.*)$}gcs) {
    my $Content = $1;
    warn "defang_rcdata_content Content=$Content" if $Self->{Debug};
    $Self->add_to_output($Content) if defined($Content) && length($Content);
  }

  return;
}

sub defang_style_tag {
  my ($Self, $OutR, $HtmlR, $TagOps, $OpenAngle, $IsEndTag, $lcTag, $TagTrail, $Attributes, $CloseAngle) = @_;

  warn "defang_style_tag Tag=$lcTag IsEndTag=$IsEndTag" if $Self->{Debug};

  # Defang attributes
  my $Defang = $Self->defang_attributes($OutR, $HtmlR, $TagOps, $OpenAngle, $IsEndTag, $lcTag, $TagTrail, $Attributes, $CloseAngle);

  # Nothing to do if end tag
  return $Defang if $IsEndTag;

  # Do all style work in byte mode
  use bytes;

  my $Content = '';
  my $ClosingStyleTagPresent = 1;

  for ($$HtmlR) {
    if (m{\G(.*?)(?=</style\b)}gcis) {
      $Content = $1;

    # No ending style tag
    } elsif (m{\G([^<]*)}gcis) {
      $Content = $1;
      $ClosingStyleTagPresent = 0;
    }
  }

  # Handle any wrapping HTML comments. If no comments, we add
  my ($OpeningHtmlComment, $ClosingHtmlComment) = ('', '');
  $OpeningHtmlComment = $Content =~ s{^(\s*<!--)}{} ? $1 . " " : "<!-- ";
  $ClosingHtmlComment = $Content =~ s{(-->\s*)$}{} ? " " . $1 : " -->";

  # Check for large bogus style data with mostly HTML tags and blat it
  if (length $Content > 16384) {
    my $TagCount = 0;
    $TagCount++ while $Content =~ m{</?\w+\b[^>]*>}g;
    if ($TagCount > length($Content)/256) {
      $Content = '';
    }
  }

  my $StyleOut = $Self->defang_style_text($Content, $lcTag, 0, undef, $HtmlR, $OutR);

  $Self->add_to_output($OpeningHtmlComment . $StyleOut . $ClosingHtmlComment);
  $Self->add_to_output("</style>") if !$ClosingStyleTagPresent;

  return $Defang;
}

=item I<defang_style_text($Content, $lcTag, $IsAttr, $AttributeHash, $HtmlR, $OutR)>

Defang some raw css data and return the defanged content



( run in 0.503 second using v1.01-cache-2.11-cpan-751830e7986 )