rt 3.8.11
[freeside.git] / rt / lib / RT / Interface / Web.pm
index b4279fb..e4167e4 100644 (file)
@@ -2,8 +2,8 @@
 #
 # COPYRIGHT:
 #
-# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC
-#                                          <jesse@bestpractical.com>
+# This software is Copyright (c) 1996-2011 Best Practical Solutions, LLC
+#                                          <sales@bestpractical.com>
 #
 # (Except where explicitly superseded by other copyright notices)
 #
@@ -192,6 +192,11 @@ sub HandleRequest {
     SendSessionCookie();
     $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new() unless _UserLoggedIn();
 
+    # Process session-related callbacks before any auth attempts
+    $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Session', CallbackPage => '/autohandler' );
+
+    MaybeRejectPrivateComponentRequest();
+
     MaybeShowNoAuthPage($ARGS);
 
     AttemptExternalAuth($ARGS) if RT->Config->Get('WebExternalAuthContinuous') or not _UserLoggedIn();
@@ -204,13 +209,29 @@ sub HandleRequest {
     unless ( _UserLoggedIn() ) {
         _ForceLogout();
 
-        # If the user is logging in, let's authenticate
-        if ( defined $ARGS->{user} && defined $ARGS->{pass} ) {
-            AttemptPasswordAuthentication($ARGS);
-        } else {
-            # if no credentials then show him login page
-            $HTML::Mason::Commands::m->comp( '/Elements/Login', %$ARGS );
-            $HTML::Mason::Commands::m->abort;
+        # Authenticate if the user is trying to login via user/pass query args
+        my ($authed, $msg) = AttemptPasswordAuthentication($ARGS);
+
+        unless ($authed) {
+            my $m = $HTML::Mason::Commands::m;
+
+            # REST urls get a special 401 response
+            if ($m->request_comp->path =~ '^/REST/\d+\.\d+/') {
+                $HTML::Mason::Commands::r->content_type("text/plain");
+                $m->error_format("text");
+                $m->out("RT/$RT::VERSION 401 Credentials required\n");
+                $m->out("\n$msg\n") if $msg;
+                $m->abort;
+            }
+            # Specially handle /index.html so that we get a nicer URL
+            elsif ( $m->request_comp->path eq '/index.html' ) {
+                my $next = SetNextPage(RT->Config->Get('WebURL'));
+                $m->comp('/NoAuth/Login.html', next => $next, actions => [$msg]);
+                $m->abort;
+            }
+            else {
+                TangentForLogin(results => ($msg ? LoginError($msg) : undef));
+            }
         }
     }
 
@@ -223,6 +244,9 @@ sub HandleRequest {
 
     ShowRequestedPage($ARGS);
     LogRecordedSQLStatements();
+
+    # Process per-page final cleanup callbacks
+    $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Final', CallbackPage => '/autohandler' );
 }
 
 sub _ForceLogout {
@@ -239,6 +263,108 @@ sub _UserLoggedIn {
 
 }
 
+=head2 LoginError ERROR
+
+Pushes a login error into the Actions session store and returns the hash key.
+
+=cut
+
+sub LoginError {
+    my $new = shift;
+    my $key = Digest::MD5::md5_hex( rand(1024) );
+    push @{ $HTML::Mason::Commands::session{"Actions"}->{$key} ||= [] }, $new;
+    $HTML::Mason::Commands::session{'i'}++;
+    return $key;
+}
+
+=head2 SetNextPage [PATH]
+
+Intuits and stashes the next page in the sesssion hash.  If PATH is
+specified, uses that instead of the value of L<IntuitNextPage()>.  Returns
+the hash value.
+
+=cut
+
+sub SetNextPage {
+    my $next = shift || IntuitNextPage();
+    my $hash = Digest::MD5::md5_hex($next . $$ . rand(1024));
+
+    $HTML::Mason::Commands::session{'NextPage'}->{$hash} = $next;
+    $HTML::Mason::Commands::session{'i'}++;
+    
+    SendSessionCookie();
+    return $hash;
+}
+
+
+=head2 TangentForLogin [HASH]
+
+Redirects to C</NoAuth/Login.html>, setting the value of L<IntuitNextPage> as
+the next page.  Optionally takes a hash which is dumped into query params.
+
+=cut
+
+sub TangentForLogin {
+    my $hash  = SetNextPage();
+    my %query = (@_, next => $hash);
+    my $login = RT->Config->Get('WebURL') . 'NoAuth/Login.html?';
+    $login .= $HTML::Mason::Commands::m->comp('/Elements/QueryString', %query);
+    Redirect($login);
+}
+
+=head2 TangentForLoginWithError ERROR
+
+Localizes the passed error message, stashes it with L<LoginError> and then
+calls L<TangentForLogin> with the appropriate results key.
+
+=cut
+
+sub TangentForLoginWithError {
+    my $key = LoginError(HTML::Mason::Commands::loc(@_));
+    TangentForLogin( results => $key );
+}
+
+=head2 IntuitNextPage
+
+Attempt to figure out the path to which we should return the user after a
+tangent.  The current request URL is used, or failing that, the C<WebURL>
+configuration variable.
+
+=cut
+
+sub IntuitNextPage {
+    my $req_uri;
+
+    # This includes any query parameters.  Redirect will take care of making
+    # it an absolute URL.
+    if ($ENV{'REQUEST_URI'}) {
+        $req_uri = $ENV{'REQUEST_URI'};
+
+        # collapse multiple leading slashes so the first part doesn't look like
+        # a hostname of a schema-less URI
+        $req_uri =~ s{^/+}{/};
+    }
+
+    my $next = defined $req_uri ? $req_uri : RT->Config->Get('WebURL');
+
+    # sanitize $next
+    my $uri = URI->new($next);
+
+    # You get undef scheme with a relative uri like "/Search/Build.html"
+    unless (!defined($uri->scheme) || $uri->scheme eq 'http' || $uri->scheme eq 'https') {
+        $next = RT->Config->Get('WebURL');
+    }
+
+    # Make sure we're logging in to the same domain
+    # You can get an undef authority with a relative uri like "index.html"
+    my $uri_base_url = URI->new(RT->Config->Get('WebBaseURL'));
+    unless (!defined($uri->authority) || $uri->authority eq $uri_base_url->authority) {
+        $next = RT->Config->Get('WebURL');
+    }
+
+    return $next;
+}
+
 =head2 MaybeShowInstallModePage 
 
 This function, called exclusively by RT's autohandler, dispatches
@@ -278,12 +404,51 @@ sub MaybeShowNoAuthPage {
 
     return unless $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex');
 
+    # Don't show the login page to logged in users
+    Redirect(RT->Config->Get('WebURL'))
+        if $m->base_comp->path eq '/NoAuth/Login.html' and _UserLoggedIn();
+
     # If it's a noauth file, don't ask for auth.
     SendSessionCookie();
     $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
     $m->abort;
 }
 
+=head2 MaybeRejectPrivateComponentRequest
+
+This function will reject calls to private components, like those under
+C</Elements>. If the requested path is a private component then we will
+abort with a C<403> error.
+
+=cut
+
+sub MaybeRejectPrivateComponentRequest {
+    my $m = $HTML::Mason::Commands::m;
+    my $path = $m->request_comp->path;
+
+    # We do not check for dhandler here, because requesting our dhandlers
+    # directly is okay. Mason will invoke the dhandler with a dhandler_arg of
+    # 'dhandler'.
+
+    if ($path =~ m{
+            / # leading slash
+            ( Elements    |
+              _elements   | # mobile UI
+              Widgets     |
+              autohandler | # requesting this directly is suspicious
+              l           ) # loc component
+            ( $ | / ) # trailing slash or end of path
+        }xi
+        && $path !~ m{ /RTx/Statistics/\w+/Elements/Chart }xi
+      )
+    {
+            warn "rejecting private component $path\n";
+            $m->abort(403);
+    }
+
+    return;
+}
+
 =head2 ShowRequestedPage  \%ARGS
 
 This function, called exclusively by RT's autohandler, dispatches
@@ -380,9 +545,12 @@ sub AttemptExternalAuth {
 
                 # we failed to successfully create the user. abort abort abort.
                 delete $HTML::Mason::Commands::session{'CurrentUser'};
-                $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc( 'Cannot create user: [_1]', $msg ) )
-                    if RT->Config->Get('WebFallbackToInternalAuth');;
-                $m->abort();
+
+                if (RT->Config->Get('WebFallbackToInternalAuth')) {
+                    TangentForLoginWithError('Cannot create user: [_1]', $msg);
+                } else {
+                    $m->abort();
+                }
             }
         }
 
@@ -393,15 +561,13 @@ sub AttemptExternalAuth {
             $user = $orig_user;
 
             if ( RT->Config->Get('WebExternalOnly') ) {
-                $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc('You are not an authorized user') );
-                $m->abort();
+                TangentForLoginWithError('You are not an authorized user');
             }
         }
     } elsif ( RT->Config->Get('WebFallbackToInternalAuth') ) {
         unless ( defined $HTML::Mason::Commands::session{'CurrentUser'} ) {
             # XXX unreachable due to prior defaulting in HandleRequest (check c34d108)
-            $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc('You are not an authorized user') );
-            $m->abort();
+            TangentForLoginWithError('You are not an authorized user');
         }
     } else {
 
@@ -414,7 +580,9 @@ sub AttemptExternalAuth {
 }
 
 sub AttemptPasswordAuthentication {
-    my $ARGS     = shift;
+    my $ARGS = shift;
+    return unless defined $ARGS->{user} && defined $ARGS->{pass};
+
     my $user_obj = RT::CurrentUser->new();
     $user_obj->Load( $ARGS->{user} );
 
@@ -422,15 +590,34 @@ sub AttemptPasswordAuthentication {
 
     unless ( $user_obj->id && $user_obj->IsPassword( $ARGS->{pass} ) ) {
         $RT::Logger->error("FAILED LOGIN for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
-        $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc('Your username or password is incorrect'), );
         $m->callback( %$ARGS, CallbackName => 'FailedLogin', CallbackPage => '/autohandler' );
-        $m->abort;
+        return (0, HTML::Mason::Commands::loc('Your username or password is incorrect'));
     }
+    else {
+        $RT::Logger->info("Successful login for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
 
-    $RT::Logger->info("Successful login for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
-    InstantiateNewSession();
-    $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
-    $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler' );
+        # It's important to nab the next page from the session before we blow
+        # the session away
+        my $next = delete $HTML::Mason::Commands::session{'NextPage'}->{$ARGS->{'next'} || ''};
+
+        InstantiateNewSession();
+        $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
+        SendSessionCookie();
+
+        $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler' );
+
+        # Really the only time we don't want to redirect here is if we were
+        # passed user and pass as query params in the URL.
+        if ($next) {
+            Redirect($next);
+        }
+        elsif ($ARGS->{'next'}) {
+            # Invalid hash, but still wants to go somewhere, take them to /
+            Redirect(RT->Config->Get('WebURL'));
+        }
+
+        return (1, HTML::Mason::Commands::loc('Logged in'));
+    }
 }
 
 =head2 LoadSessionFromCookie
@@ -474,10 +661,11 @@ sub InstantiateNewSession {
 
 sub SendSessionCookie {
     my $cookie = CGI::Cookie->new(
-        -name   => _SessionCookieName(),
-        -value  => $HTML::Mason::Commands::session{_session_id},
-        -path   => RT->Config->Get('WebPath'),
-        -secure => ( RT->Config->Get('WebSecureCookies') ? 1 : 0 )
+        -name     => _SessionCookieName(),
+        -value    => $HTML::Mason::Commands::session{_session_id},
+        -path     => RT->Config->Get('WebPath'),
+        -secure   => ( RT->Config->Get('WebSecureCookies') ? 1 : 0 ),
+        -httponly => ( RT->Config->Get('WebHttpOnlyCookies') ? 1 : 0 ),
     );
 
     $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'} = $cookie->as_string;
@@ -497,6 +685,13 @@ sub Redirect {
     untie $HTML::Mason::Commands::session;
     my $uri        = URI->new($redir_to);
     my $server_uri = URI->new( RT->Config->Get('WebURL') );
+    
+    # Make relative URIs absolute from the server host and scheme
+    $uri->scheme($server_uri->scheme) if not defined $uri->scheme;
+    if (not defined $uri->host) {
+        $uri->host($server_uri->host);
+        $uri->port($server_uri->port);
+    }
 
     # If the user is coming in via a non-canonical
     # hostname, don't redirect them to the canonical host,
@@ -554,6 +749,58 @@ sub StaticFileHeaders {
     # $HTML::Mason::Commands::r->headers_out->{'Last-Modified'} = $date->RFC2616;
 }
 
+=head2 PathIsSafe
+
+Takes a C<< Path => path >> and returns a boolean indicating that
+the path is safely within RT's control or not. The path I<must> be
+relative.
+
+This function does not consult the filesystem at all; it is merely
+a logical sanity checking of the path. This explicitly does not handle
+symlinks; if you have symlinks in RT's webroot pointing outside of it,
+then we assume you know what you are doing.
+
+=cut
+
+sub PathIsSafe {
+    my $self = shift;
+    my %args = @_;
+    my $path = $args{Path};
+
+    # Get File::Spec to clean up extra /s, ./, etc
+    my $cleaned_up = File::Spec->canonpath($path);
+
+    if (!defined($cleaned_up)) {
+        $RT::Logger->info("Rejecting path that canonpath doesn't understand: $path");
+        return 0;
+    }
+
+    # Forbid too many ..s. We can't just sum then check because
+    # "../foo/bar/baz" should be illegal even though it has more
+    # downdirs than updirs. So as soon as we get a negative score
+    # (which means "breaking out" of the top level) we reject the path.
+
+    my @components = split '/', $cleaned_up;
+    my $score = 0;
+    for my $component (@components) {
+        if ($component eq '..') {
+            $score--;
+            if ($score < 0) {
+                $RT::Logger->info("Rejecting unsafe path: $path");
+                return 0;
+            }
+        }
+        elsif ($component eq '.' || $component eq '') {
+            # these two have no effect on $score
+        }
+        else {
+            $score++;
+        }
+    }
+
+    return 1;
+}
+
 =head2 SendStaticFile 
 
 Takes a File => path and a Type => Content-type
@@ -571,6 +818,12 @@ sub SendStaticFile {
     my %args = @_;
     my $file = $args{File};
     my $type = $args{Type};
+    my $relfile = $args{RelativeFile};
+
+    if (defined($relfile) && !$self->PathIsSafe(Path => $relfile)) {
+        $HTML::Mason::Commands::r->status(400);
+        $HTML::Mason::Commands::m->abort;
+    }
 
     $self->StaticFileHeaders();
 
@@ -581,8 +834,15 @@ sub SendStaticFile {
         }
         $type ||= "application/octet-stream";
     }
+
+    # CGI.pm version 3.51 and 3.52 bang charset=iso-8859-1 onto our JS
+    # since we don't specify a charset
+    if ( $type =~ m{application/javascript} &&
+         $type !~ m{charset=([\w-]+)$} ) {
+         $type .= "; charset=utf-8";
+    }
     $HTML::Mason::Commands::r->content_type($type);
-    open my $fh, "<$file" or die "couldn't open file: $!";
+    open( my $fh, '<', $file ) or die "couldn't open file: $!";
     binmode($fh);
     {
         local $/ = \16384;
@@ -626,8 +886,13 @@ sub StripContent {
     # Check for plaintext sig
     return '' if not $html and $content =~ /^(--)?\Q$sig\E$/;
 
-    # Check for html-formatted sig
-    RT::Interface::Web::EscapeUTF8( \$sig );
+    # Check for html-formatted sig; we don't use EscapeUTF8 here
+    # because we want to precisely match the escaping that FCKEditor
+    # uses. see also 311223f5, which fixed this for 4.0
+    $sig =~ s/&/&amp;/g;
+    $sig =~ s/</&lt;/g;
+    $sig =~ s/>/&gt;/g;
+
     return ''
       if $html
           and $content =~ m{^(?:<p>)?(--)?\Q$sig\E(?:</p>)?$}s;
@@ -874,7 +1139,9 @@ sub CreateTicket {
     }
 
     foreach my $argument (qw(Encrypt Sign)) {
-        $MIMEObj->head->add( "X-RT-$argument" => $ARGS{$argument} ) if defined $ARGS{$argument};
+        $MIMEObj->head->add(
+            "X-RT-$argument" => Encode::encode_utf8( $ARGS{$argument} )
+        ) if defined $ARGS{$argument};
     }
 
     my %create_args = (
@@ -1084,7 +1351,9 @@ sub ProcessUpdateMessage {
         Type    => $args{ARGSRef}->{'UpdateContentType'},
     );
 
-    $Message->head->add( 'Message-ID' => RT::Interface::Email::GenMessageId( Ticket => $args{'TicketObj'}, ) );
+    $Message->head->add( 'Message-ID' => Encode::encode_utf8(
+        RT::Interface::Email::GenMessageId( Ticket => $args{'TicketObj'} )
+    ) );
     my $old_txn = RT::Transaction->new( $session{'CurrentUser'} );
     if ( $args{ARGSRef}->{'QuoteTransaction'} ) {
         $old_txn->Load( $args{ARGSRef}->{'QuoteTransaction'} );
@@ -1115,15 +1384,42 @@ sub ProcessUpdateMessage {
     my $bcc = $args{ARGSRef}->{'UpdateBcc'};
     my $cc  = $args{ARGSRef}->{'UpdateCc'};
 
+    my %txn_customfields;
+
+    foreach my $key ( keys %{ $args{ARGSRef} } ) {
+      if ( $key =~ /^(?:Object-RT::Transaction--)?CustomField-(\d+)/ ) {
+        $txn_customfields{$key} = $args{ARGSRef}->{$key};
+      }
+    }
+
     my %message_args = (
         CcMessageTo  => $cc,
         BccMessageTo => $bcc,
         Sign         => $args{ARGSRef}->{'Sign'},
         Encrypt      => $args{ARGSRef}->{'Encrypt'},
         MIMEObj      => $Message,
-        TimeTaken    => $args{ARGSRef}->{'UpdateTimeWorked'}
+        TimeTaken    => $args{ARGSRef}->{'UpdateTimeWorked'},
+        CustomFields => \%txn_customfields,
     );
 
+    my @temp_squelch;
+    foreach my $type (qw(Cc AdminCc)) {
+        if (grep $_ eq $type || $_ eq ( $type . 's' ), @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
+            push @temp_squelch, map $_->address, Email::Address->parse( $message_args{$type} );
+            push @temp_squelch, $args{TicketObj}->$type->MemberEmailAddresses;
+            push @temp_squelch, $args{TicketObj}->QueueObj->$type->MemberEmailAddresses;
+        }
+    }
+    if (grep $_ eq 'Requestor' || $_ eq 'Requestors', @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
+            push @temp_squelch, map $_->address, Email::Address->parse( $message_args{Requestor} );
+            push @temp_squelch, $args{TicketObj}->Requestors->MemberEmailAddresses;
+    }
+
+    if (@temp_squelch) {
+        require RT::Action::SendEmail;
+        RT::Action::SendEmail->SquelchMailTo( RT::Action::SendEmail->SquelchMailTo, @temp_squelch );
+    }
+
     unless ( $args{'ARGSRef'}->{'UpdateIgnoreAddressCheckboxes'} ) {
         foreach my $key ( keys %{ $args{ARGSRef} } ) {
             next unless $key =~ /^Update(Cc|Bcc)-(.*)$/;
@@ -1139,14 +1435,17 @@ sub ProcessUpdateMessage {
     }
 
     my @results;
+    # Do the update via the appropriate Ticket method
     if ( $args{ARGSRef}->{'UpdateType'} =~ /^(private|public)$/ ) {
-        my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Comment(%message_args);
+        my ( $Transaction, $Description, $Object ) = 
+            $args{TicketObj}->Comment(%message_args);
         push( @results, $Description );
-        $Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object;
+        #$Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object;
     } elsif ( $args{ARGSRef}->{'UpdateType'} eq 'response' ) {
-        my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Correspond(%message_args);
+        my ( $Transaction, $Description, $Object ) = 
+            $args{TicketObj}->Correspond(%message_args);
         push( @results, $Description );
-        $Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object;
+        #$Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object;
     } else {
         push( @results,
             loc("Update type was neither correspondence nor comment.") . " " . loc("Update not recorded.") );
@@ -1182,9 +1481,8 @@ sub MakeMIMEEntity {
     );
     my $Message = MIME::Entity->build(
         Type    => 'multipart/mixed',
-        Subject => $args{'Subject'} || "",
-        From    => $args{'From'},
-        Cc      => $args{'Cc'},
+        map { $_ => Encode::encode_utf8( $args{ $_} ) }
+            grep defined $args{$_}, qw(Subject From Cc)
     );
 
     if ( defined $args{'Body'} && length $args{'Body'} ) {
@@ -1192,12 +1490,8 @@ sub MakeMIMEEntity {
         # Make the update content have no 'weird' newlines in it
         $args{'Body'} =~ s/\r\n/\n/gs;
 
-        # MIME::Head is not happy in utf-8 domain.  This only happens
-        # when processing an incoming email (so far observed).
-        no utf8;
-        use bytes;
         $Message->attach(
-            Type => $args{'Type'} || 'text/plain',
+            Type    => $args{'Type'} || 'text/plain',
             Charset => 'UTF-8',
             Data    => $args{'Body'},
         );
@@ -1218,8 +1512,8 @@ sub MakeMIMEEntity {
 
             # Prefer the cached name first over CGI.pm stringification.
             my $filename = $RT::Mason::CGI::Filename;
-            $filename = "$filehandle" unless defined($filename);
-            $filename = Encode::decode_utf8($filename);
+            $filename = "$filehandle" unless defined $filename;
+            $filename = Encode::encode_utf8( $filename );
             $filename =~ s{^.*[\\/]}{};
 
             $Message->attach(
@@ -1234,6 +1528,7 @@ sub MakeMIMEEntity {
     }
 
     $Message->make_singlepart;
+
     RT::I18N::SetMIMEEntityToUTF8($Message);    # convert text parts into utf-8
 
     return ($Message);
@@ -1269,8 +1564,6 @@ sub ParseDateToISO {
 sub ProcessACLChanges {
     my $ARGSref = shift;
 
-    #XXX: why don't we get ARGSref like in other Process* subs?
-
     my @results;
 
     foreach my $arg ( keys %$ARGSref ) {
@@ -1485,6 +1778,8 @@ sub ProcessTicketCustomFieldUpdates {
             $ARGSRef->{"Object-RT::Ticket-$1"} = delete $ARGSRef->{$arg};
         } elsif ( $arg =~ /^CustomField-(\d+-.*)/ ) {
             $ARGSRef->{"Object-RT::Ticket--$1"} = delete $ARGSRef->{$arg};
+        } elsif ( $arg =~ /^Object-RT::Transaction-(\d*)-CustomField/ ) {
+            delete $ARGSRef->{$arg}; # don't try to update transaction fields
         }
     }
 
@@ -1560,6 +1855,9 @@ sub _ProcessObjectCustomFieldUpdates {
         # skip category argument
         next if $arg eq 'Category';
 
+        # and TimeUnits
+        next if $arg eq 'Value-TimeUnits';
+
         # since http won't pass in a form element with a null value, we need
         # to fake it
         if ( $arg eq 'Values-Magic' ) {
@@ -1638,6 +1936,9 @@ sub _ProcessObjectCustomFieldUpdates {
                 $values_hash{$val} = 1 if $val;
             }
 
+            # For Date Cfs, @values is empty when there is no changes (no datas in form input)
+            return @results if ( $cf->Type eq 'Date' && ! @values );
+
             $cf_values->RedoSearch;
             while ( my $cf_value = $cf_values->Next ) {
                 next if $values_hash{ $cf_value->id };
@@ -2021,9 +2322,7 @@ sub _parse_saved_search {
     return ( _load_container_object( $obj_type, $obj_id ), $search_id );
 }
 
-eval "require RT::Interface::Web_Vendor";
-die $@ if ( $@ && $@ !~ qr{^Can't locate RT/Interface/Web_Vendor.pm} );
-eval "require RT::Interface::Web_Local";
-die $@ if ( $@ && $@ !~ qr{^Can't locate RT/Interface/Web_Local.pm} );
+package RT::Interface::Web;
+RT::Base->_ImportOverlays();
 
 1;