Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Secure by default sessions #2200

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ jobs:
- name: Install Dependencies
run: |
cpanm --installdeps .
cpanm -n TAP::Formatter::GitHubActions
cpanm -n TAP::Formatter::GitHubActions Crypt::URandom
- name: Run Tests
run: prove --merge --formatter TAP::Formatter::GitHubActions -l t t/mojo t/mojolicious
41 changes: 39 additions & 2 deletions lib/Mojo/Util.pm
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use IO::Compress::Gzip;
use IO::Poll qw(POLLIN POLLPRI);
use IO::Uncompress::Gunzip;
use List::Util qw(min);
use MIME::Base64 qw(decode_base64 encode_base64);
use MIME::Base64 qw(decode_base64 encode_base64 encode_base64url);
use Mojo::BaseUtil qw(class_to_path monkey_patch);
use Pod::Usage qw(pod2usage);
use Socket qw(inet_pton AF_INET6 AF_INET);
Expand All @@ -24,6 +24,8 @@ use Unicode::Normalize ();
# Check for monotonic clock support
use constant MONOTONIC => !!eval { Time::HiRes::clock_gettime(Time::HiRes::CLOCK_MONOTONIC()) };

use constant CRYPT_URANDOM => !!eval { require Crypt::URandom };

# Punycode bootstring parameters
use constant {
PC_BASE => 36,
Expand Down Expand Up @@ -72,7 +74,7 @@ our @EXPORT_OK = (
qw(extract_usage getopt gunzip gzip header_params hmac_sha1_sum html_attr_unescape html_unescape humanize_bytes),
qw(md5_bytes md5_sum monkey_patch network_contains punycode_decode punycode_encode quote scope_guard secure_compare),
qw(sha1_bytes sha1_sum slugify split_cookie_header split_header steady_time tablify term_escape trim unindent),
qw(unquote url_escape url_unescape xml_escape xor_encode)
qw(unquote urandom_bytes urandom_urlsafe url_escape url_unescape xml_escape xor_encode)
);

# Aliases
Expand Down Expand Up @@ -379,6 +381,25 @@ sub unquote {
return $str;
}

sub urandom_bytes {
my $num = shift || 32;

return Crypt::URandom::urandom($num) if CRYPT_URANDOM;

croak 'Cannot find /dev/urandom, install Crypt::URandom from CPAN' unless -e '/dev/urandom';

open(my $urandom, '<', '/dev/urandom') or croak "Cannot open /dev/urandom: $!";
sysread($urandom, my $bytes, $num) == $num or croak "sysread() from /dev/urandom didn't return $num bytes";
close($urandom);

return $bytes;
}

sub urandom_urlsafe {
my $num = shift;
return encode_base64url(urandom_bytes($num));
}

sub url_escape {
my ($str, $pattern) = @_;

Expand Down Expand Up @@ -958,6 +979,22 @@ Unindent multi-line string.

Unquote string.

=head2 urandom_bytes

my $bytes = urandom_bytes;
my $bytes = urandom_bytes 32;

Returns strong random bytes. Uses L<Crypt::URandom> if it is installed, with fallback to /dev/urandom. The default
number of random bytes returned is 32.

=head2 urandom_urlsafe

my $token = urandom_urlsafe;
my $token = urandom_urlsafe 32;

Generates a base64url encoded string of random bytes suitable for session tokens and similar. The default number of random
bytes encoded is 32.

=head2 url_escape

my $escaped = url_escape $str;
Expand Down
40 changes: 30 additions & 10 deletions lib/Mojolicious.pm
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use Mojo::Home;
use Mojo::Loader;
use Mojo::Log;
use Mojo::Server;
use Mojo::Util;
use Mojo::Util qw(urandom_urlsafe);
use Mojo::File qw(path);
use Mojo::UserAgent;
use Mojolicious::Commands;
use Mojolicious::Controller;
Expand Down Expand Up @@ -41,14 +42,31 @@ has plugins => sub { Mojolicious::Plugins->new };
has preload_namespaces => sub { [] };
has renderer => sub { Mojolicious::Renderer->new };
has routes => sub { Mojolicious::Routes->new };
has secrets_file => sub { $ENV{MOJO_SECRETS_FILE} || shift->home->rel_file('mojo.secrets') };
has secrets => sub {
my $self = shift;
my $file = $self->secrets_file;

# Warn developers about insecure default
$self->log->trace('Your secret passphrase needs to be changed (see FAQ for more)');
if (-f $file) {

# Default to moniker
return [$self->moniker];
# Read secrets and filter out those who are less than 22 characters long
# (~128 bits), as they are not likely to be sufficiently strong.
my @secrets = grep { length $_ >= 22 } split /\n/, path($file)->slurp;
Comment on lines +52 to +54
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be a warning message if a candidate secret is skipped? Right now, they're discarded silently.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - or maybe even a fatal error?

Btw, I'm not sure how useful more than one key in app->secrets is. With a strong secret, wouldn't rotation only makes sense if it is compromised/leaked/etc? And then you wouldn't want to continue trusting the old key imho.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is useful so that instances using the session key can be upgraded to use the new secret over even a short period of time without immediately invalidating everything.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, regular secret rotation is good practice even when not found to be compromised.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think only the first secret (the one used to set new session cookies) should be subject to the length restriction, since you might still want to restore sessions cookies that were set with crappy secrets so you can re-issue them with new stronger key...


die qq{"Your secrets_file "$file" does not contain any acceptable secret (of 22 chars or more)} unless @secrets;

return [@secrets];
}

# If no secrets file exists, generate one and attempt to write it back to
# secrets_file, taking care that the file is only readable by the current
# user.
my $secret = urandom_urlsafe;
path($file)->touch->chmod(0600)->spew($secret);

$self->log->trace(qq{Your secret passphrase has been set to strong random value and stored in "$file"});

return [$secret];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I think this is generally a good and necessary step, this will invalidate session cookies in currently insecure apps so that will need to at minimum be noted in the changelog (which is not the responsibility of this PR).

};
has sessions => sub { Mojolicious::Sessions->new };
has static => sub { Mojolicious::Static->new };
Expand Down Expand Up @@ -496,11 +514,13 @@ endpoints for your application.
my $secrets = $app->secrets;
$app = $app->secrets([$bytes]);

Secret passphrases used for signed cookies and the like, defaults to the L</"moniker"> of this application, which is
not very secure, so you should change it!!! As long as you are using the insecure default there will be debug messages
in the log file reminding you to change your passphrase. Only the first passphrase is used to create new signatures,
but all of them for verification. So you can increase security without invalidating all your existing signed cookies by
rotating passphrases, just add new ones to the front and remove old ones from the back.
Secret passphrases used for signed cookies and the like, defaults to 256 bits of data from your systems secure
random number generator and is stored in the file mojo.secrets in your MOJO_HOME directory. You can override
the location of this file by setting MOJO_SECRETS_FILE in your environment.

Only the first passphrase is used to create new signatures, but all of them for verification. So you can
increase security without invalidating all your existing signed cookies by rotating passphrases, just add new
ones to the front and remove old ones from the back.

# Rotate passphrases
$app->secrets(['new_passw0rd', 'old_passw0rd', 'very_old_passw0rd']);
Expand Down
4 changes: 2 additions & 2 deletions lib/Mojolicious/Command/Author/generate/app.pm
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ done_testing();
</p>

@@ config
% use Mojo::Util qw(sha1_sum steady_time);
% use Mojo::Util qw(urandom_urlsafe);
---
secrets:
- <%= sha1_sum $$ . steady_time . rand %>
- <%= urandom_urlsafe %>
82 changes: 82 additions & 0 deletions lib/Mojolicious/Command/Author/generate/secret.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package Mojolicious::Command::Author::generate::secret;
stigtsp marked this conversation as resolved.
Show resolved Hide resolved
use Mojo::Base 'Mojolicious::Command';
use Mojo::File qw(path);
use Mojo::Util qw(urandom_urlsafe);

has description => 'Generate secret';
has usage => sub { shift->extract_usage };

sub run {
my ($self, $secret_file) = (shift, shift);

$secret_file //= $self->app->secrets_file;

my $token = urandom_urlsafe();

print "Writing secret to $secret_file\n";

path($secret_file)->touch->chmod(0600)->spew($token);
}

1;

=encoding utf8

=head1 NAME

Mojolicious::Command::Author::generate::secret - Secret generator command

=head1 SYNOPSIS

Usage: APPLICATION generate secret [PATH]

mojo generate secret
mojo generate secret /path/to/secret

Options:
-h, --help Show this summary of available options

=head1 DESCRIPTION

L<Mojolicious::Command::Author::generate::secret> generates a secret token for protecting session cookies

This is a core command, that means it is always enabled and its code a good example for learning to build new commands,
you're welcome to fork it.

See L<Mojolicious::Commands/"COMMANDS"> for a list of commands that are available by default.

=head1 ATTRIBUTES

L<Mojolicious::Command::Author::generate::secret> inherits all attributes from L<Mojolicious::Command> and implements
the following new ones.

=head2 description

my $description = $app->description;
$app = $app->description('Foo');

Short description of this command, used for the command list.

=head2 usage

my $usage = $app->usage;
$app = $app->usage('Foo');

Usage information for this command, used for the help screen.

=head1 METHODS

L<Mojolicious::Command::Author::generate::secret> inherits all methods from L<Mojolicious::Command> and implements
the following new ones.

=head2 run

$app->run(@ARGV);

Run this command.

=head1 SEE ALSO

L<Mojolicious>, L<Mojolicious::Guides>, L<https://mojolicious.org>.

=cut
4 changes: 2 additions & 2 deletions lib/Mojolicious/Plugin/DefaultHelpers.pm
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use Mojo::Collection;
use Mojo::Exception;
use Mojo::IOLoop;
use Mojo::Promise;
use Mojo::Util qw(dumper hmac_sha1_sum steady_time);
use Mojo::Util qw(dumper urandom_urlsafe);
use Time::HiRes qw(gettimeofday tv_interval);
use Scalar::Util qw(blessed weaken);

Expand Down Expand Up @@ -95,7 +95,7 @@ sub _convert_to_exception {
return (blessed $e && $e->isa('Mojo::Exception')) ? $e : Mojo::Exception->new($e);
}

sub _csrf_token { $_[0]->session->{csrf_token} ||= hmac_sha1_sum($$ . steady_time . rand, $_[0]->app->secrets->[0]) }
sub _csrf_token { $_[0]->session->{csrf_token} ||= urandom_urlsafe; }

sub _current_route {
return '' unless my $route = shift->match->endpoint;
Expand Down
16 changes: 15 additions & 1 deletion t/mojo/util.t
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use Mojo::Util qw(b64_decode b64_encode camelize class_to_file class_to_path dec
qw(extract_usage getopt gunzip gzip header_params hmac_sha1_sum html_unescape html_attr_unescape humanize_bytes),
qw(md5_bytes md5_sum monkey_patch network_contains punycode_decode punycode_encode quote scope_guard secure_compare),
qw(sha1_bytes sha1_sum slugify split_cookie_header split_header steady_time tablify term_escape trim unindent),
qw(unquote url_escape url_unescape xml_escape xor_encode);
qw(unquote urandom_bytes urandom_urlsafe url_escape url_unescape xml_escape xor_encode);

subtest 'camelize' => sub {
is camelize('foo_bar_baz'), 'FooBarBaz', 'right camelized result';
Expand Down Expand Up @@ -661,4 +661,18 @@ subtest 'Hide DATA usage from error messages' => sub {
unlike $@, qr/DATA/, 'DATA has been hidden';
};

subtest 'urandom' => sub {
isnt urandom_bytes, urandom_bytes, "two urandom_bytes invocations are not the same";
is length(urandom_bytes), 32, "urandom_bytes returns 32 bytes by default";
is length(urandom_bytes(16)), 16, "urandom_bytes(16) returns 16 bytes";

isnt urandom_urlsafe, urandom_urlsafe, "two urandom_urlsafe invocations are not the same";
like urandom_urlsafe, qr/^[-A-Za-z0-9_]{43}$/, "urandom_urlsafe returns 43 chars of urlsafe encoded base64";
like urandom_urlsafe(128), qr/^[-A-Za-z0-9_]{171}$/,
"urandom_urlsafe(128) returns 171 chars of urlsafe encoded base64";
like urandom_urlsafe(2048), qr/^[-A-Za-z0-9_]{2731}$/,
"urandom_urlsafe(2048) returns 2731 chars of urlsafe encoded base64";
};


done_testing();
6 changes: 3 additions & 3 deletions t/mojolicious/app.t
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ is_deeply $t->app->commands->namespaces,
['Mojolicious::Command::Author', 'Mojolicious::Command', 'MojoliciousTest::Command'], 'right namespaces';
is $t->app, $t->app->commands->app, 'applications are equal';
is $t->app->static->file('hello.txt')->slurp, "Hello Mojo from a development static file!\n", 'right content';
is $t->app->static->file('does_not_exist.html'), undef, 'no file';
is $t->app->moniker, 'mojolicious_test', 'right moniker';
is $t->app->secrets->[0], $t->app->moniker, 'secret defaults to moniker';
is $t->app->static->file('does_not_exist.html'), undef, 'no file';
is $t->app->moniker, 'mojolicious_test', 'right moniker';
is $t->app->secrets->[0], 'NeverGonnaGiveYouUpNeverGonnaLetYouDown', 'secret defaults to content of mojo.secrets';
is $t->app->renderer->template_handler({template => 'foo/bar/index', format => 'html'}), 'epl', 'right handler';
is $t->app->build_controller->req->url, '', 'no URL';
is $t->app->build_controller->render_to_string('does_not_exist'), undef, 'no result';
Expand Down
35 changes: 35 additions & 0 deletions t/mojolicious/commands.t
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ $buffer = '';
}
like $buffer, qr/Usage: APPLICATION generate lite-app \[OPTIONS\] \[NAME\]/, 'right output';
$buffer = '';
{
open my $handle, '>', \$buffer;
local *STDOUT = $handle;
$commands->run('generate', 'secret', '--help');
}
like $buffer, qr/Usage: APPLICATION generate secret \[PATH\]/, 'right output';
$buffer = '';
{
open my $handle, '>', \$buffer;
local *STDOUT = $handle;
Expand Down Expand Up @@ -474,6 +481,34 @@ $buffer = '';
like $buffer, qr/Unknown option: unknown/, 'right output';
chdir $cwd;

# generate lite_app
require Mojolicious::Command::Author::generate::secret;
$app = Mojolicious::Command::Author::generate::secret->new;
ok $app->description, 'has a description';
like $app->usage, qr/secret/, 'has usage information';
$dir = tempdir CLEANUP => 1;
chdir $dir;
{
my $check = sub {
my $f = path($dir)->child(shift);
ok -f $f, "$f exists";
like $f->slurp, qr/^[-A-Za-z0-9_]{43}$/, "$f contains a urandom_urlsafe generated secret";
};

local $ENV{MOJO_HOME} = $dir;
$app->run;
$check->("mojo.secrets");

local $ENV{MOJO_SECRETS_FILE} = path($dir)->child("from-env-var.secrets");
$app = Mojolicious::Command::Author::generate::secret->new;
$app->run;
$check->("from-env-var.secrets");

$app->run("from-args.secrets");
$check->("from-args.secrets");
}
chdir $cwd;

# inflate
require Mojolicious::Command::Author::inflate;
my $inflate = Mojolicious::Command::Author::inflate->new;
Expand Down
8 changes: 3 additions & 5 deletions t/mojolicious/lite_app.t
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@ app->defaults(default => 23);

# Secret
app->log->level('trace')->unsubscribe('message');
my $logs = app->log->capture('trace');
is app->secrets->[0], app->moniker, 'secret defaults to moniker';
like $logs, qr/Your secret passphrase needs to be changed/, 'right message';
undef $logs;

is app->secrets->[0], 'NeverGonnaGiveYouUpNeverGonnaLetYouDown', 'secret defaults to content of mojo.secrets';

# Test helpers
helper test_helper => sub { shift->param(@_) };
Expand Down Expand Up @@ -751,7 +749,7 @@ $t->get_ok('/to_string')->status_is(200)->content_is('beforeafter');
$t->get_ok('/source')->status_is(200)->content_type_is('application/octet-stream')->content_like(qr!get_ok\('/source!);

# File does not exist
$logs = app->log->capture('trace');
my $logs = app->log->capture('trace');
$t->get_ok('/source?fail=1')->status_is(500)->content_like(qr/Static file "does_not_exist.txt" not found/);
like $logs, qr/Static file "does_not_exist.txt" not found/, 'right message';
undef $logs;
Expand Down
1 change: 1 addition & 0 deletions t/mojolicious/mojo.secrets
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NeverGonnaGiveYouUpNeverGonnaLetYouDown
3 changes: 3 additions & 0 deletions t/mojolicious/secret/custom.secrets
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
NeverGonnaMakeYouCryNeverGonnaSayGoodbye
skip-me
NeverGonnaTellALieAndHurtYou
16 changes: 16 additions & 0 deletions t/mojolicious/secret/lite-create.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use Mojo::Base -strict;

use Mojo::File qw(tempdir path);
use Test::Mojo;
use Test::More;
use Mojolicious::Lite;


my $tmpdir = tempdir;
my $file = $tmpdir->child("mojo.secrets");
$ENV{MOJO_SECRETS_FILE} = $file;

like app->secrets->[0], qr/^[-A-Za-z0-9_]{43}$/, 'secret was generated, and matches expected urandom_urlsafe format';
is app->secrets->[0], $file->slurp, 'secret stored at $ENV{MOJO_SECRETS_FILE} is the same as app->secrets->[0]';

done_testing();
Loading
Loading