File Coverage

blib/lib/CGI/IDS/Whitelist.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             package CGI::IDS::Whitelist;
2              
3             our $VERSION = '1.0217';
4              
5             #------------------------- Notes -----------------------------------------------
6             # This source code is documented in both POD and ROBODoc format.
7             # Please find additional POD documentation at the end of this file
8             # (search for "__END__").
9             #-------------------------------------------------------------------------------
10              
11             #****c* IDS::Whitelist
12             # NAME
13             # PerlIDS Whitelist (CGI::IDS::Whitelist)
14             # DESCRIPTION
15             # Whitelist Processor for PerlIDS (CGI::IDS)
16             # AUTHOR
17             # Hinnerk Altenburg
18             # CREATION DATE
19             # 2010-03-29
20             # COPYRIGHT
21             # Copyright (C) 2010-2014 Hinnerk Altenburg
22             #
23             # This file is part of PerlIDS.
24             #
25             # PerlIDS is free software: you can redistribute it and/or modify
26             # it under the terms of the GNU Lesser General Public License as published by
27             # the Free Software Foundation, either version 3 of the License, or
28             # (at your option) any later version.
29             #
30             # PerlIDS is distributed in the hope that it will be useful,
31             # but WITHOUT ANY WARRANTY; without even the implied warranty of
32             # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
33             # GNU Lesser General Public License for more details.
34             #
35             # You should have received a copy of the GNU Lesser General Public License
36             # along with PerlIDS. If not, see .
37              
38             #****
39              
40             =head1 NAME
41              
42             CGI::IDS::Whitelist - Whitelist Processor for PerlIDS - Perl Website Intrusion Detection System (XSS, CSRF, SQLI, LFI etc.)
43              
44             =head1 DESCRIPTION
45              
46             Whitelist Processor for PerlIDS (L). Performs a basic string check and the whitelist check.
47             See section L for details on setting up a whitelist file. CGI::IDS::Whitelist may also be
48             used standalone without CGI::IDS to check whether a request has suspicious parameters at all before
49             handing it over to CGI::IDS. This may be the case if you let worker servers do the more expensive
50             CGI::IDS job and only want to send over the requests that have suspicious parameters.
51             See L for an example.
52              
53             =head1 SYNOPSIS
54              
55             use CGI;
56             use CGI::IDS::Whitelist;
57              
58             $query = new CGI;
59              
60             my $whitelist = CGI::IDS::Whitelist->new(
61             whitelist_file => '/home/hinnerk/sandbox/ids/cgi-bin/param_whitelist.xml',
62             );
63              
64             my @request_keys = keys %$query->Vars;
65             foreach my $key (@request_keys) {
66             if ( $whitelist->is_suspicious(key => $key, request => $query->Vars ) {
67             send_to_ids_worker_server( $query->Vars );
68             last;
69             }
70             }
71              
72             =head1 METHODS
73              
74             =cut
75              
76             #------------------------- Pragmas ---------------------------------------------
77 1     1   10900 use strict;
  1         2  
  1         46  
78 1     1   5 use warnings;
  1         2  
  1         106  
79              
80             #------------------------- Libs ------------------------------------------------
81 1     1   775 use XML::Simple qw(:strict);
  0            
  0            
82             use Carp;
83             use JSON::XS;
84             use Encode;
85              
86             #------------------------- Subs ------------------------------------------------
87              
88             #****m* IDS/new
89             # NAME
90             # Constructor
91             # DESCRIPTION
92             # Creates a Whitelist object.
93             # The whitelist will stay loaded during the lifetime of the object.
94             # You may call is_suspicious() multiple times, the collecting debug
95             # arrays suspicious_keys() and non_suspicious_keys() will only be
96             # emptied by an explizit reset() call.
97             # INPUT
98             # HASH
99             # whitelist_file STRING The path to the whitelist XML file
100             # OUTPUT
101             # Whitelist object, dies (croaks) if a whitelist parsing error occurs.
102             # EXAMPLE
103             # # instantiate object
104             # my $whitelist = CGI::IDS::Whitelist->new(
105             # whitelist_file => '/home/hinnerk/sandbox/ids/cgi-bin/param_whitelist.xml',
106             # );
107             # # instantiate object without a whitelist, just performs a basic string check
108             # my $whitelist = CGI::IDS::Whitelist->new();
109              
110             #****
111              
112             =head2 new()
113              
114             Constructor. Can optionally take the path to a whitelist file.
115             If I is not given, just a basic string check will be performed.
116              
117             The whitelist will stay loaded during the lifetime of the object.
118             You may call C multiple times, the collecting debug
119             arrays C and C will only be
120             emptied by an explizit C call.
121              
122             For example, the following are valid constructors:
123              
124             my $whitelist = CGI::IDS::Whitelist->new(
125             whitelist_file => '/home/hinnerk/sandbox/ids/cgi-bin/param_whitelist.xml',
126             );
127              
128             my $whitelist = CGI::IDS::Whitelist->new();
129              
130             The Constructor dies (croaks) if a whitelist parsing error occurs.
131              
132             =cut
133              
134             sub new {
135             my ($package, %args) = @_;
136              
137             # self member variables
138             my $self = {
139             whitelist_file => $args{whitelist_file},
140             suspicious_keys => [],
141             non_suspicious_keys => [],
142             };
143              
144             # create object
145             bless $self, $package;
146              
147             # read & parse XML
148             $self->_load_whitelist_from_xml($self->{whitelist_file});
149              
150             return $self;
151             }
152              
153             #****m* IDS/Whitelist/is_suspicious
154             # NAME
155             # is_suspicious
156             # DESCRIPTION
157             # Performs the whitelist check for a given request parameter.
158             # INPUT
159             # HASHREF
160             # + key The key of the request parameter to be checked
161             # + request HASHREF to the complete request (for whitelist conditions check)
162             # OUTPUT
163             # 1 if you should check it with the complete filter set,
164             # 0 if harmless or sucessfully whitelisted.
165             # SYNOPSIS
166             # $whitelist->is_suspicious( key => 'mykey', request => $request );
167             #****
168              
169             =head2 is_suspicious()
170              
171             DESCRIPTION
172             Performs the whitelist check for a given request parameter.
173             INPUT
174             HASHREF
175             + key The key of the request parameter to be checked
176             + request HASHREF to the complete request (for whitelist conditions check)
177             OUTPUT
178             1 if you should check it with the complete filter set,
179             0 if harmless or sucessfully whitelisted.
180             SYNOPSIS
181             $whitelist->is_suspicious( key => 'mykey', request => $request );
182              
183             =cut
184              
185             sub is_suspicious {
186             my ($self, %args) = @_;
187             my $key = $args{key};
188             my $request = $args{request};
189             my $request_value = $args{request}->{$key};
190             my $contains_encoding = 0;
191              
192             # skip if value is empty or generally whitelisted
193             if ( $request_value ne '' &&
194             !( $self->{whitelist}{$key} &&
195             !defined($self->{whitelist}{$key}->{rule}) &&
196             !defined($self->{whitelist}{$key}->{conditions}) &&
197             !defined($self->{whitelist}{$key}->{encoding})
198             )
199             ) {
200             my $request_value_orig = $request_value;
201             $request_value = $self->convert_if_marked_encoded(key => $key, value => $request_value);
202             if ($request_value ne $request_value_orig) {
203             $contains_encoding = 1;
204             }
205              
206             $request_value = $self->make_utf_8($request_value);
207              
208             # scan only if value is not harmless
209             if ( !$self->is_harmless_string($request_value) ) {
210             my $attacks = {};
211              
212             if (!$self->{whitelist}{$key}) {
213             # apply filters to value, not in whitelist
214             push (@{$self->{suspicious_keys}}, {key => $key, value => $request_value, reason => 'key'}); # key not whitelisted
215             return 1;
216             }
217             else {
218             # check if all conditions match
219             my $condition_mismatch = 0;
220             foreach my $condition (@{$self->{whitelist}{$key}->{conditions}}) {
221             if (! defined($request->{$condition->{key}}) ||
222             ( defined ($condition->{rule}) && $request->{$condition->{key}} !~ $condition->{rule} )
223             ) {
224             $condition_mismatch = 1;
225             }
226             }
227              
228             # Apply filters if key is not in whitelisted environment conditions
229             # or if the value does not match the whitelist rule if one is set.
230             # Filtering is skipped if no rule is set.
231             if ( $condition_mismatch ||
232             (defined($self->{whitelist}{$key}->{rule}) &&
233             $request_value !~ $self->{whitelist}{$key}->{rule}) ||
234             $contains_encoding
235             ) {
236             # apply filters to value, whitelist rules mismatched
237             my $reason = '';
238             if ($condition_mismatch) {
239             $reason = 'cond'; # condition mismatch
240             }
241             elsif (!$contains_encoding) {
242             $reason = 'rule'; # rule mismatch
243             }
244             else {
245             $reason = 'enc'; # contains encoding
246             }
247             push (@{$self->{suspicious_keys}}, {key => $key, value => $request_value, reason => $reason});
248             return 1;
249             }
250             else {
251             # skipped, whitelist rule matched
252             push (@{$self->{non_suspicious_keys}}, {key => $key, value => $request_value, reason => 'r&c'}); # rule & conditions matched
253             }
254             }
255             }
256             else {
257             # skipped, harmless string
258             push (@{$self->{non_suspicious_keys}}, {key => $key, value => $request_value, reason => 'harml'}); # harmless
259             }
260             }
261             else {
262             # skipped, empty value or key generally whitelisted
263             my $reason = $request_value ? 'key' : 'empty';
264             push (@{$self->{non_suspicious_keys}}, {key => $key, value => $request_value, reason => $reason});
265             }
266             return 0;
267             }
268              
269             #****m* IDS/Whitelist/convert_if_marked_encoded
270             # NAME
271             # convert_if_marked_encoded
272             # DESCRIPTION
273             # Tries to JSON-decode and flatten a value to a plain string if the key has been marked as JSON in the whitelist.
274             # Other encodings may follow in future.
275             # INPUT
276             # HASHREF
277             # + key
278             # + value
279             # OUTPUT
280             # The JSON-decoded and flattened 'value' if key is marked JSON. Plain keys and values, newline separated.
281             # Untouched 'value' otherwise.
282             # SYNOPSIS
283             # $whitelist->convert_if_marked_encoded( key => 'data', value = '{"a":"b","c":["123", 111, "456"]}');
284             #****
285              
286             =head2 convert_if_marked_encoded()
287              
288             DESCRIPTION
289             Tries to JSON-decode and flatten a value to a plain string if the key has been marked as JSON in the whitelist.
290             Other encodings may follow in future.
291             INPUT
292             HASHREF
293             + key
294             + value
295             OUTPUT
296             The JSON-decoded and flattened 'value' if key is marked JSON. Plain keys and values, newline separated.
297             Untouched 'value' otherwise.
298             SYNOPSIS
299             $whitelist->convert_if_marked_encoded( key => 'data', value => '{"a":"b","c":["123", 111, "456"]}');
300              
301             =cut
302              
303             sub convert_if_marked_encoded {
304             my ($self, %args) = @_;
305             my $key = $args{key};
306             my $request_value = $args{value};
307              
308             # If marked as JSON, try to convert from JSON to reduce false positives
309             if (defined($self->{whitelist}{$key}) &&
310             defined($self->{whitelist}{$key}->{encoding}) &&
311             $self->{whitelist}{$key}->{encoding} eq 'json') {
312              
313             $request_value = _json_to_string($request_value);
314             }
315             return $request_value;
316             }
317              
318             #****m* IDS/Whitelist/suspicious_keys
319             # NAME
320             # suspicious_keys
321             # DESCRIPTION
322             # Returns the set of filters that are suspicious
323             # Keys are listed from the last reset() or Whitelist->new()
324             # INPUT
325             # none
326             # OUTPUT
327             # [ { 'value' => , 'reason' => , 'key' => }, { ... } ]
328             # SYNOPSIS
329             # $whitelist->suspicious_keys();
330             #****
331              
332             =head2 suspicious_keys()
333              
334             DESCRIPTION
335             Returns the set of filters that are suspicious
336             Keys are listed from the last reset() or Whitelist->new()
337             INPUT
338             none
339             OUTPUT
340             [ { 'value' => , 'reason' => , 'key' => }, { ... } ]
341             SYNOPSIS
342             $whitelist->suspicious_keys();
343              
344             =cut
345              
346             sub suspicious_keys {
347             my ($self) = @_;
348             return $self->{suspicious_keys};
349             }
350              
351             #****m* IDS/Whitelist/non_suspicious_keys
352             # NAME
353             # non_suspicious_keys
354             # DESCRIPTION
355             # Returns the set of filters that have been checked but are not suspicious
356             # Keys are listed from the last reset() or Whitelist->new()
357             # INPUT
358             # none
359             # OUTPUT
360             # [ { 'value' => , 'reason' => , 'key' => }, { ... } ]
361             # SYNOPSIS
362             # $whitelist->non_suspicious_keys();
363             #****
364              
365             =head2 non_suspicious_keys()
366              
367             DESCRIPTION
368             Returns the set of filters that have been checked but are not suspicious
369             Keys are listed from the last reset() or Whitelist->new()
370             INPUT
371             none
372             OUTPUT
373             [ { 'value' => , 'reason' => , 'key' => }, { ... } ]
374             SYNOPSIS
375             $whitelist->non_suspicious_keys();
376              
377             =cut
378              
379             sub non_suspicious_keys {
380             my ($self) = @_;
381             return $self->{non_suspicious_keys};
382             }
383              
384             #****m* IDS/Whitelist/reset
385             # NAME
386             # reset
387             # DESCRIPTION
388             # resets the member variables suspicious_keys and non_suspicious_keys to []
389             # INPUT
390             # none
391             # OUTPUT
392             # none
393             # SYNOPSIS
394             # $whitelist->reset();
395             #****
396              
397             =head2 reset()
398              
399             DESCRIPTION
400             resets the member variables suspicious_keys and non_suspicious_keys to []
401             INPUT
402             none
403             OUTPUT
404             none
405             SYNOPSIS
406             $whitelist->reset();
407              
408             =cut
409              
410             sub reset {
411             my ($self) = @_;
412             $self->{suspicious_keys} = [];
413             $self->{non_suspicious_keys} = [];
414             }
415              
416             #****f* IDS/Whitelist/is_harmless_string
417             # NAME
418             # is_harmless_string
419             # DESCRIPTION
420             # Performs a basic regexp check for harmless characters
421             # INPUT
422             # + string
423             # OUTPUT
424             # BOOLEAN (pattern match return value)
425             # SYNOPSIS
426             # $whitelist->is_harmless_string( $string );
427             #****
428              
429             =head2 is_harmless_string()
430              
431             DESCRIPTION
432             Performs a basic regexp check for harmless characters
433             INPUT
434             + string
435             OUTPUT
436             BOOLEAN (pattern match return value)
437             SYNOPSIS
438             $whitelist->is_harmless_string( $string );
439              
440             =cut
441              
442             sub is_harmless_string {
443             my ($self, $string) = @_;
444              
445             $string = $self->make_utf_8($string);
446              
447             return ( $string !~ m/[^\w\s\/@!?\.]+|(?:\.\/)|(?:@@\w+)/ );
448             }
449              
450             #****f* IDS/Whitelist/make_utf_8
451             # NAME
452             # make_utf_8
453             # DESCRIPTION
454             # Encodes string to UTF-8 and strips malformed UTF-8 characters
455             # INPUT
456             # + string
457             # OUTPUT
458             # UTF-8 string
459             # SYNOPSIS
460             # $whitelist->make_utf_8( $string );
461             #****
462              
463             =head2 make_utf_8()
464              
465             DESCRIPTION
466             Encodes string to UTF-8 and strips malformed UTF-8 characters
467             INPUT
468             + string
469             OUTPUT
470             UTF-8 string
471             SYNOPSIS
472             $whitelist->make_utf_8( $string );
473              
474             =cut
475              
476             sub make_utf_8 {
477             my ($self, $string) = @_;
478              
479             # make string UTF-8
480             my $utf8_encoded = '';
481             eval {
482             $utf8_encoded = Encode::encode('UTF-8', $string, Encode::FB_CROAK);
483             };
484             if ($@) {
485             # sanitize malformed UTF-8
486             $utf8_encoded = '';
487             my @chars = split(//, $string);
488             foreach my $char (@chars) {
489             my $utf_8_char = eval { Encode::encode('UTF-8', $char, Encode::FB_CROAK) }
490             or next;
491             $utf8_encoded .= $utf_8_char;
492             }
493             }
494             return $utf8_encoded;
495             }
496              
497             #****im* IDS/Whitelist/_load_whitelist_from_xml
498             # NAME
499             # _load_whitelist_from_xml
500             # DESCRIPTION
501             # loads the parameter whitelist XML file
502             # croaks if a xml or regexp parsing error occors
503             # INPUT
504             # whitelistfile path + name of the XML whitelist file
505             # OUTPUT
506             # int number of loaded rules
507             # SYNOPSIS
508             # $self->_load_whitelist_from_xml('/home/xyz/param_whitelist.xml');
509             #****
510              
511             sub _load_whitelist_from_xml {
512             my ($self, $whitelistfile) = @_;
513             my $whitelistcnt = 0;
514              
515             if ($whitelistfile) {
516             # read & parse whitelist XML
517             my $whitelistxml;
518             eval {
519             $whitelistxml = XMLin($whitelistfile,
520             forcearray => [ qw(whitelist param conditions condition)],
521             keyattr => [],
522             );
523             };
524             if ($@) {
525             croak "Error in _load_whitelist_from_xml while parsing $whitelistfile: $@";
526             }
527              
528             # convert XML structure into handy data structure
529             foreach my $whitelistobj (@{$whitelistxml->{param}}) {
530             my @conditionslist = ();
531             foreach my $condition (@{$whitelistobj->{conditions}[0]{condition}}) {
532             if (defined($condition->{rule})) {
533             # copy for error message
534             my $rule = $condition->{rule};
535              
536             eval {
537             $condition->{rule} = qr/$condition->{rule}/ms;
538             };
539             if ($@) {
540             croak 'Error in whitelist rule of condition "' . $condition->{key} . '" for param "' . $whitelistobj->{key} . '": ' . $rule . ' Message: ' . $@;
541             }
542             }
543             push(@conditionslist, $condition);
544             }
545             my %whitelisthash = ();
546             if (defined($whitelistobj->{rule})) {
547             eval {
548             $whitelisthash{rule} = qr/$whitelistobj->{rule}/ms;
549             };
550             if ($@) {
551             croak 'Error in whitelist rule for param "' . $whitelistobj->{key} . '": ' . $whitelistobj->{rule} . ' Message: ' . $@;
552             }
553             }
554             if (@conditionslist) {
555             $whitelisthash{conditions} = \@conditionslist;
556             }
557             if ($whitelistobj->{encoding}) {
558             $whitelisthash{encoding} = $whitelistobj->{encoding};
559             }
560             $self->{whitelist}{$whitelistobj->{key}} = \%whitelisthash;
561             $whitelistcnt++;
562             }
563             }
564             return $whitelistcnt;
565             }
566              
567             #****if* IDS/Whitelist/_json_to_string
568             # NAME
569             # _json_to_string
570             # DESCRIPTION
571             # Tries to decode a string from JSON. Uses _datastructure_to_string().
572             # INPUT
573             # value the string to convert
574             # OUTPUT
575             # value converted string if correct JSON, the unchanged input string otherwise
576             # SYNOPSIS
577             # IDS::Whitelist::_json_to_string($value);
578             #****
579              
580             sub _json_to_string {
581             my ($value) = @_;
582             my $json_ds;
583             eval {
584             $json_ds = JSON::XS::decode_json($value);
585             };
586             if (!$@) {
587             $value = _datastructure_to_string($json_ds)."\n";
588             }
589             return $value;
590             }
591              
592             #****if* IDS/Whitelist/_datastructure_to_string
593             # NAME
594             # _datastructure_to_string
595             # DESCRIPTION
596             # Walks recursively through array or hash and concatenates keys and values to one single string (\n separated)
597             # INPUT
598             # ref the array/hash to convert
599             # OUTPUT
600             # string converted string
601             # SYNOPSIS
602             # IDS::Whitelist::_datastructure_to_string($ref);
603             #****
604              
605             sub _datastructure_to_string {
606             my $in = shift;
607             my $out = '';
608             if (ref $in eq 'HASH') {
609             foreach (keys %$in) {
610             $out .= $_."\n";
611             $out .= _datastructure_to_string($in->{$_});
612             }
613             }
614             elsif (ref $in eq 'ARRAY') {
615             foreach (@$in) {
616             $out = _datastructure_to_string($_) . $out;
617             }
618             }
619             else {
620             $out .= $in."\n";
621             }
622             return $out;
623             }
624              
625             1;
626              
627             __END__