(CVE-2013-0786) [SECURITY] build_subselect() leaks the existence of products and...
[WebKit-https.git] / Websites / bugs.webkit.org / Bugzilla / Token.pm
1 # -*- Mode: perl; indent-tabs-mode: nil -*-
2 #
3 # The contents of this file are subject to the Mozilla Public
4 # License Version 1.1 (the "License"); you may not use this file
5 # except in compliance with the License. You may obtain a copy of
6 # the License at http://www.mozilla.org/MPL/
7 #
8 # Software distributed under the License is distributed on an "AS
9 # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
10 # implied. See the License for the specific language governing
11 # rights and limitations under the License.
12 #
13 # The Original Code is the Bugzilla Bug Tracking System.
14 #
15 # The Initial Developer of the Original Code is Netscape Communications
16 # Corporation. Portions created by Netscape are
17 # Copyright (C) 1998 Netscape Communications Corporation. All
18 # Rights Reserved.
19 #
20 # Contributor(s):    Myk Melez <myk@mozilla.org>
21 #                    Frédéric Buclin <LpSolit@gmail.com>
22
23 ################################################################################
24 # Module Initialization
25 ################################################################################
26
27 # Make it harder for us to do dangerous things in Perl.
28 use strict;
29
30 # Bundle the functions in this file together into the "Bugzilla::Token" package.
31 package Bugzilla::Token;
32
33 use Bugzilla::Constants;
34 use Bugzilla::Error;
35 use Bugzilla::Mailer;
36 use Bugzilla::Util;
37 use Bugzilla::User;
38
39 use Date::Format;
40 use Date::Parse;
41 use File::Basename;
42 use Digest::MD5 qw(md5_hex);
43
44 use base qw(Exporter);
45
46 @Bugzilla::Token::EXPORT = qw(issue_session_token check_token_data delete_token
47                               issue_hash_token check_hash_token);
48
49 ################################################################################
50 # Public Functions
51 ################################################################################
52
53 # Creates and sends a token to create a new user account.
54 # It assumes that the login has the correct format and is not already in use.
55 sub issue_new_user_account_token {
56     my $login_name = shift;
57     my $dbh = Bugzilla->dbh;
58     my $template = Bugzilla->template;
59     my $vars = {};
60
61     # Is there already a pending request for this login name? If yes, do not throw
62     # an error because the user may have lost his email with the token inside.
63     # But to prevent using this way to mailbomb an email address, make sure
64     # the last request is at least 10 minutes old before sending a new email.
65
66     my $pending_requests =
67         $dbh->selectrow_array('SELECT COUNT(*)
68                                  FROM tokens
69                                 WHERE tokentype = ?
70                                   AND ' . $dbh->sql_istrcmp('eventdata', '?') . '
71                                   AND issuedate > NOW() - ' . $dbh->sql_interval(10, 'MINUTE'),
72                                undef, ('account', $login_name));
73
74     ThrowUserError('too_soon_for_new_token', {'type' => 'account'}) if $pending_requests;
75
76     my ($token, $token_ts) = _create_token(undef, 'account', $login_name);
77
78     $vars->{'email'} = $login_name . Bugzilla->params->{'emailsuffix'};
79     $vars->{'token_ts'} = $token_ts;
80     $vars->{'token'} = $token;
81
82     my $message;
83     $template->process('account/email/request-new.txt.tmpl', $vars, \$message)
84       || ThrowTemplateError($template->error());
85
86     # In 99% of cases, the user getting the confirmation email is the same one
87     # who made the request, and so it is reasonable to send the email in the same
88     # language used to view the "Create a New Account" page (we cannot use his
89     # user prefs as the user has no account yet!).
90     MessageToMTA($message);
91 }
92
93 sub IssueEmailChangeToken {
94     my ($user, $old_email, $new_email) = @_;
95     my $email_suffix = Bugzilla->params->{'emailsuffix'};
96
97     my ($token, $token_ts) = _create_token($user->id, 'emailold', $old_email . ":" . $new_email);
98
99     my $newtoken = _create_token($user->id, 'emailnew', $old_email . ":" . $new_email);
100
101     # Mail the user the token along with instructions for using it.
102
103     my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
104     my $vars = {};
105
106     $vars->{'oldemailaddress'} = $old_email . $email_suffix;
107     $vars->{'newemailaddress'} = $new_email . $email_suffix;
108     
109     $vars->{'max_token_age'} = MAX_TOKEN_AGE;
110     $vars->{'token_ts'} = $token_ts;
111
112     $vars->{'token'} = $token;
113     $vars->{'emailaddress'} = $old_email . $email_suffix;
114
115     my $message;
116     $template->process("account/email/change-old.txt.tmpl", $vars, \$message)
117       || ThrowTemplateError($template->error());
118
119     MessageToMTA($message);
120
121     $vars->{'token'} = $newtoken;
122     $vars->{'emailaddress'} = $new_email . $email_suffix;
123
124     $message = "";
125     $template->process("account/email/change-new.txt.tmpl", $vars, \$message)
126       || ThrowTemplateError($template->error());
127
128     Bugzilla->template_inner("");
129     MessageToMTA($message);
130 }
131
132 # Generates a random token, adds it to the tokens table, and sends it
133 # to the user with instructions for using it to change their password.
134 sub IssuePasswordToken {
135     my $user = shift;
136     my $dbh = Bugzilla->dbh;
137
138     my $too_soon =
139         $dbh->selectrow_array('SELECT 1 FROM tokens
140                                 WHERE userid = ?
141                                   AND tokentype = ?
142                                   AND issuedate > NOW() - ' .
143                                       $dbh->sql_interval(10, 'MINUTE'),
144                                 undef, ($user->id, 'password'));
145
146     ThrowUserError('too_soon_for_new_token', {'type' => 'password'}) if $too_soon;
147
148     my ($token, $token_ts) = _create_token($user->id, 'password', $::ENV{'REMOTE_ADDR'});
149
150     # Mail the user the token along with instructions for using it.
151     my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
152     my $vars = {};
153
154     $vars->{'token'} = $token;
155     $vars->{'emailaddress'} = $user->email;
156     $vars->{'max_token_age'} = MAX_TOKEN_AGE;
157     $vars->{'token_ts'} = $token_ts;
158
159     my $message = "";
160     $template->process("account/password/forgotten-password.txt.tmpl", 
161                                                                $vars, \$message)
162       || ThrowTemplateError($template->error());
163
164     Bugzilla->template_inner("");
165     MessageToMTA($message);
166 }
167
168 sub issue_session_token {
169     # Generates a random token, adds it to the tokens table, and returns
170     # the token to the caller.
171
172     my $data = shift;
173     return _create_token(Bugzilla->user->id, 'session', $data);
174 }
175
176 sub issue_hash_token {
177     my ($data, $time) = @_;
178     $data ||= [];
179     $time ||= time();
180
181     # The concatenated string is of the form
182     # token creation time + site-wide secret + user ID + data
183     my @args = ($time, Bugzilla->localconfig->{'site_wide_secret'}, Bugzilla->user->id, @$data);
184
185     my $token = join('*', @args);
186     # Wide characters cause md5_hex() to die.
187     if (Bugzilla->params->{'utf8'}) {
188         utf8::encode($token) if utf8::is_utf8($token);
189     }
190     $token = md5_hex($token);
191
192     # Prepend the token creation time, unencrypted, so that the token
193     # lifetime can be validated.
194     return $time . '-' . $token;
195 }
196
197 sub check_hash_token {
198     my ($token, $data) = @_;
199     $data ||= [];
200     my ($time, $expected_token);
201
202     if ($token) {
203         ($time, undef) = split(/-/, $token);
204         # Regenerate the token based on the information we have.
205         $expected_token = issue_hash_token($data, $time);
206     }
207
208     if (!$token
209         || $expected_token ne $token
210         || time() - $time > MAX_TOKEN_AGE * 86400)
211     {
212         my $template = Bugzilla->template;
213         my $vars = {};
214         $vars->{'script_name'} = basename($0);
215         $vars->{'token'} = issue_hash_token($data);
216         $vars->{'reason'} = (!$token) ?                   'missing_token' :
217                             ($expected_token ne $token) ? 'invalid_token' :
218                                                           'expired_token';
219         print Bugzilla->cgi->header();
220         $template->process('global/confirm-action.html.tmpl', $vars)
221           || ThrowTemplateError($template->error());
222         exit;
223     }
224
225     # If we come here, then the token is valid and not too old.
226     return 1;
227 }
228
229 sub CleanTokenTable {
230     my $dbh = Bugzilla->dbh;
231     $dbh->do('DELETE FROM tokens
232               WHERE ' . $dbh->sql_to_days('NOW()') . ' - ' .
233                         $dbh->sql_to_days('issuedate') . ' >= ?',
234               undef, MAX_TOKEN_AGE);
235 }
236
237 sub GenerateUniqueToken {
238     # Generates a unique random token.  Uses generate_random_password 
239     # for the tokens themselves and checks uniqueness by searching for
240     # the token in the "tokens" table.  Gives up if it can't come up
241     # with a token after about one hundred tries.
242     my ($table, $column) = @_;
243
244     my $token;
245     my $duplicate = 1;
246     my $tries = 0;
247     $table ||= "tokens";
248     $column ||= "token";
249
250     my $dbh = Bugzilla->dbh;
251     my $sth = $dbh->prepare("SELECT userid FROM $table WHERE $column = ?");
252
253     while ($duplicate) {
254         ++$tries;
255         if ($tries > 100) {
256             ThrowCodeError("token_generation_error");
257         }
258         $token = generate_random_password();
259         $sth->execute($token);
260         $duplicate = $sth->fetchrow_array;
261     }
262     return $token;
263 }
264
265 # Cancels a previously issued token and notifies the user.
266 # This should only happen when the user accidentally makes a token request
267 # or when a malicious hacker makes a token request on behalf of a user.
268 sub Cancel {
269     my ($token, $cancelaction, $vars) = @_;
270     my $dbh = Bugzilla->dbh;
271     $vars ||= {};
272
273     # Get information about the token being canceled.
274     trick_taint($token);
275     my ($issuedate, $tokentype, $eventdata, $userid) =
276         $dbh->selectrow_array('SELECT ' . $dbh->sql_date_format('issuedate') . ',
277                                       tokentype, eventdata, userid
278                                  FROM tokens
279                                 WHERE token = ?',
280                                 undef, $token);
281
282     # If we are canceling the creation of a new user account, then there
283     # is no entry in the 'profiles' table.
284     my $user = new Bugzilla::User($userid);
285
286     $vars->{'emailaddress'} = $userid ? $user->email : $eventdata;
287     $vars->{'remoteaddress'} = $::ENV{'REMOTE_ADDR'};
288     $vars->{'token'} = $token;
289     $vars->{'tokentype'} = $tokentype;
290     $vars->{'issuedate'} = $issuedate;
291     $vars->{'eventdata'} = $eventdata;
292     $vars->{'cancelaction'} = $cancelaction;
293
294     # Notify the user via email about the cancellation.
295     my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
296
297     my $message;
298     $template->process("account/cancel-token.txt.tmpl", $vars, \$message)
299       || ThrowTemplateError($template->error());
300
301     Bugzilla->template_inner("");
302     MessageToMTA($message);
303
304     # Delete the token from the database.
305     delete_token($token);
306 }
307
308 sub DeletePasswordTokens {
309     my ($userid, $reason) = @_;
310     my $dbh = Bugzilla->dbh;
311
312     detaint_natural($userid);
313     my $tokens = $dbh->selectcol_arrayref('SELECT token FROM tokens
314                                            WHERE userid = ? AND tokentype = ?',
315                                            undef, ($userid, 'password'));
316
317     foreach my $token (@$tokens) {
318         Bugzilla::Token::Cancel($token, $reason);
319     }
320 }
321
322 # Returns an email change token if the user has one. 
323 sub HasEmailChangeToken {
324     my $userid = shift;
325     my $dbh = Bugzilla->dbh;
326
327     my $token = $dbh->selectrow_array('SELECT token FROM tokens
328                                        WHERE userid = ?
329                                        AND (tokentype = ? OR tokentype = ?) ' .
330                                        $dbh->sql_limit(1),
331                                        undef, ($userid, 'emailnew', 'emailold'));
332     return $token;
333 }
334
335 # Returns the userid, issuedate and eventdata for the specified token
336 sub GetTokenData {
337     my ($token) = @_;
338     my $dbh = Bugzilla->dbh;
339
340     return unless defined $token;
341     $token = clean_text($token);
342     trick_taint($token);
343
344     return $dbh->selectrow_array(
345         "SELECT userid, " . $dbh->sql_date_format('issuedate') . ", eventdata 
346          FROM   tokens 
347          WHERE  token = ?", undef, $token);
348 }
349
350 # Deletes specified token
351 sub delete_token {
352     my ($token) = @_;
353     my $dbh = Bugzilla->dbh;
354
355     return unless defined $token;
356     trick_taint($token);
357
358     $dbh->do("DELETE FROM tokens WHERE token = ?", undef, $token);
359 }
360
361 # Given a token, makes sure it comes from the currently logged in user
362 # and match the expected event. Returns 1 on success, else displays a warning.
363 # Note: this routine must not be called while tables are locked as it will try
364 # to lock some tables itself, see CleanTokenTable().
365 sub check_token_data {
366     my ($token, $expected_action, $alternate_script) = @_;
367     my $user = Bugzilla->user;
368     my $template = Bugzilla->template;
369     my $cgi = Bugzilla->cgi;
370
371     my ($creator_id, $date, $token_action) = GetTokenData($token);
372     unless ($creator_id
373             && $creator_id == $user->id
374             && $token_action eq $expected_action)
375     {
376         # Something is going wrong. Ask confirmation before processing.
377         # It is possible that someone tried to trick an administrator.
378         # In this case, we want to know his name!
379         require Bugzilla::User;
380
381         my $vars = {};
382         $vars->{'abuser'} = Bugzilla::User->new($creator_id)->identity;
383         $vars->{'token_action'} = $token_action;
384         $vars->{'expected_action'} = $expected_action;
385         $vars->{'script_name'} = basename($0);
386         $vars->{'alternate_script'} = $alternate_script || basename($0);
387
388         # Now is a good time to remove old tokens from the DB.
389         CleanTokenTable();
390
391         # If no token was found, create a valid token for the given action.
392         unless ($creator_id) {
393             $token = issue_session_token($expected_action);
394             $cgi->param('token', $token);
395         }
396
397         print $cgi->header();
398
399         $template->process('admin/confirm-action.html.tmpl', $vars)
400           || ThrowTemplateError($template->error());
401         exit;
402     }
403     return 1;
404 }
405
406 ################################################################################
407 # Internal Functions
408 ################################################################################
409
410 # Generates a unique token and inserts it into the database
411 # Returns the token and the token timestamp
412 sub _create_token {
413     my ($userid, $tokentype, $eventdata) = @_;
414     my $dbh = Bugzilla->dbh;
415
416     detaint_natural($userid) if defined $userid;
417     trick_taint($tokentype);
418     trick_taint($eventdata);
419
420     $dbh->bz_start_transaction();
421
422     my $token = GenerateUniqueToken();
423
424     $dbh->do("INSERT INTO tokens (userid, issuedate, token, tokentype, eventdata)
425         VALUES (?, NOW(), ?, ?, ?)", undef, ($userid, $token, $tokentype, $eventdata));
426
427     $dbh->bz_commit_transaction();
428
429     if (wantarray) {
430         my (undef, $token_ts, undef) = GetTokenData($token);
431         $token_ts = str2time($token_ts);
432         return ($token, $token_ts);
433     } else {
434         return $token;
435     }
436 }
437
438 1;
439
440 __END__
441
442 =head1 NAME
443
444 Bugzilla::Token - Provides different routines to manage tokens.
445
446 =head1 SYNOPSIS
447
448     use Bugzilla::Token;
449
450     Bugzilla::Token::issue_new_user_account_token($login_name);
451     Bugzilla::Token::IssueEmailChangeToken($user, $old_email, $new_email);
452     Bugzilla::Token::IssuePasswordToken($user);
453     Bugzilla::Token::DeletePasswordTokens($user_id, $reason);
454     Bugzilla::Token::Cancel($token, $cancelaction, $vars);
455
456     Bugzilla::Token::CleanTokenTable();
457
458     my $token = issue_session_token($event);
459     check_token_data($token, $event)
460     delete_token($token);
461
462     my $token = Bugzilla::Token::GenerateUniqueToken($table, $column);
463     my $token = Bugzilla::Token::HasEmailChangeToken($user_id);
464     my ($token, $date, $data) = Bugzilla::Token::GetTokenData($token);
465
466 =head1 SUBROUTINES
467
468 =over
469
470 =item C<issue_new_user_account_token($login_name)>
471
472  Description: Creates and sends a token per email to the email address
473               requesting a new user account. It doesn't check whether
474               the user account already exists. The user will have to
475               use this token to confirm the creation of his user account.
476
477  Params:      $login_name - The new login name requested by the user.
478
479  Returns:     Nothing. It throws an error if the same user made the same
480               request in the last few minutes.
481
482 =item C<sub IssueEmailChangeToken($user, $old_email, $new_email)>
483
484  Description: Sends two distinct tokens per email to the old and new email
485               addresses to confirm the email address change for the given
486               user. These tokens remain valid for the next MAX_TOKEN_AGE days.
487
488  Params:      $user      - User object of the user requesting a new
489                            email address.
490               $old_email - The current (old) email address of the user.
491               $new_email - The new email address of the user.
492
493  Returns:     Nothing.
494
495 =item C<IssuePasswordToken($user)>
496
497  Description: Sends a token per email to the given user. This token
498               can be used to change the password (e.g. in case the user
499               cannot remember his password and wishes to enter a new one).
500
501  Params:      $user - User object of the user requesting a new password.
502
503  Returns:     Nothing. It throws an error if the same user made the same
504               request in the last few minutes.
505
506 =item C<CleanTokenTable()>
507
508  Description: Removes all tokens older than MAX_TOKEN_AGE days from the DB.
509               This means that these tokens will now be considered as invalid.
510
511  Params:      None.
512
513  Returns:     Nothing.
514
515 =item C<GenerateUniqueToken($table, $column)>
516
517  Description: Generates and returns a unique token. This token is unique
518               in the $column of the $table. This token is NOT stored in the DB.
519
520  Params:      $table (optional): The table to look at (default: tokens).
521               $column (optional): The column to look at for uniqueness (default: token).
522
523  Returns:     A token which is unique in $column.
524
525 =item C<Cancel($token, $cancelaction, $vars)>
526
527  Description: Invalidates an existing token, generally when the token is used
528               for an action which is not the one expected. An email is sent
529               to the user who originally requested this token to inform him
530               that this token has been invalidated (e.g. because an hacker
531               tried to use this token for some malicious action).
532
533  Params:      $token:        The token to invalidate.
534               $cancelaction: The reason why this token is invalidated.
535               $vars:         Some additional information about this action.
536
537  Returns:     Nothing.
538
539 =item C<DeletePasswordTokens($user_id, $reason)>
540
541  Description: Cancels all password tokens for the given user. Emails are sent
542               to the user to inform him about this action.
543
544  Params:      $user_id: The user ID of the user account whose password tokens
545                         are canceled.
546               $reason:  The reason why these tokens are canceled.
547
548  Returns:     Nothing.
549
550 =item C<HasEmailChangeToken($user_id)>
551
552  Description: Returns any existing token currently used for an email change
553               for the given user.
554
555  Params:      $user_id - A user ID.
556
557  Returns:     A token if it exists, else undef.
558
559 =item C<GetTokenData($token)>
560
561  Description: Returns all stored data for the given token.
562
563  Params:      $token - A valid token.
564
565  Returns:     The user ID, the date and time when the token was created and
566               the (event)data stored with that token.
567
568 =back
569
570 =head2 Security related routines
571
572 The following routines have been written to be used together as described below,
573 although they can be used separately.
574
575 =over
576
577 =item C<issue_session_token($event)>
578
579  Description: Creates and returns a token used internally.
580
581  Params:      $event - The event which needs to be stored in the DB for future
582                        reference/checks.
583
584  Returns:     A unique token.
585
586 =item C<check_token_data($token, $event)>
587
588  Description: Makes sure the $token has been created by the currently logged in
589               user and to be used for the given $event. If this token is used for
590               an unexpected action (i.e. $event doesn't match the information stored
591               with the token), a warning is displayed asking whether the user really
592               wants to continue. On success, it returns 1.
593               This is the routine to use for security checks, combined with
594               issue_session_token() and delete_token() as follows:
595
596               1. First, create a token for some coming action.
597               my $token = issue_session_token($action);
598               2. Some time later, it's time to make sure that the expected action
599                  is going to be executed, and by the expected user.
600               check_token_data($token, $action);
601               3. The check has been done and we no longer need this token.
602               delete_token($token);
603
604  Params:      $token - The token used for security checks.
605               $event - The expected event to be run.
606
607  Returns:     1 on success, else a warning is thrown.
608
609 =item C<delete_token($token)>
610
611  Description: Deletes the specified token. No notification is sent.
612
613  Params:      $token - The token to delete.
614
615  Returns:     Nothing.
616
617 =back
618
619 =cut