Skip to content

Commit fb4ae3f

Browse files
committed
Generate and persist a 256 bit session secret by default
* Add `urandom_bytes` and `urandom_urlsafe` to `Mojo::Util` for generating secure random bits from either Crypt::Random or /dev/urandom * 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 * Install Crypt::URandom in GH Windows workflow so urandom_bytes works on that platform
1 parent ecb44cf commit fb4ae3f

File tree

17 files changed

+278
-26
lines changed

17 files changed

+278
-26
lines changed

.github/workflows/windows.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ jobs:
2424
- name: Install Dependencies
2525
run: |
2626
cpanm --installdeps .
27-
cpanm -n TAP::Formatter::GitHubActions
27+
cpanm -n TAP::Formatter::GitHubActions Crypt::URandom
2828
- name: Run Tests
2929
run: prove --merge --formatter TAP::Formatter::GitHubActions -l t t/mojo t/mojolicious

lib/Mojo/Util.pm

+39-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use IO::Compress::Gzip;
1313
use IO::Poll qw(POLLIN POLLPRI);
1414
use IO::Uncompress::Gunzip;
1515
use List::Util qw(min);
16-
use MIME::Base64 qw(decode_base64 encode_base64);
16+
use MIME::Base64 qw(decode_base64 encode_base64 encode_base64url);
1717
use Mojo::BaseUtil qw(class_to_path monkey_patch);
1818
use Pod::Usage qw(pod2usage);
1919
use Socket qw(inet_pton AF_INET6 AF_INET);
@@ -24,6 +24,8 @@ use Unicode::Normalize ();
2424
# Check for monotonic clock support
2525
use constant MONOTONIC => !!eval { Time::HiRes::clock_gettime(Time::HiRes::CLOCK_MONOTONIC()) };
2626

27+
use constant CRYPT_URANDOM => !!eval { require Crypt::URandom };
28+
2729
# Punycode bootstring parameters
2830
use constant {
2931
PC_BASE => 36,
@@ -72,7 +74,7 @@ our @EXPORT_OK = (
7274
qw(extract_usage getopt gunzip gzip header_params hmac_sha1_sum html_attr_unescape html_unescape humanize_bytes),
7375
qw(md5_bytes md5_sum monkey_patch network_contains punycode_decode punycode_encode quote scope_guard secure_compare),
7476
qw(sha1_bytes sha1_sum slugify split_cookie_header split_header steady_time tablify term_escape trim unindent),
75-
qw(unquote url_escape url_unescape xml_escape xor_encode)
77+
qw(unquote urandom_bytes urandom_urlsafe url_escape url_unescape xml_escape xor_encode)
7678
);
7779

7880
# Aliases
@@ -379,6 +381,25 @@ sub unquote {
379381
return $str;
380382
}
381383

384+
sub urandom_bytes {
385+
my $num = shift || 32;
386+
387+
return Crypt::URandom::urandom($num) if CRYPT_URANDOM;
388+
389+
croak 'Cannot find /dev/urandom, install Crypt::URandom from CPAN' unless -e '/dev/urandom';
390+
391+
open(my $urandom, '<', '/dev/urandom') or croak "Cannot open /dev/urandom: $!";
392+
sysread($urandom, my $bytes, $num) == $num or croak "sysread() from /dev/urandom didn't return $num bytes";
393+
close($urandom);
394+
395+
return $bytes;
396+
}
397+
398+
sub urandom_urlsafe {
399+
my $num = shift;
400+
return encode_base64url(urandom_bytes($num));
401+
}
402+
382403
sub url_escape {
383404
my ($str, $pattern) = @_;
384405

@@ -958,6 +979,22 @@ Unindent multi-line string.
958979
959980
Unquote string.
960981
982+
=head2 urandom_bytes
983+
984+
my $bytes = urandom_bytes;
985+
my $bytes = urandom_bytes 32;
986+
987+
Returns strong random bytes. Uses L<Crypt::URandom> if it is installed, with fallback to /dev/urandom. The default
988+
number of random bytes returned is 32.
989+
990+
=head2 urandom_urlsafe
991+
992+
my $token = urandom_urlsafe;
993+
my $token = urandom_urlsafe 32;
994+
995+
Generates a base64url encoded string of random bytes suitable for session tokens and similar. The default number of random
996+
bytes encoded is 32.
997+
961998
=head2 url_escape
962999
9631000
my $escaped = url_escape $str;

lib/Mojolicious.pm

+30-10
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use Mojo::Home;
99
use Mojo::Loader;
1010
use Mojo::Log;
1111
use Mojo::Server;
12-
use Mojo::Util;
12+
use Mojo::Util qw(urandom_urlsafe);
13+
use Mojo::File qw(path);
1314
use Mojo::UserAgent;
1415
use Mojolicious::Commands;
1516
use Mojolicious::Controller;
@@ -41,14 +42,31 @@ has plugins => sub { Mojolicious::Plugins->new };
4142
has preload_namespaces => sub { [] };
4243
has renderer => sub { Mojolicious::Renderer->new };
4344
has routes => sub { Mojolicious::Routes->new };
45+
has secrets_file => sub { $ENV{MOJO_SECRETS_FILE} || shift->home->rel_file('mojo.secrets') };
4446
has secrets => sub {
4547
my $self = shift;
48+
my $file = $self->secrets_file;
4649

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

50-
# Default to moniker
51-
return [$self->moniker];
52+
# Read secrets and filter out those who are less than 22 characters long
53+
# (~128 bits), as they are not likely to be sufficiently strong.
54+
my @secrets = grep { length $_ >= 22 } split /\n/, path($file)->slurp;
55+
56+
die qq{"Your secrets_file "$file" does not contain any acceptable secret (of 22 chars or more)} unless @secrets;
57+
58+
return [@secrets];
59+
}
60+
61+
# If no secrets file exists, generate one and attempt to write it back to
62+
# secrets_file, taking care that the file is only readable by the current
63+
# user.
64+
my $secret = urandom_urlsafe;
65+
path($file)->touch->chmod(0600)->spew($secret);
66+
67+
$self->log->trace(qq{Your secret passphrase has been set to strong random value and stored in "$file"});
68+
69+
return [$secret];
5270
};
5371
has sessions => sub { Mojolicious::Sessions->new };
5472
has static => sub { Mojolicious::Static->new };
@@ -496,11 +514,13 @@ endpoints for your application.
496514
my $secrets = $app->secrets;
497515
$app = $app->secrets([$bytes]);
498516
499-
Secret passphrases used for signed cookies and the like, defaults to the L</"moniker"> of this application, which is
500-
not very secure, so you should change it!!! As long as you are using the insecure default there will be debug messages
501-
in the log file reminding you to change your passphrase. Only the first passphrase is used to create new signatures,
502-
but all of them for verification. So you can increase security without invalidating all your existing signed cookies by
503-
rotating passphrases, just add new ones to the front and remove old ones from the back.
517+
Secret passphrases used for signed cookies and the like, defaults to 256 bits of data from your systems secure
518+
random number generator and is stored in the file mojo.secrets in your MOJO_HOME directory. You can override
519+
the location of this file by setting MOJO_SECRETS_FILE in your environment.
520+
521+
Only the first passphrase is used to create new signatures, but all of them for verification. So you can
522+
increase security without invalidating all your existing signed cookies by rotating passphrases, just add new
523+
ones to the front and remove old ones from the back.
504524
505525
# Rotate passphrases
506526
$app->secrets(['new_passw0rd', 'old_passw0rd', 'very_old_passw0rd']);

lib/Mojolicious/Command/Author/generate/app.pm

+2-2
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ done_testing();
196196
</p>
197197
198198
@@ config
199-
% use Mojo::Util qw(sha1_sum steady_time);
199+
% use Mojo::Util qw(urandom_urlsafe);
200200
---
201201
secrets:
202-
- <%= sha1_sum $$ . steady_time . rand %>
202+
- <%= urandom_urlsafe %>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package Mojolicious::Command::Author::generate::secret;
2+
use Mojo::Base 'Mojolicious::Command';
3+
use Mojo::File qw(path);
4+
use Mojo::Util qw(urandom_urlsafe);
5+
6+
has description => 'Generate secret';
7+
has usage => sub { shift->extract_usage };
8+
9+
sub run {
10+
my ($self, $secret_file) = (shift, shift);
11+
12+
$secret_file //= $self->app->secrets_file;
13+
14+
my $token = urandom_urlsafe();
15+
16+
print "Writing secret to $secret_file\n";
17+
18+
path($secret_file)->touch->chmod(0600)->spew($token);
19+
}
20+
21+
1;
22+
23+
=encoding utf8
24+
25+
=head1 NAME
26+
27+
Mojolicious::Command::Author::generate::secret - Secret generator command
28+
29+
=head1 SYNOPSIS
30+
31+
Usage: APPLICATION generate secret [PATH]
32+
33+
mojo generate secret
34+
mojo generate secret /path/to/secret
35+
36+
Options:
37+
-h, --help Show this summary of available options
38+
39+
=head1 DESCRIPTION
40+
41+
L<Mojolicious::Command::Author::generate::secret> generates a secret token for protecting session cookies
42+
43+
This is a core command, that means it is always enabled and its code a good example for learning to build new commands,
44+
you're welcome to fork it.
45+
46+
See L<Mojolicious::Commands/"COMMANDS"> for a list of commands that are available by default.
47+
48+
=head1 ATTRIBUTES
49+
50+
L<Mojolicious::Command::Author::generate::secret> inherits all attributes from L<Mojolicious::Command> and implements
51+
the following new ones.
52+
53+
=head2 description
54+
55+
my $description = $app->description;
56+
$app = $app->description('Foo');
57+
58+
Short description of this command, used for the command list.
59+
60+
=head2 usage
61+
62+
my $usage = $app->usage;
63+
$app = $app->usage('Foo');
64+
65+
Usage information for this command, used for the help screen.
66+
67+
=head1 METHODS
68+
69+
L<Mojolicious::Command::Author::generate::secret> inherits all methods from L<Mojolicious::Command> and implements
70+
the following new ones.
71+
72+
=head2 run
73+
74+
$app->run(@ARGV);
75+
76+
Run this command.
77+
78+
=head1 SEE ALSO
79+
80+
L<Mojolicious>, L<Mojolicious::Guides>, L<https://mojolicious.org>.
81+
82+
=cut

lib/Mojolicious/Plugin/DefaultHelpers.pm

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use Mojo::Collection;
88
use Mojo::Exception;
99
use Mojo::IOLoop;
1010
use Mojo::Promise;
11-
use Mojo::Util qw(dumper hmac_sha1_sum steady_time);
11+
use Mojo::Util qw(dumper urandom_urlsafe);
1212
use Time::HiRes qw(gettimeofday tv_interval);
1313
use Scalar::Util qw(blessed weaken);
1414

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

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

100100
sub _current_route {
101101
return '' unless my $route = shift->match->endpoint;

t/mojo/util.t

+15-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use Mojo::Util qw(b64_decode b64_encode camelize class_to_file class_to_path dec
1212
qw(extract_usage getopt gunzip gzip header_params hmac_sha1_sum html_unescape html_attr_unescape humanize_bytes),
1313
qw(md5_bytes md5_sum monkey_patch network_contains punycode_decode punycode_encode quote scope_guard secure_compare),
1414
qw(sha1_bytes sha1_sum slugify split_cookie_header split_header steady_time tablify term_escape trim unindent),
15-
qw(unquote url_escape url_unescape xml_escape xor_encode);
15+
qw(unquote urandom_bytes urandom_urlsafe url_escape url_unescape xml_escape xor_encode);
1616

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

664+
subtest 'urandom' => sub {
665+
isnt urandom_bytes, urandom_bytes, "two urandom_bytes invocations are not the same";
666+
is length(urandom_bytes), 32, "urandom_bytes returns 32 bytes by default";
667+
is length(urandom_bytes(16)), 16, "urandom_bytes(16) returns 16 bytes";
668+
669+
isnt urandom_urlsafe, urandom_urlsafe, "two urandom_urlsafe invocations are not the same";
670+
like urandom_urlsafe, qr/^[-A-Za-z0-9_]{43}$/, "urandom_urlsafe returns 43 chars of urlsafe encoded base64";
671+
like urandom_urlsafe(128), qr/^[-A-Za-z0-9_]{171}$/,
672+
"urandom_urlsafe(128) returns 171 chars of urlsafe encoded base64";
673+
like urandom_urlsafe(2048), qr/^[-A-Za-z0-9_]{2731}$/,
674+
"urandom_urlsafe(2048) returns 2731 chars of urlsafe encoded base64";
675+
};
676+
677+
664678
done_testing();

t/mojolicious/app.t

+3-3
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,9 @@ is_deeply $t->app->commands->namespaces,
9090
['Mojolicious::Command::Author', 'Mojolicious::Command', 'MojoliciousTest::Command'], 'right namespaces';
9191
is $t->app, $t->app->commands->app, 'applications are equal';
9292
is $t->app->static->file('hello.txt')->slurp, "Hello Mojo from a development static file!\n", 'right content';
93-
is $t->app->static->file('does_not_exist.html'), undef, 'no file';
94-
is $t->app->moniker, 'mojolicious_test', 'right moniker';
95-
is $t->app->secrets->[0], $t->app->moniker, 'secret defaults to moniker';
93+
is $t->app->static->file('does_not_exist.html'), undef, 'no file';
94+
is $t->app->moniker, 'mojolicious_test', 'right moniker';
95+
is $t->app->secrets->[0], 'NeverGonnaGiveYouUpNeverGonnaLetYouDown', 'secret defaults to content of mojo.secrets';
9696
is $t->app->renderer->template_handler({template => 'foo/bar/index', format => 'html'}), 'epl', 'right handler';
9797
is $t->app->build_controller->req->url, '', 'no URL';
9898
is $t->app->build_controller->render_to_string('does_not_exist'), undef, 'no result';

t/mojolicious/commands.t

+35
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,13 @@ $buffer = '';
155155
}
156156
like $buffer, qr/Usage: APPLICATION generate lite-app \[OPTIONS\] \[NAME\]/, 'right output';
157157
$buffer = '';
158+
{
159+
open my $handle, '>', \$buffer;
160+
local *STDOUT = $handle;
161+
$commands->run('generate', 'secret', '--help');
162+
}
163+
like $buffer, qr/Usage: APPLICATION generate secret \[PATH\]/, 'right output';
164+
$buffer = '';
158165
{
159166
open my $handle, '>', \$buffer;
160167
local *STDOUT = $handle;
@@ -474,6 +481,34 @@ $buffer = '';
474481
like $buffer, qr/Unknown option: unknown/, 'right output';
475482
chdir $cwd;
476483

484+
# generate lite_app
485+
require Mojolicious::Command::Author::generate::secret;
486+
$app = Mojolicious::Command::Author::generate::secret->new;
487+
ok $app->description, 'has a description';
488+
like $app->usage, qr/secret/, 'has usage information';
489+
$dir = tempdir CLEANUP => 1;
490+
chdir $dir;
491+
{
492+
my $check = sub {
493+
my $f = path($dir)->child(shift);
494+
ok -f $f, "$f exists";
495+
like $f->slurp, qr/^[-A-Za-z0-9_]{43}$/, "$f contains a urandom_urlsafe generated secret";
496+
};
497+
498+
local $ENV{MOJO_HOME} = $dir;
499+
$app->run;
500+
$check->("mojo.secrets");
501+
502+
local $ENV{MOJO_SECRETS_FILE} = path($dir)->child("from-env-var.secrets");
503+
$app = Mojolicious::Command::Author::generate::secret->new;
504+
$app->run;
505+
$check->("from-env-var.secrets");
506+
507+
$app->run("from-args.secrets");
508+
$check->("from-args.secrets");
509+
}
510+
chdir $cwd;
511+
477512
# inflate
478513
require Mojolicious::Command::Author::inflate;
479514
my $inflate = Mojolicious::Command::Author::inflate->new;

t/mojolicious/lite_app.t

+3-5
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,8 @@ app->defaults(default => 23);
2222

2323
# Secret
2424
app->log->level('trace')->unsubscribe('message');
25-
my $logs = app->log->capture('trace');
26-
is app->secrets->[0], app->moniker, 'secret defaults to moniker';
27-
like $logs, qr/Your secret passphrase needs to be changed/, 'right message';
28-
undef $logs;
25+
26+
is app->secrets->[0], 'NeverGonnaGiveYouUpNeverGonnaLetYouDown', 'secret defaults to content of mojo.secrets';
2927

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

753751
# File does not exist
754-
$logs = app->log->capture('trace');
752+
my $logs = app->log->capture('trace');
755753
$t->get_ok('/source?fail=1')->status_is(500)->content_like(qr/Static file "does_not_exist.txt" not found/);
756754
like $logs, qr/Static file "does_not_exist.txt" not found/, 'right message';
757755
undef $logs;

t/mojolicious/mojo.secrets

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
NeverGonnaGiveYouUpNeverGonnaLetYouDown

t/mojolicious/secret/custom.secrets

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
NeverGonnaMakeYouCryNeverGonnaSayGoodbye
2+
skip-me
3+
NeverGonnaTellALieAndHurtYou

t/mojolicious/secret/lite-create.t

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use Mojo::Base -strict;
2+
3+
use Mojo::File qw(tempdir path);
4+
use Test::Mojo;
5+
use Test::More;
6+
use Mojolicious::Lite;
7+
8+
9+
my $tmpdir = tempdir;
10+
my $file = $tmpdir->child("mojo.secrets");
11+
$ENV{MOJO_SECRETS_FILE} = $file;
12+
13+
like app->secrets->[0], qr/^[-A-Za-z0-9_]{43}$/, 'secret was generated, and matches expected urandom_urlsafe format';
14+
is app->secrets->[0], $file->slurp, 'secret stored at $ENV{MOJO_SECRETS_FILE} is the same as app->secrets->[0]';
15+
16+
done_testing();

0 commit comments

Comments
 (0)