Skip to content

Commit

Permalink
Generate and persist a 256 bit session secret by default
Browse files Browse the repository at this point in the history
* Add `urandom_bytes` and `urandom_urlsafe` to `Mojo::Util` for
  generating secure random bits from the system csprng.

* Don't use the hard coded moniker as the default secret

* Generate and store a strong secret if not exists in
  `$ENV{MOJO_HOME}/mojo.secrets`, overridable with
  `$ENV{MOJO_SECRETS_FILE}` when app->secrets is called

* Only load secrets from `mojo.secrets` that are over 22 chars

* Use `urandom_urlsafe` when generating CSRF tokens

* Use `urandom_urlsafe` when in `mojo generate app`

* Add `mojo generate secret`

* Tests:

  - Add misc tests for generating and loading mojo.secrets in
    `t/mojolicious/secret/` and for `mojo generate secret`.

  - Add a default secret in `t/mojolicious/mojo.secrets` so other
    session checks work
  • Loading branch information
stigtsp committed Sep 29, 2024
1 parent ecb44cf commit 6bd013a
Show file tree
Hide file tree
Showing 16 changed files with 278 additions and 26 deletions.
44 changes: 41 additions & 3 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 WIN32_URANDOM => $^O eq 'MSWin32';

# 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,27 @@ sub unquote {
return $str;
}

sub urandom_bytes {
my $num = shift || 32;
my $bytes = chr(0) x $num;

if (WIN32_URANDOM) {
state $RtlGenRandom = Win32::API->new('advapi32', 'INT SystemFunction036 (PVOID RandomBuffer, ULONG RandomBufferLength)')
or croak "SystemFunction036 import failed: $!";
$RtlGenRandom->Call($bytes, $num) or croak "SystemFunction036 call failed: $!";
} else {
open(my $urandom, '<', '/dev/urandom') or croak "Cannot open /dev/urandom: $!";
sysread($urandom, $bytes, $num) == $num or die "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 @@ -540,7 +563,7 @@ Mojo::Util - Portable utility functions
=head1 SYNOPSIS
use Mojo::Util qw(b64_encode url_escape url_unescape);
use Mojo::Util go(b64_encode url_escape url_unescape);
my $str = 'test=23';
my $escaped = url_escape $str;
Expand Down Expand Up @@ -958,6 +981,21 @@ Unindent multi-line string.
Unquote string.
=head2 urandom_bytes
my $bytes = urandom_bytes(32);
Returns random bytes. On Linux and UNIX-like systems /dev/urandom is used as a
source of random data, on Windows RtlGenRandom/SystemFunction036 is used. The
default number of bytes returned is 32 bytes.
=head2 urandom_urlsafe
my $token = urandom_urlsafe;
Generates a base64url encoded string of 32 random bytes suitable for session
tokens and similar.
=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;

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];
};
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;
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 @{[ length($token) ]} byte 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
14 changes: 13 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,16 @@ 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

0 comments on commit 6bd013a

Please sign in to comment.