File Coverage

blib/lib/HTML/Restrict.pm
Criterion Covered Total %
statement 163 164 99.3
branch 69 80 86.2
condition 19 24 79.1
subroutine 23 23 100.0
pod 1 1 100.0
total 275 292 94.1


line stmt bran cond sub pod time code
1 15     15   629997 use strict;
  15         150  
  15         389  
2 15     15   290 use 5.006;
  15         48  
3              
4             package HTML::Restrict;
5              
6 15     15   5837 use version;
  15         26262  
  15         76  
7             our $VERSION = 'v3.0.0';
8              
9 15     15   1352 use Carp qw( croak );
  15         30  
  15         841  
10 15     15   6318 use Data::Dump qw( dump );
  15         94018  
  15         819  
11 15     15   7768 use HTML::Parser ();
  15         85844  
  15         443  
12 15     15   97 use HTML::Entities qw( encode_entities );
  15         38  
  15         937  
13 15     15   7599 use Types::Standard 1.000001 qw[ Bool HashRef ArrayRef CodeRef ];
  15         1020212  
  15         166  
14 15     15   16996 use List::Util 1.33 qw( any none );
  15         271  
  15         1553  
15 15     15   112 use Scalar::Util qw( reftype weaken );
  15         41  
  15         678  
16 15     15   6642 use Sub::Quote 'quote_sub';
  15         66384  
  15         760  
17 15     15   7549 use URI ();
  15         63117  
  15         417  
18              
19 15     15   7320 use Moo 1.002000;
  15         149563  
  15         90  
20 15     15   26787 use namespace::clean;
  15         150799  
  15         93  
21              
22             has allow_comments => (
23             is => 'rw',
24             isa => Bool,
25             default => 0,
26             );
27              
28             has allow_declaration => (
29             is => 'rw',
30             isa => Bool,
31             default => 0,
32             );
33              
34             has debug => (
35             is => 'rw',
36             isa => Bool,
37             default => 0,
38             );
39              
40             has parser => (
41             is => 'ro',
42             lazy => 1,
43             builder => '_build_parser',
44             );
45              
46             has rules => (
47             is => 'rw',
48             isa => HashRef,
49             required => 0,
50             default => quote_sub(q{ {} }),
51             trigger => \&_build_parser,
52             reader => 'get_rules',
53             writer => 'set_rules',
54             );
55              
56             has strip_enclosed_content => (
57             is => 'rw',
58             isa => ArrayRef,
59             default => sub { [ 'script', 'style' ] },
60             );
61              
62             has replace_img => (
63             is => 'rw',
64             isa => Bool | CodeRef,
65             default => 0,
66             );
67              
68             has trim => (
69             is => 'rw',
70             isa => Bool,
71             default => 1,
72             );
73              
74             has uri_schemes => (
75             is => 'rw',
76             isa => ArrayRef,
77             required => 0,
78             default => sub { [ undef, 'http', 'https' ] },
79             reader => 'get_uri_schemes',
80             writer => 'set_uri_schemes',
81             );
82              
83             has _processed => (
84             is => 'rw',
85             isa => quote_sub(
86             q{
87             die "$_[0] is not false or a string!"
88             unless !defined($_[0]) || $_[0] eq "" || "$_[0]" eq '0' || ref(\$_[0]) eq 'SCALAR'
89             }
90             ),
91             clearer => '_clear_processed',
92             );
93              
94             has _stripper_stack => (
95             is => 'rw',
96             isa => ArrayRef,
97             default => sub { [] },
98             );
99              
100             sub _build_parser {
101 38     38   4780 my $self = shift;
102 38         68 my $rules = shift;
103              
104             # don't allow any upper case tag or attribute names
105             # these rules would otherwise silently be ignored
106 38 100       100 if ($rules) {
107 16         25 foreach my $tag_name ( keys %{$rules} ) {
  16         61  
108 17 100       59 if ( lc $tag_name ne $tag_name ) {
109 1         259 croak "All tag names must be lower cased";
110             }
111 16 50       73 if ( reftype $rules->{$tag_name} eq 'ARRAY' ) {
112 16         29 my @attr_names;
113 16         25 foreach my $attr_item ( @{ $rules->{$tag_name} } ) {
  16         41  
114 26 100       79 ref $attr_item eq 'HASH'
115             ? push( @attr_names, keys(%$attr_item) )
116             : push( @attr_names, $attr_item );
117             }
118 16         40 for (@attr_names) {
119 27 100       184 croak "All attribute names must be lower cased"
120             if lc $_ ne $_;
121             }
122             }
123             }
124             }
125              
126 36         133 weaken($self);
127             return HTML::Parser->new(
128             empty_element_tags => 1,
129              
130             start_h => [
131             sub {
132 182     182   783 my ( $p, $tagname, $attr, $text ) = @_;
133 182 50       2945 print "starting tag: $tagname", "\n" if $self->debug;
134 182         1139 my $more = q{};
135              
136 182 100 100     689 if ( any { $_ eq $tagname } keys %{ $self->get_rules } ) {
  175 100       452  
  182 100       1057  
137 160 50       2563 print dump $attr if $self->debug;
138              
139 160         974 foreach my $source_type ( 'href', 'src', 'cite' ) {
140              
141 480         1197 my $link = $attr->{$source_type};
142              
143             # Remove unprintable ASCII control characters, which
144             # are 0..31. These characters are not valid in URLs,
145             # but they can prevent the URI parser from recognizing
146             # the scheme when they are used as leading characters.
147             # Browsers will helpfully ignore some of them, meaning
148             # that some of these characters (particularly 1..8 and
149             # 14..31) can be used to defeat HTML::Restrict when
150             # used as leading characters in a link. In our case we
151             # will strip them all regardless of where they are in
152             # the URL. See
153             # https://github.com/oalders/html-restrict/issues/30
154             # https://url.spec.whatwg.org/
155             # https://infra.spec.whatwg.org/#c0-control
156              
157 480 100       935 if ($link) {
158              
159             # C0 control chars (decimal 0..31)
160             # sort of like $link =~ s/[[:^print:]]//g
161 152         933 $link =~ s/[\00-\037]|&#x?0+;/ /g;
162              
163 152         554 my $url = URI->new($link);
164 152 100       76167 if ( defined $url->scheme ) {
165             delete $attr->{$source_type}
166 30         232 if none { $_ eq $url->scheme }
167 147 100       3433 grep { defined } @{ $self->get_uri_schemes };
  187         1260  
  147         492  
168             }
169             else { # relative URL
170             delete $attr->{$source_type}
171 16         54 unless grep { !defined }
172 5 100       157 @{ $self->get_uri_schemes };
  5         17  
173             }
174             }
175             }
176              
177 160         240 foreach
178 160         474 my $attr_item ( @{ $self->get_rules->{$tagname} } ) {
179 181 100       474 if ( ref $attr_item eq 'HASH' ) {
180              
181             # validate or munge with regex or coderef contraints
182             #
183 13         41 for my $attr_name (
184             sort grep exists $attr->{$_},
185             keys %$attr_item
186             ) {
187 13         36 my $rule = $attr_item->{$attr_name};
188 13         18 my $value = $attr->{$attr_name};
189 13 100       55 if ( ref $rule eq 'CODE' ) {
    100          
190 2         5 $value = $rule->($value);
191             next
192 2 100       43 if !defined $value;
193             }
194             elsif ( $value =~ $rule ) {
195              
196             # ok
197             }
198             else {
199 3         6 next;
200             }
201 9         24 $more .= qq[ $attr_name="]
202             . encode_entities($value) . q["];
203             }
204             }
205             else {
206 168         235 my $attr_name = $attr_item;
207 168 100       453 if ( exists $attr->{$attr_name} ) {
208             my $value
209 22         62 = encode_entities( $attr->{$attr_name} );
210 22 50       378 $more .= qq[ $attr_name="$value" ]
211             unless $attr_name eq q{/};
212             }
213             }
214             }
215              
216             # closing slash should (naturally) close the tag
217 160 50 33     428 if ( exists $attr->{q{/}} && $attr->{q{/}} eq q{/} ) {
218 0         0 $more .= ' /';
219             }
220              
221 160         378 my $elem = "<$tagname $more>";
222 160         739 $elem =~ s{\s*>}{>}gxms;
223 160         372 $elem =~ s{\s+}{ }gxms;
224              
225 160   100     3354 $self->_processed( ( $self->_processed || q{} ) . $elem );
226             }
227             elsif ( $tagname eq 'img' && $self->replace_img ) {
228 4         28 my $alt;
229 4 100       59 if ( ref $self->replace_img ) {
230 2         39 $alt = $self->replace_img->( $tagname, $attr, $text );
231             }
232             else {
233             $alt
234 2 50       16 = defined( $attr->{alt} ) ? ": $attr->{alt}" : "";
235 2         5 $alt = "[IMAGE$alt]";
236             }
237 4   50     79 $self->_processed( ( $self->_processed || q{} ) . $alt );
238             }
239             elsif (
240 33         241 any { $_ eq $tagname }
241 18         316 @{ $self->strip_enclosed_content }
242             ) {
243 4 50       59 print "adding $tagname to strippers" if $self->debug;
244 4         25 push @{ $self->_stripper_stack }, $tagname;
  4         59  
245             }
246              
247             },
248             "self,tagname,attr,text"
249             ],
250              
251             end_h => [
252             sub {
253 177     177   7882 my ( $p, $tagname, $attr, $text ) = @_;
254 177 50       2836 print "end: $text\n" if $self->debug;
255 177 100       1332 if ( any { $_ eq $tagname } keys %{ $self->get_rules } ) {
  174 100       443  
  177         725  
256 159   50     2459 $self->_processed( ( $self->_processed || q{} ) . $text );
257             }
258 3         24 elsif ( any { $_ eq $tagname } @{ $self->_stripper_stack } ) {
  18         276  
259 3         11 $self->_delete_tag_from_stack($tagname);
260             }
261              
262             },
263             "self,tagname,attr,text"
264             ],
265              
266             text_h => [
267             sub {
268 180     180   8214 my ( $p, $text ) = @_;
269 180 100       3031 print "text: $text\n" if $self->debug;
270 180         1205 $text = _fix_text_encoding($text);
271 180 100       8162 if ( !@{ $self->_stripper_stack } ) {
  180         2856  
272 178   100     3467 $self->_processed( ( $self->_processed || q{} ) . $text );
273             }
274             },
275             "self,text"
276             ],
277              
278             comment_h => [
279             sub {
280 6     6   82 my ( $p, $text ) = @_;
281 6 50       80 print "comment: $text\n" if $self->debug;
282 6 100       100 if ( $self->allow_comments ) {
283 2   100     32 $self->_processed( ( $self->_processed || q{} ) . $text );
284             }
285             },
286             "self,text"
287             ],
288              
289             declaration_h => [
290             sub {
291 3     3   7 my ( $p, $text ) = @_;
292 3 50       41 print "declaration: $text\n" if $self->debug;
293 3 100       146 if ( $self->allow_declaration ) {
294 2   50     34 $self->_processed( ( $self->_processed || q{} ) . $text );
295             }
296             },
297 36         668 "self,text"
298             ],
299              
300             );
301             }
302              
303             sub process {
304 192     192 1 165717 my $self = shift;
305              
306             # returns undef if no value was passed
307 192 100       535 return if !@_;
308 191 100       382 return $_[0] if !$_[0];
309              
310 189         355 my ($content) = @_;
311 189 50       531 die 'content must be a string!'
312             unless ref( \$content ) eq 'SCALAR';
313 189         4369 $self->_clear_processed;
314              
315 189         3593 my $parser = $self->parser;
316 189         4309 $parser->parse($content);
317 189         7368 $parser->eof;
318              
319 189         3561 my $text = $self->_processed;
320              
321 189 100 100     3527 if ( $self->trim && $text ) {
322 179         1777 $text =~ s{\A\s*}{}gxms;
323 179         1100 $text =~ s{\s*\z}{}gxms;
324             }
325 189         2966 $self->_processed($text);
326              
327             # ensure stripper stack is reset in case of broken html
328 189         6642 $self->_stripper_stack( [] );
329              
330 189         6637 return $self->_processed;
331             }
332              
333             # strip_enclosed_content tags could be nested in the source HTML, so we
334             # maintain a stack of these tags.
335              
336             sub _delete_tag_from_stack {
337 4     4   582 my $self = shift;
338 4         10 my $closing_tag = shift;
339              
340 4         8 my $found = 0;
341 4         11 my @tag_list = ();
342              
343 4         7 foreach my $tag ( reverse @{ $self->_stripper_stack } ) {
  4         63  
344 7 100 100     58 if ( $tag eq $closing_tag && $found == 0 ) {
345 4         8 $found = 1;
346 4         12 next;
347             }
348 3         5 push @tag_list, $tag;
349             }
350              
351 4         73 $self->_stripper_stack( [ reverse @tag_list ] );
352              
353 4         121 return;
354             }
355              
356             # regex for entities that don't require a terminating semicolon
357             my ($short_entity_re)
358             = map qr/$_/i,
359             join '|',
360             '#x[0-9a-f]+',
361             '#[0-9]+',
362             grep !/;\z/,
363             sort keys %HTML::Entities::entity2char;
364              
365             # semicolon required
366             my ($complete_entity_re)
367             = map qr/$_/i,
368             join '|',
369             grep /;\z/,
370             sort keys %HTML::Entities::entity2char;
371              
372             sub _fix_text_encoding {
373 180     180   299 my $text = shift;
374 180         10101 $text =~ s{
375             &
376             (?:
377             ($short_entity_re);?
378             |
379             ($complete_entity_re)
380             )?
381             }{
382 9 100       52 defined $1 ? "&$1;"
    100          
383             : defined $2 ? "&$2"
384             : "&"
385             }xgie;
386 180         604 return encode_entities( $text, '<>' );
387             }
388              
389             1; # End of HTML::Restrict
390              
391             # ABSTRACT: Strip unwanted HTML tags and attributes
392              
393             __END__