#!/usr/bin/perl -wT
#This file is Copyright (C) 2000-2003 Peter Behroozi and is
#licensed for use under the same terms as Perl itself.
package CGI::SecureState;
use strict;
use CGI;
use Crypt::Blowfish;
use Digest::SHA1 qw(sha1 sha1_hex sha1_base64);
use File::Spec;
use Fcntl qw(:flock :DEFAULT);
use vars qw(@ISA $VERSION $Counter $NASTY_WARNINGS $AVOID_SYMLINKS
$SEEK_SET $USE_FLOCK);
BEGIN {
@ISA=qw(CGI);
$VERSION = '0.36';
#Set this to 0 if you want warnings about deprecated behavior to be suppressed,
#especially if you are upgrading from CGI::SecureState 0.2x. However, heed the
#warnings issued when this is set to 1 because they will better your coding style
#and likely increase program security.
$NASTY_WARNINGS = 1;
#Set this to 0 if you don't want CGI::SecureState to test for a symlink attack
#before writing to a state file. If this is set to 1 and CGI::SecureState sees a
#symlink in place of a real file, it will spit out a fatal error.
$AVOID_SYMLINKS = 1;
#Set this to 0 if you do not want CGI::SecureState to use flock() to assure that
#only one instance of CGI::SecureState is accessing the state file at a time.
#Leave this at 1 unless you really have a good reason not to.
$USE_FLOCK = 1;
#The operating systems below do not support flock, except for Windows NT systems,
#but it is impossible to distinguish WinNT systems from Win9x systems only based
#on $^O
local $_=$^O;
$USE_FLOCK = 0 if (/MacOS/i || /V[MO]S/i || /MSWin32/i);
#Workaround for Perl v5.005_03
$SEEK_SET = ($]<5.006) ? 0 : &Fcntl::SEEK_SET;
}
sub import {
foreach (@_) {
$NASTY_WARNINGS=0, next if (/[:-]?no_nasty_warnings/);
$AVOID_SYMLINKS=0, next if (/[:-]?dont_avoid_symlinks/);
$USE_FLOCK=0, next if (/[:-]?no_flock/);
$USE_FLOCK=1, next if (/[:-]?use_flock/);
if (/[:-]?(extra|paranoid|no)_secure/) {
$CGI::PRIVATE_TEMPFILES = ! /no_/;
$CGI::POST_MAX = /no_/ ? -1 : 10240;
$CGI::DISABLE_UPLOADS = /paranoid_/;
}
}
}
sub new
{
#Obtain the class (should be CGI::SecureState in most cases)
my $class = shift;
#populate the argument array
my %args = args_to_hash([qw(-stateDir -mindSet -memory -temp -key)], @_);
#Set up the CGI object to our liking
my $cgi=new CGI;
#We don't want any nassssty tricksssy people playing with things that we
#should be setting ourselves
$cgi->delete($_) foreach (qw(.statefile .cipher .isforgetful .memory
.recent_memory .age .errormsg));
#if the user has an error message subroutine, we should use it:
$cgi->{'.errormsg'} = $args{'-errorSub'} || $args{'-errorsub'} || undef;
#set the forgetfulness; By default, this is "forgetful" because it encourages
#cleaner programming, but if the user is upgrading from 0.2x series, this will be
#undef; if so, be backwards-compatible but give them a few nasty warning messages.
$args{'-mindSet'} = $args{'-mindset'} unless (defined $args{'-mindSet'});
$cgi->{'.isforgetful'} = $args{'-mindSet'};
if (defined $args{'-mindSet'}) {
$cgi->{'.isforgetful'} = 0 if ($args{'-mindSet'} =~ /unforgetful/i);
} elsif ($NASTY_WARNINGS) {
warn "Programmer did not set mindset when declaring new CGI::SecureState object at ",
(caller)[1], " line ", (caller)[2], ". Please tell him/her to read the new CGI::SecureState ",
"documentation.\n";
}
#Set up long-term memory
$args{'-memory'} ||= $args{'-longTerm'} || $args{'-longterm'} || [];
$cgi->{'.memory'} = {map {$_ => 1} @{$args{'-memory'}}};
#Set up short-term memory
$args{'-temp'} ||= $args{'-shortTerm'} || $args{'-shortterm'} || [];
$cgi->{'.recent_memory'} = {map {$_ => undef} @{$args{'-temp'}}};
#Check for ID tag in url if it is not in the normal parameters list
if (!defined($cgi->param('.id')) && $cgi->request_method() eq 'POST') {
$cgi->param('.id', $cgi->url_param('.id'));
}
#Set up the encryption part
my $id = $cgi->param('.id') || sha1_hex($args{'-key'} or generate_id());
my $remote_addr = $cgi->remote_addr();
my $remoteip = pack("CCCC", split (/\./, $remote_addr));
my $key = pack("H*",$id) . $remoteip;
$cgi->{'.cipher'} = new Crypt::Blowfish($key) || errormsg($cgi, 'invalid state file');
#set the directory where we will store saved information
my $statedir = $args{'-stateDir'} || $args{'-statedir'} || ".";
#Set up (and untaint) the name of the location to store data
my $statefile = sha1_base64($id.$remote_addr);
$statefile =~ tr|+/|_-|;
$statefile =~ /([\w-]{27})/;
$cgi->{'.statefile'} = File::Spec->catfile($statedir,$1);
#convert $cgi into a CGI::SecureState object
bless $cgi, $class;
#if this is not a new session, attempt to read from the state file
$cgi->param('.id') ? $cgi->recover_memory : $cgi->param('.id' => $id);
#save any changes to the state file; if there are none, then update only the timestamp
my $newmemory = (@{$args{'-memory'}}) ? 1 : 0;
($newmemory || !$cgi->{'.isforgetful'}) ? $cgi->save_memory : $cgi->encipher;
#finish
return $cgi;
}
sub add {
my $self = shift;
my %params = (ref($_[1]) eq 'ARRAY') ? @_ : (shift, \@_);
$self->param($_, @{$params{$_}}) foreach (keys %params);
$self->remember(keys %params);
}
sub remember {
my $self = shift;
my ($isforgetful,$memory) = @$self{'.isforgetful','.memory'};
$isforgetful ? $memory->{$_}=1 : delete($memory->{$_}) foreach (@_);
$self->save_memory;
}
sub delete {
my $self = shift;
my ($isforgetful,$memory) = @$self{'.isforgetful','.memory'};
foreach (@_) {
delete $memory->{$_} if ($isforgetful);
$self->SUPER::delete($_);
}
$self->save_memory;
}
sub delete_all
{
my $self = shift;
my (@state) = @$self{qw(.statefile .cipher .isforgetful .memory .age .errormsg)};
my $id=$self->param('.id');
$self->SUPER::delete_all();
$self->param('.id' => $id);
@$self{qw(.statefile .cipher .isforgetful .memory .age .errormsg)} = @state;
$self->{'.memory'}={} if ($self->{'.isforgetful'});
$self->{'.recent_memory'} = {};
$self->save_memory;
}
sub delete_session {
my $self = shift;
unlink $self->{'.statefile'} or $self->errormsg('failed to delete the state file');
$self->SUPER::delete_all;
}
sub params {
my $self = shift;
return $self->param unless (@_);
return map { scalar $self->param($_) } @_;
}
sub user_param
{
my $self = shift;
return $self->param unless (@_);
if (@_ == 1) {
my $param = shift;
my $value = $self->{'.recent_memory'}->{$param};
return $self->param($param) if (!defined $value);
return wantarray ? @$value : $value->[0];
} else {
my %params = (ref($_[1]) eq 'ARRAY') ? @_ : (shift, \@_);
$self->{'.recent_memory'}->{$_}=[@{$params{$_}}] foreach (keys %params);
}
}
sub user_params {
my $self = shift;
return $self->param unless (@_);
return map { scalar $self->user_param($_) } @_;
}
sub user_delete {
my $self = shift;
delete @{$self->{'.recent_memory'}}{@_};
}
sub age {
my $self = shift;
if (defined $self->{'.age'}) {
my $current_time=unpack("N",pack("N",time()));
return (($current_time-$self->{'.age'})/24/3600);
}
return 0;
}
sub state_url {
my $self = shift;
return $self->script_name()."?.id=".$self->param('.id');
}
sub state_param {
my $self = shift;
return ".id=" . $self->param('.id');
}
sub state_field {
my $self = shift;
return $self->hidden('.id' => $self->param('.id'));
}
sub memory_as {
my ($self, $type) = @_;
return (($type eq 'url') ? $self->state_url . $self->stringify_recent_memory('url') :
($type eq 'param') ? $self->state_param . $self->stringify_recent_memory('url') :
($type eq 'field') ? $self->state_field . $self->stringify_recent_memory('form') : undef);
}
sub start_html {
my $self=shift;
my $isforgetful=$self->{'.isforgetful'};
if ($NASTY_WARNINGS && ! defined $isforgetful) {
return $self->SUPER::start_html(@_) . 'The author of this dynamic web-enabled application did not set the '.
'mandatory \'-mindSet\' attribute when creating a CGI::SecureState object. Please contact him/her and '.
'tell him/her to read the updated CGI::SecureState documentation.';
}
return $self->SUPER::start_html(@_);
}
sub clean_statedir
{
my $self = shift;
my %args = args_to_hash([qw(-age -directory)], @_);
my @states;
if (!defined $args{'-directory'}) {
return unless $self->{'.statefile'};
my ($volume, $directory) = File::Spec->splitpath($self->{'.statefile'});
$args{'-directory'} = ($volume or '') . $directory;
}
$args{'-age'} ||= 1/24;
opendir STATEDIR, $args{'-directory'} or return;
foreach (readdir STATEDIR) {
next unless /^([0-9A-Za-z_-]{27})$/;
push @states, File::Spec->catfile($args{'-directory'}, $1);
}
closedir STATEDIR;
my $removed = 0;
my @old_states = grep { -M $_ > $args{'-age'} } @states;
foreach (@old_states) {
warn "Symlink encountered at $_\n" if ($AVOID_SYMLINKS && -l);
(unlink $_) ? $removed++ : warn "Could not remove old state file $_: $!\n";
}
return @old_states ? $removed/@old_states : 1;
}
sub errormsg
{
my $self=shift;
if (ref($self->{'.errormsg'}) eq 'CODE') {
$self->{'.errormsg'}->(@_) && exit;
}
my $error = shift;
print $self->header;
print $self->start_html(-title => "Server Error: \u$error.", -bgcolor => "white");
print "
\n", $self->h1("The following error was encountered:");
if ($error =~ /^failed/) {
print("
The server $error, which is a file manipulation error. This is most likely due to a bug in ", "the referring script or a permissions problem on the server.
"); } elsif ($error eq "symlink encountered") { print("The server encountered a symlink in the state file directory. This is usually the sign of an ", "attempted security breach and has been logged in the server log files. It is unlikely that you are ", "responsible for this error, but it is nonetheless fatal.
"); warn("CGI::SecureState FATAL error: Symlink encountered while trying to access $self->{'.statefile'}"); } elsif ($error eq "invalid state file") { print("The file that stores information about your session has been corrupted on the server. ", "This is usually the sign of an attemped security breach and has been logged in the server ", " log files. It is unlikely that you are responsible for this error, but it is nonetheless fatal."); warn("CGI::SecureState FATAL error: The state file $self->{'.statefile'} became corrupted."); } elsif ($error eq "statefile inconsistent with mindset") { print("The mindset of the statefile is different from that specified in the referring script. This is", " most likely a bug in the referring script, but could also be due to a file permissions problem."); } else { print "$error.
"; warn("CGI::SecureState FATAL error: $error."); } print $self->end_html; exit; } #### Subroutines below this line are for private use only #### sub generate_id { return join("", map { sprintf("%.32f", $_) } (rand(), rand(), time()^rand(), $CGI::SecureState::Counter+=rand())); } sub args_to_hash { my $list = shift; return unless @_; return ($_[0] =~ /^-/) ? @_ : map { shift @$list => $_ } @_; } sub stringify_recent_memory { my ($self, $format) = @_; my $recent_memory = $self->{'.recent_memory'}; my ($leading, $separating, $closing, $result); if ($format eq 'url') { $leading = $CGI::USE_PARAM_SEMICOLONS ? ';' : '&'; ($separating, $closing) = ('=', ''); } elsif ($format eq 'form') { ($leading, $separating, $closing) = ("\n'); } foreach (keys %$recent_memory) { next if ($_ eq '.id' or substr($_,0,4) eq '.tmp'); my $param = $_; escape_url($param) if ($format eq 'url'); #Do URL-encoding $param = $self->escapeHTML($param) if ($format eq 'form'); foreach (@{$recent_memory->{$param}}) { my $value = $_; escape_url($value) if ($format eq 'url'); #Do URL-encoding $value = $self->escapeHTML($value) if ($format eq 'form'); $result .= $leading . ".tmp$param" . $separating . $value . $closing; } } return $result; } sub recover_recent_memory { my $self = shift; my $recent_memory = $self->{'.recent_memory'}; foreach my $param (keys %$recent_memory) { my @values = $self->param($param); $recent_memory->{$param} = @values ? \@values : [ $self->param(".tmp$param") ]; $self->SUPER::delete(".tmp$param"); $self->param($param => undef) unless @values; } } #Workaround for Perl v5.005_03 so that Unicode is encrypted #and decrypted properly. BEGIN { my $subs = <<'END_OF_FUNCTIONS' #Derived from the escape funtion of CGI::Util sub escape_url { $_[0]=~s/([^a-zA-Z0-9_.-])/sprintf("%%%02X",ord($1))/eg; } sub save_memory { my $self=shift; my (@data,@values,$entity); my ($isforgetful,$memory)=@$self{'.isforgetful','.memory'}; #If we are forgetful, then we need to save the contents of our memory #If we remember stuff, then we need to save everything but the contents of our memory foreach ($self->param) { next if ($isforgetful xor (exists $memory->{$_})); next if ($_ eq '.id' or substr($_,0,4) eq '.tmp'); if (@values=$self->param($_)) { foreach $entity ($_, @values) { $entity =~ s/([ \n\\])/\\$1/go } #escape meta-characters push @data, join(" ",@values), $_; } } push @data, $isforgetful ? "Forgetful" : "Remembering"; $self->encipher(join("\n\n", @data, "Saved-Values")); } sub recover_memory { my $self=shift; my (@data,$param,@values, $value); my ($isforgetful,$memory)=@$self{'.isforgetful','.memory'}; #recover short-term "recent" memory $self->recover_recent_memory(); @data = split(/(?decipher); if (@data) { #skip over fields until we get to the Saved-Values section #to retain compatibility with later versions of CGI::SecureState do { $param=pop(@data) } while ($param ne "Saved-Values" && @data); #check to make sure that our mindset is the same as the statefile's $param=pop @data; if ($param ne ($isforgetful ? "Forgetful" : "Remembering")) { $self->errormsg('statefile inconsistent with mindset') } while (@data) { ($param = pop @data) =~ s/\\(.)/$1/go; #unescape meta-characters @values=split(/(?{$param}) || defined $self->param($param))); foreach $value (@values) { $value =~ s/\\(.)/$1/go } #unescape meta-characters $self->param($param,@values); $self->{'.memory'}->{$param}=1 if ($isforgetful); } } } #The encipher subroutine accepts a list of values to encrypt and writes them to #the state file. If the list of values is empty, it merely updates the timestamp #of the state file. sub encipher { my ($self, $buffer) = @_; my ($cipher, $statefile) = @$self{'.cipher','.statefile'}; my ($length, $time, $block); $time=pack("N",time()); # Open the target file and die with warnings if necessary my $open_flags = $buffer ? (O_WRONLY | O_TRUNC | O_CREAT) : (O_RDWR | O_CREAT); if ($AVOID_SYMLINKS && -l $statefile) { $self->errormsg('symlink encountered') } sysopen(STATEFILE, $statefile, $open_flags, 0600 ) or $self->errormsg('failed to open the state file'); if ($USE_FLOCK && !flock(STATEFILE, LOCK_EX)) { $self->errormsg('failed to lock the state file') } binmode STATEFILE; #if we've got nothing to write, only update the timestamp unless ($buffer) { if (sysread(STATEFILE,$buffer,16)==16) { #the length of the encrypted data is stored in the first four bytes of the state file $length=substr($cipher->decrypt(substr($buffer,0,8)),0,4); $buffer=$length.($time^substr($buffer,12,4)); } else { $length=pack("N",0); $buffer=$length.$time; } sysseek(STATEFILE,0,$SEEK_SET); syswrite(STATEFILE,$cipher->encrypt($buffer)); } else { #add metadata to the beginning of the plaintext $length=length($buffer); $buffer=pack("N",$length).$time.$buffer; #pad the buffer to have a length that is divisible by 8 if ($length%=8) { $length=8-$length; $buffer.=chr(int(rand(256))) while ($length--); } #encrypt in reverse-CBC mode $block=$cipher->encrypt(substr($buffer,-8,8)); substr($buffer,-8,8,$block); $length=length($buffer) - 8; while(($length-=8)>-8) { $block^=substr($buffer,$length,8); $block=$cipher->encrypt($block); substr($buffer,$length,8,$block); } #blast it to the file syswrite(STATEFILE,$buffer); } if ($USE_FLOCK) { flock(STATEFILE, LOCK_UN) || $self->errormsg('failed to unlock the state file') } close(STATEFILE) || $self->errormsg('failed to close the state file'); } sub decipher { my $self = shift; my ($cipher,$statefile) = @$self{'.cipher','.statefile'}; my ($length,$extra,$decoded,$buffer,$block); if ($AVOID_SYMLINKS) { -l $statefile and $self->errormsg('symlink encountered')} sysopen(STATEFILE,$statefile, O_RDONLY) || $self->errormsg('failed to open the state file'); if ($USE_FLOCK) { flock(STATEFILE, LOCK_SH) || $self->errormsg('failed to lock the state file') } binmode STATEFILE; #read metadata sysread(STATEFILE,$block,8); $block = $cipher->decrypt($block); #if there is nothing in the file, only set the age; otherwise read the contents unless (sysread(STATEFILE,$buffer,8)==8) { $self->{'.age'} = unpack("N",substr($block,4,4)); $buffer = ""; } else { #parse metadata $block^=$buffer; $self->{'.age'} = unpack("N",substr($block,4,4)); $length = unpack("N",substr($block,0,4)); $extra = ($length % 8) ? (8-($length % 8)) : 0; $decoded=-8; #sanity check if ((stat(STATEFILE))[7] != ($length+$extra+8)) { $self->errormsg('invalid state file') } #read the rest of the file sysseek(STATEFILE, 8, $SEEK_SET); unless (sysread(STATEFILE,$buffer,$length+$extra) == ($length+$extra)) { $self->errormsg('invalid state file') } my $next_block; $block = $cipher->decrypt(substr($buffer,0,8)); #decrypt it while (($decoded+=8)<$length-8) { $next_block = substr($buffer,$decoded+8,8); $block^=$next_block; substr($buffer, $decoded, 8, $block); $block=$cipher->decrypt($next_block); } substr($buffer, $decoded, 8, $block); substr($buffer, -$extra, $extra, ""); } if ($USE_FLOCK) { flock(STATEFILE, LOCK_UN) || $self->errormsg('failed to unlock the state file') } close(STATEFILE) || $self->errormsg('failed to close the state file'); return($buffer); } END_OF_FUNCTIONS ; eval(($]<5.006) ? $subs : "use bytes; $subs"); } "True Value"; =head1 NAME CGI::SecureState -- Transparent, secure statefulness for CGI programs =head1 SYNOPSIS use CGI::SecureState; my @memory = qw(param1 param2 other_params_to_remember); my $cgi = new CGI::SecureState(-stateDir => "states", -mindSet => 'forgetful', -memory => \@memory); print $cgi->header(), $cgi->start_html; my $url = $cgi->state_url(); my $param = $cgi->state_param(); print "I am a stateful CGI session."; print "I am a different ", "script that also has access to this session."; =head2 Very Important Note for Users of CGI::SecureState 0.2x For those still using the 0.2x series, CGI::SecureState changed enormously between 0.26 and 0.30. Specifically, the addition of mindsets is so important that if you run your old scripts unchanged under CGI::SecureState 0.3x, you will receive nasty warnings (likely both in output web pages and your log files) that will tell you not to do so. Please do yourself a favor by re-reading this documentation, as this mysterious mindset business (as well as all the scrumptious new features) will be made clear. Of course, any and all comments on the changes are welcome. If you are interested, send mail to behroozi@cpan.org with the subject "CGI::SecureState Comment". =head1 DESCRIPTION A Better Solution to the stateless problem. HTTP is by nature a stateless protocol; as soon as the requested object is delivered, HTTP severs the object's connection to the client. HTTP retains no memory of the request details and does not relate subsequent requests with what it has already served. There are a few methods available to deal with this problem, including forms and cookies, but most have problems themselves, including security issues (cookie stealing), browser support (cookie blocking), and painful implementations (forms). CGI::SecureState solves this problem by storing session data in an encrypted state file on the server. CGI::SecureState is similar in purpose to CGI::Persistent (and retains much of the same user interface) but has a completely different implementation. For those of you who have worked with CGI::Persistent before, you will be pleased to learn that CGI::SecureState was designed to work with Perl's taint mode and has worked flawlessly with mod_perl and Apache::Registry for over two years. CGI::SecureState was also designed from the ground up for security, a fact which may rear its ugly head if anybody tries to do something tricksy. =head1 MINDSETS If you were curious about the mindset business mentioned earlier, this section is for you. In the past, CGI::SecureState had only one behavior (which I like to call a mindset), which was to store all the CGI parameters that the client sent to it. Besides bloating session files, this mindset encouraged all sorts of insidious bugs where parameters saved by one script would lurk in the state file and cause problems for scripts down the line. If you could tell CGI::SecureState exactly which parameters to save, then life would get much better. This is exactly what the shiny new "forgetful" mindset does, as it will only store parameters that are I