]> mj.ucw.cz Git - libucw.git/blob - ucw/perl/UCW/CGI.pm
UCW::CGI: Escaping functions silently convert undef to undef
[libucw.git] / ucw / perl / UCW / CGI.pm
1 #       Poor Man's CGI Module for Perl
2 #
3 #       (c) 2002--2011 Martin Mares <mj@ucw.cz>
4 #       Slightly modified by Tomas Valla <tom@ucw.cz>
5 #
6 #       This software may be freely distributed and used according to the terms
7 #       of the GNU Lesser General Public License.
8
9 package UCW::CGI;
10
11 use strict;
12 use warnings;
13
14 require Exporter;
15 our $VERSION = 1.0;
16 our @ISA = qw(Exporter);
17 our @EXPORT = qw(&html_escape &url_escape &url_deescape &url_param_escape &url_param_deescape &self_ref &self_form &http_get);
18 our @EXPORT_OK = qw();
19
20 # Configuration settings
21 our $debug = 0;
22 our $utf8_mode = 0;
23
24 sub http_error($;@) {
25         my $err = shift @_;
26         print join("\n", "Status: $err", "Content-Type: text/plain", @_, "", $err, "");
27         exit;
28 }
29
30 ### Escaping ###
31
32 sub url_escape($) {
33         my $x = shift @_;
34         defined $x or return;
35         utf8::encode($x) if $utf8_mode;
36         $x =~ s/([^-\$_.!*'(),0-9A-Za-z\x80-\xff])/"%".unpack('H2',$1)/ge;
37         utf8::decode($x) if $utf8_mode;
38         return $x;
39 }
40
41 sub url_deescape($) {
42         my $x = shift @_;
43         defined $x or return;
44         utf8::encode($x) if $utf8_mode;
45         $x =~ s/%(..)/pack("H2",$1)/ge;
46         utf8::decode($x) if $utf8_mode;
47         return $x;
48 }
49
50 sub url_param_escape($) {
51         my $x = shift @_;
52         defined $x or return;
53         $x = url_escape($x);
54         $x =~ s/%20/+/g;
55         return $x;
56 }
57
58 sub url_param_deescape($) {
59         my $x = shift @_;
60         defined $x or return;
61         $x =~ s/\+/ /g;
62         return url_deescape($x);
63 }
64
65 sub html_escape($) {
66         my $x = shift @_;
67         defined $x or return;
68         $x =~ s/&/&amp;/g;
69         $x =~ s/</&lt;/g;
70         $x =~ s/>/&gt;/g;
71         $x =~ s/"/&quot;/g;
72         $x =~ s/'/&#39;/g;
73         return $x;
74 }
75
76 ### Analysing RFC 822 Style Headers ###
77
78 sub rfc822_prepare($) {
79         my $x = shift @_;
80         # Convert all %'s and backslash escapes to %xx escapes
81         $x =~ s/%/%25/g;
82         $x =~ s/\\(.)/"%".unpack("H2",$1)/ge;
83         # Remove all comments, beware, they can be nested (unterminated comments are closed at EOL automatically)
84         while ($x =~ s/^(("[^"]*"|[^"(])*(\([^)]*)*)(\([^()]*(\)|$))/$1 /) { }
85         # Remove quotes and escape dangerous characters inside (again closing at the end automatically)
86         $x =~ s{"([^"]*)("|$)}{my $z=$1; $z =~ s/([^0-9a-zA-Z%_-])/"%".unpack("H2",$1)/ge; $z;}ge;
87         # All control characters are properly escaped, tokens are clearly visible.
88         # Finally remove all unnecessary spaces.
89         $x =~ s/\s+/ /g;
90         $x =~ s/(^ | $)//g;
91         $x =~ s{\s*([()<>@,;:\\"/\[\]?=])\s*}{$1}g;
92         return $x;
93 }
94
95 sub rfc822_deescape($) {
96         my $x = shift @_;
97         return url_deescape($x);
98 }
99
100 ### Reading of HTTP headers ###
101
102 sub http_get($) {
103         my $h = shift @_;
104         $h =~ tr/a-z-/A-Z_/;
105         return $ENV{"HTTP_$h"} // $ENV{"$h"};
106 }
107
108 ### Parsing of Arguments ###
109
110 my $main_arg_table;
111 my %raw_args;
112
113 sub parse_raw_args_ll($$) {
114         my ($arg, $s) = @_;
115         $s =~ s/\r\n/\n/g;
116         $s =~ s/\r/\n/g;
117         utf8::decode($s) if $utf8_mode;
118         push @{$raw_args{$arg}}, $s;
119 }
120
121 sub parse_raw_args($) {
122         my ($s) = @_;
123         $s =~ s/\s+//;
124         for $_ (split /[&:]/, $s) {
125                 (/^([^=]+)=(.*)$/) or next;
126                 my $arg = $1;
127                 $_ = $2;
128                 s/\+/ /g;
129                 s/%(..)/pack("H2",$1)/eg;
130                 parse_raw_args_ll($arg, $_);
131         }
132 }
133
134 sub parse_multipart_form_data();
135
136 sub init_args() {
137         if (!defined $ENV{"GATEWAY_INTERFACE"}) {
138                 print STDERR "Must be called as a CGI script.\n";
139                 $UCW::CGI::ErrorHandler::exit_code = 1;
140                 exit;
141         }
142
143         my $method = $ENV{"REQUEST_METHOD"};
144         if (my $qs = $ENV{"QUERY_STRING"}) {
145                 parse_raw_args($qs);
146         }
147         if ($method eq "GET" || $method eq "HEAD") {
148         } elsif ($method eq "POST") {
149                 my $content_type = $ENV{"CONTENT_TYPE"} // "";
150                 if ($content_type =~ /^application\/x-www-form-urlencoded\b/i) {
151                         while (<STDIN>) {
152                                 chomp;
153                                 parse_raw_args($_);
154                         }
155                 } elsif ($content_type =~ /^multipart\/form-data\b/i) {
156                         parse_multipart_form_data();
157                 } else {
158                         http_error "415 Unsupported Media Type";
159                         exit;
160                 }
161         } else {
162                 http_error "405 Method Not Allowed", "Allow: GET, HEAD, POST";
163         }
164 }
165
166 sub parse_args($) {                     # CAVEAT: attached files must be defined in the main arg table
167         my $args = shift @_;
168         if (!$main_arg_table) {
169                 $main_arg_table = $args;
170                 init_args();
171         }
172
173         for my $a (values %$args) {
174                 my $r = ref($a->{'var'});
175                 $a->{'default'} //= '';
176                 if ($r eq 'SCALAR') {
177                         ${$a->{'var'}} = $a->{'default'};
178                 } elsif ($r eq 'ARRAY') {
179                         @{$a->{'var'}} = ();
180                 }
181         }
182
183         for my $arg (keys %$args) {
184                 my $a = $args->{$arg};
185                 defined($raw_args{$arg}) or next;
186                 for (@{$raw_args{$arg}}) {
187                         $a->{'multiline'} or s/(\n|\t)/ /g;
188                         s/^\s+//;
189                         s/\s+$//;
190                         if (my $rx = $a->{'check'}) {
191                                 if (!/^$rx$/) { $_ = $a->{'default'}; }
192                         }
193
194                         my $v = $a->{'var'};
195                         my $r = ref($v);
196                         if ($r eq 'SCALAR') {
197                                 $$v = $_;
198                         } elsif ($r eq 'ARRAY') {
199                                 push @$v, $_;
200                         }
201                 }
202         }
203 }
204
205 ### Parsing Multipart Form Data ###
206
207 my $boundary;
208 my $boundary_len;
209 my $mp_buffer;
210 my $mp_buffer_i;
211 my $mp_buffer_boundary;
212 my $mp_eof;
213
214 sub refill_mp_data($) {
215         my ($more) = @_;
216         if ($mp_buffer_boundary >= $mp_buffer_i) {
217                 return $mp_buffer_boundary - $mp_buffer_i;
218         } elsif ($mp_buffer_i + $more <= length($mp_buffer) - $boundary_len) {
219                 return $more;
220         } else {
221                 if ($mp_buffer_i) {
222                         $mp_buffer = substr($mp_buffer, $mp_buffer_i);
223                         $mp_buffer_i = 0;
224                 }
225                 while ($mp_buffer_i + $more > length($mp_buffer) - $boundary_len) {
226                         last if $mp_eof;
227                         my $data;
228                         my $n = read(STDIN, $data, 2048);
229                         if ($n > 0) {
230                                 $mp_buffer .= $data;
231                         } else {
232                                 $mp_eof = 1;
233                         }
234                 }
235                 $mp_buffer_boundary = index($mp_buffer, $boundary, $mp_buffer_i);
236                 if ($mp_buffer_boundary >= 0) {
237                         return $mp_buffer_boundary;
238                 } elsif ($mp_eof) {
239                         return length($mp_buffer);
240                 } else {
241                         return length($mp_buffer) - $boundary_len;
242                 }
243         }
244 }
245
246 sub get_mp_line($) {
247         my ($allow_empty) = @_;
248         my $n = refill_mp_data(1024);
249         my $i = index($mp_buffer, "\r\n", $mp_buffer_i);
250         if ($i >= $mp_buffer_i && $i < $mp_buffer_i + $n - 1) {
251                 my $s = substr($mp_buffer, $mp_buffer_i, $i - $mp_buffer_i);
252                 $mp_buffer_i = $i + 2;
253                 return $s;
254         } elsif ($allow_empty) {
255                 if ($n) {                                                       # An incomplete line
256                         my $s = substr($mp_buffer, $mp_buffer_i, $n);
257                         $mp_buffer_i += $n;
258                         return $s;
259                 } else {                                                        # No more lines
260                         return undef;
261                 }
262         } else {
263                 http_error "400 Bad Request: Premature end of multipart POST data";
264         }
265 }
266
267 sub skip_mp_boundary() {
268         if ($mp_buffer_boundary != $mp_buffer_i) {
269                 http_error "400 Bad Request: Premature end of multipart POST data";
270         }
271         $mp_buffer_boundary = -1;
272         $mp_buffer_i += 2;
273         my $b = get_mp_line(0);
274         print STDERR "SEP $b\n" if $debug;
275         $mp_buffer_boundary = index($mp_buffer, $boundary, $mp_buffer_i);
276         if (substr("\r\n$b", 0, $boundary_len) eq "$boundary--") {
277                 return 0;
278         } else {
279                 return 1;
280         }
281 }
282
283 sub parse_mp_header() {
284         my $h = {};
285         my $last;
286         while ((my $l = get_mp_line(0)) ne "") {
287                 print STDERR "HH $l\n" if $debug;
288                 if (my ($name, $value) = ($l =~ /([A-Za-z0-9-]+)\s*:\s*(.*)/)) {
289                         $name =~ tr/A-Z/a-z/;
290                         $h->{$name} = $value;
291                         $last = $name;
292                 } elsif ($l =~ /^\s+/ && $last) {
293                         $h->{$last} .= $l;
294                 } else {
295                         $last = undef;
296                 }
297         }
298         foreach my $n (keys %$h) {
299                 $h->{$n} = rfc822_prepare($h->{$n});
300                 print STDERR "H $n: $h->{$n}\n" if $debug;
301         }
302         return (keys %$h) ? $h : undef;
303 }
304
305 sub parse_multipart_form_data() {
306         # First of all, find the boundary string
307         my $ct = rfc822_prepare($ENV{"CONTENT_TYPE"});
308         if (!(($boundary) = ($ct =~ /^.*;\s*boundary=([^; ]+)/))) {
309                 http_error "400 Bad Request: Multipart content with no boundary string received";
310         }
311         $boundary = rfc822_deescape($boundary);
312         print STDERR "BOUNDARY IS $boundary\n" if $debug;
313
314         # BUG: IE 3.01 on Macintosh forgets to add the "--" at the start of the boundary string
315         # as the MIME specs preach. Workaround borrowed from CGI.pm in Perl distribution.
316         my $agent = http_get("User-Agent") // "";
317         $boundary = "--$boundary" unless $agent =~ /MSIE\s+3\.0[12];\s*Mac/;
318         $boundary = "\r\n$boundary";
319         $boundary_len = length($boundary) + 2;
320
321         # Check upload size in advance
322         if (my $size = http_get("Content-Length")) {
323                 my $max_allowed = 0;
324                 foreach my $a (values %$main_arg_table) {
325                         $max_allowed += $a->{"maxsize"} || 65536;
326                 }
327                 if ($size > $max_allowed) {
328                         http_error "413 Request Entity Too Large";
329                 }
330         }
331
332         # Initialize our buffering mechanism and part splitter
333         $mp_buffer = "\r\n";
334         $mp_buffer_i = 0;
335         $mp_buffer_boundary = -1;
336         $mp_eof = 0;
337
338         # Skip garbage before the 1st part
339         while (my $i = refill_mp_data(256)) { $mp_buffer_i += $i; }
340         skip_mp_boundary() || return;
341
342         # Process individual parts
343         do { PART: {
344                 print STDERR "NEXT PART\n" if $debug;
345                 my $h = parse_mp_header();
346                 my ($field, $cdisp, $a);
347                 if ($h &&
348                     ($cdisp = $h->{"content-disposition"}) &&
349                     $cdisp =~ /^form-data/ &&
350                     (($field) = ($cdisp =~ /;name=([^;]+)/)) &&
351                     ($a = $main_arg_table->{"$field"})) {
352                         print STDERR "FIELD $field\n" if $debug;
353                         if (defined $h->{"content-transfer-encoding"}) {
354                                 http_error "400 Bad Request: Unexpected Content-Transfer-Encoding";
355                         }
356                         if (defined $a->{"var"}) {
357                                 while (defined (my $l = get_mp_line(1))) {
358                                         print STDERR "VALUE $l\n" if $debug;
359                                         parse_raw_args_ll($field, $l);
360                                 }
361                                 next PART;
362                         } elsif (defined $a->{"file"}) {
363                                 require File::Temp;
364                                 require IO::Handle;
365                                 my $max_size = $a->{"maxsize"} || 1048576;
366                                 my @tmpargs = (undef, UNLINK => 1);
367                                 push @tmpargs, DIR => $a->{"tmpdir"} if defined $a->{"tmpdir"};
368                                 my ($fh, $fn) = File::Temp::tempfile(@tmpargs);
369                                 print STDERR "FILE UPLOAD to $fn\n" if $debug;
370                                 ${$a->{"file"}} = $fn;
371                                 ${$a->{"fh"}} = $fh if defined $a->{"fh"};
372                                 my $total_size = 0;
373                                 while (my $i = refill_mp_data(4096)) {
374                                         print $fh substr($mp_buffer, $mp_buffer_i, $i);
375                                         $mp_buffer_i += $i;
376                                         $total_size += $i;
377                                         if ($total_size > $max_size) { http_error "413 Request Entity Too Large"; }
378                                 }
379                                 $fh->flush();   # Don't close the handle, the file would disappear otherwise
380                                 next PART;
381                         }
382                 }
383                 print STDERR "SKIPPING\n" if $debug;
384                 while (my $i = refill_mp_data(256)) { $mp_buffer_i += $i; }
385         } } while (skip_mp_boundary());
386 }
387
388 ### Generating Self-ref URL's ###
389
390 sub make_out_args(@) {          # Usage: make_out_args([arg_table, ...] name => value, ...)
391         my @arg_tables = ( $main_arg_table );
392         while (@_ && ref($_[0]) eq 'HASH') {
393                 push @arg_tables, shift @_;
394         }
395         my %overrides = @_;
396         my $out = {};
397         for my $table (@arg_tables) {
398                 for my $name (keys %$table) {
399                         my $arg = $table->{$name};
400                         defined($arg->{'var'}) || next;
401                         defined($arg->{'pass'}) && !$arg->{'pass'} && !exists $overrides{$name} && next;
402                         defined $arg->{'default'} or $arg->{'default'} = "";
403                         my $value;
404                         if (!defined($value = $overrides{$name})) {
405                                 if (exists $overrides{$name}) {
406                                         $value = $arg->{'default'};
407                                 } else {
408                                         $value = ${$arg->{'var'}};
409                                         defined $value or $value = $arg->{'default'};
410                                 }
411                         }
412                         if ($value ne $arg->{'default'}) {
413                                 $out->{$name} = $value;
414                         }
415                 }
416         }
417         return $out;
418 }
419
420 sub self_ref(@) {
421         my $out = make_out_args(@_);
422         return "?" . join(':', map { "$_=" . url_param_escape($out->{$_}) } sort keys %$out);
423 }
424
425 sub self_form(@) {
426         my $out = make_out_args(@_);
427         return join('', map { "<input type=hidden name=$_ value='" . html_escape($out->{$_}) . "'>\n" } sort keys %$out);
428 }
429
430 ### Cookies
431
432 sub set_cookie($$@) {
433         #
434         # Unfortunately, the support for the new cookie standard (RFC 2965) among
435         # web browsers is still very scarce, so we are still using the old Netscape
436         # specification.
437         #
438         # Usage: set_cookie(name, value, option => value...), where options are:
439         #
440         #       max-age         maximal age in seconds
441         #       domain          domain name scope
442         #       path            path name scope
443         #       secure          if present, cookie applies only to SSL connections
444         #                       (in this case, the value should be undefined)
445         #       discard         if present with any value, the cookie is discarded
446         #
447
448         my $key = shift @_;
449         my $value = shift @_;
450         my %other = @_;
451         if (exists $other{'discard'}) {
452                 delete $other{'discard'};
453                 $other{'max-age'} = 0;
454         }
455         if (defined(my $age = $other{'max-age'})) {
456                 delete $other{'max-age'};
457                 my $exp = ($age ? (time + $age) : 0);
458                 # Avoid problems with locales
459                 my ($S,$M,$H,$d,$m,$y,$wd) = gmtime $exp;
460                 my @wdays = ( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' );
461                 my @mons = ( 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' );
462                 $other{'expires'} = sprintf("%s, %02d-%s-%d %02d:%02d:%02d GMT",
463                         $wdays[$wd], $d, $mons[$m], $y+1900, $H, $M, $S);
464         }
465
466         print "Set-Cookie: $key=", url_escape($value);
467         foreach my $k (keys %other) {
468                 print "; $k";
469                 print "=", $other{$k} if defined $other{$k};
470         }
471         print "\n";
472 }
473
474 sub parse_cookies() {
475         my $h = http_get("Cookie") or return ();
476         my @cook = ();
477         foreach my $x (split /;\s*/, $h) {
478                 my ($k,$v) = split /=/, $x;
479                 $v = url_deescape($v) if defined $v;
480                 push @cook, $k => $v;
481         }
482         return @cook;
483 }
484
485 1;  # OK