CVE-2021-47208 (Other, published 2024-04-08T00:15Z)
Description: value: The Mojolicious module before 9.11 for Perl has a bug in format detection that can potentially be exploited for denial of service.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/mojolicious/mojo
Files changed:
Changes
lib/Mojolicious/Guides/Routing.pod
lib/Mojolicious/Guides/Tutorial.pod
lib/Mojolicious/Lite.pm
lib/Mojolicious/Routes/Match.pm
lib/Mojolicious/Routes/Pattern.pm
lib/Mojolicious/Routes/Route.pm
t/mojolicious/charset_lite_app.t
t/mojolicious/exception_lite_app.t
t/mojolicious/group_lite_app.t
t/mojolicious/layouted_lite_app.t
t/mojolicious/lib/MojoliciousTest.pm
t/mojolicious/lite_app.t
t/mojolicious/pattern.t
t/mojolicious/restful_lite_app.t
t/mojolicious/routes.t
t/mojolicious/twinkle_lite_app.t
Commit message: Flip format detection to fix a security issue (closes #1736)


Vulnerable code:
9.11  2021-03-18
File extensions like C<.html> and C<.txt> at the end of a route are automatically detected and stored in the stash
value C<format>.

  # /foo      -> {controller => 'foo', action => 'bar'}
  # /foo.html -> {controller => 'foo', action => 'bar', format => 'html'}
  # /foo.txt  -> {controller => 'foo', action => 'bar', format => 'txt'}
  $r->get('/foo')->to('foo#bar');

This for example, allows multiple templates in different formats to share the same action code. Restrictive
placeholders can also be used to limit the allowed formats.
Or you can just disable format detection with a special type of restrictive placeholder, which gets inherited by nested
routes, and then re-enable it on demand.

  # /foo      -> {controller => 'foo', action => 'bar'}
  # /foo.html -> undef
  $r->get('/foo' => [format => 0])->to('foo#bar');

  # /foo      -> {controller => 'foo', action => 'bar'}
  # /foo.html -> undef
  # /baz      -> undef
  # /baz.txt  -> {controller => 'baz', action => 'yada', format => 'txt'}
  # /baz.html -> {controller => 'baz', action => 'yada', format => 'html'}
  # /baz.xml  -> undef
  my $inactive = $r->under([format => 0]);
  $inactive->get('/foo')->to('foo#bar');
  $inactive->get('/baz' => [format => ['txt', 'html']])->to('baz#yada');

generate the correct C<Content-Type> header.
  # /detection
  get '/detection' => sub ($c) {
The default format is C<html>, and restrictive placeholders can be used to limit possible values.
  get '/hello' => [format => ['json', 'txt']] => sub ($c) {
    return $c->render(json => {hello => 'world'})
      if $c->stash('format') eq 'json';
Or you can just disable format detection with a special type of restrictive placeholder.

  use Mojolicious::Lite;

  # /hello
  get '/hello' => [format => 0] => {text => 'No format detection.'};

  # Disable detection and allow the following routes to re-enable it on demand
  under [format => 0];

  # /foo
  get '/foo' => {text => 'No format detection again.'};

  # /bar.txt
  get '/bar' => [format => 'txt'] => {text => ' Just one format.'};

  app->start;
  get '/hello' => sub ($c) {
  my $route = under [format => 0];
  my $route;
  if (!$name || $name eq 'current') { return {} unless $route = $self->endpoint }
  my $captures = $self->stack->[-1] // {};
  %values = (%$captures, format => undef, %values);
  my $pattern = $route->pattern;
  $values{format} //= defined $captures->{format} ? $captures->{format} : $pattern->defaults->{format}
    if $pattern->constraints->{format};

  my $path = $route->render(\%values);
  $regex .= _compile_format($constraints->{format}, $defaults->{format}) if $detect;
  my ($format, $default) = @_;

  # Default regex
  return '/?(?:\.([^/]+))?$' unless defined $format;
  return $default ? "/?(?:$regex)?\$" : "/?$regex\$";
  my $pattern = Mojolicious::Routes::Pattern->new(format => 0);
  $pattern = $pattern->parse(format => 0);
  $route->pattern->constraints->{format} //= 0 if defined $format && !$format;
  $r = $r->parse(format => 0);
  my $route = $r->under([format => 0]);
get '/' => 'index';
get '/dead_template_too';
get '/dead_handler' => {handler => 'dead'};
get '/dead_action_epl' => {handler => 'epl'} => sub {
get '/dead_action' => sub { die "dead action!\n" };
get '/missing_template' => {exception => 'whatever'};
  get '/authgroup' => {text => "You're ok."};
get '/variants' => {layout => 'variants'} => sub {
  $r->any('/withblock')->to('foo#withBlock');
get '/' => 'root';
get '/without-format' => 'without-format';
get '/root' => sub { shift->render(text => 'root fallback!') };
get '/template.txt' => {template => 'template', format => 'txt'};
any [qw(POST PUT)] => '/json/echo' => sub {
get '/accepts' => sub {
get '/wants_json' => sub {
get sub {
post sub {
$r->any('/optional/:foo/:bar')->to(bar => 'test');
$r->any('/optional2/:foo')->to(foo => 'one')->any('/:bar')->to(bar => 'two');
# /format.html
$r->any('/format')->to(testcase => 'hello')->to(action => 'you', format => 'html');
$inline->any('/delete')->to(testcase => 'articles', action => 'delete', format => undef)->name('articles_delete');
$r->any('/method/get')->methods('GET')->to(testcase => 'method', action => 'get');
$versioned->any('/1.0')->to(testcase => 'bar')->any('/test')->to(action => 'baz');
$versioned->any('/2.4')->to(testcase => 'foo')->any('/test')->to(action => 'bar');
my $inactive = $r->any('/', [format => 0]);
$source->any('/second')->to('#second');
my $third  = $source->any('/third')->to('#third');
$r->any('/slash')->to(testcase => 'just')->any('/')->to(action => 'slash');
is $r->find('nodetect')->pattern->constraints->{format}, 0, 'right value';
is $m->path_for->{path}, '/articles/1/delete', 'right path';
is $m->path_for->{path}, '/method/get', 'right path';
is_deeply $m->stack, [{testcase => 'bar', action => 'baz'}], 'right structure';
is $m->path_for->{path}, '/versioned/1.0/test', 'right path';
is_deeply $m->stack, [{testcase => 'foo', action => 'bar'}], 'right structure';
is $m->path_for->{path}, '/versioned/2.4/test', 'right path';
is_deeply $m->stack, [{controller => 'target', action => 'second'}], 'right structure';
is $m->path_for->{path}, '/target/second', 'right path';
is_deeply $m->stack, [{controller => 'source', action => 'third'}], 'right structure';
is $m->path_for->{path}, '/source/third', 'right path';
is $m->path_for->{path}, '/slash', 'right path';
get '/rest' => sub {

Fixed code:
9.11  2021-03-20
  - This release contains fixes for security issues, everybody should upgrade!
  - Disabled format detection by default to fix a vulnerability in many Mojolicious applications. From now on you have
    to explicitly select the formats your routes are allowed to handle.
      # /foo
      # /foo.html
      # /foo.json
      $r->get('/foo')->to('bar#yada');
    becomes
      $r->get('/foo' => [format => ['html', 'json']])->to('bar#yada', format => undef);
    And if you're certain your application is not vulnerable, you can also re-enable format detection, which will be
    inherited by nested routes.
      my $active = $r->any('/' => [format => 1]);
      $active->get('/foo')->to('Test#first');
      $active->put('/bar')->to('Test#second');
File extensions like C<.html> and C<.txt> at the end of a route can be detected and stored in the stash value C<format>.
Use a restrictive placeholder to declare the possible values.
This for example, allows multiple templates in different formats to share the same action code. And just like with
placeholders you can use a default value to make the format optional.

  # /foo      -> {controller => 'foo', action => 'bar'}
  # /foo.html -> {controller => 'foo', action => 'bar', format => 'html'}
  # /foo.txt  -> {controller => 'foo', action => 'bar', format => 'txt'}
  $r->get('/foo' => [format => ['html', 'txt']])->to('foo#bar', format => undef);

generate the correct C<Content-Type> header. Use a restrictive placeholder to declare the possible values.
  get '/detection' => [format => ['html', 'txt']] => sub ($c) {
And just like with placeholders you can use a default value to make the format optional.
  # /hello
  get '/hello' => [format => ['json', 'txt']] => {format => 'txt'} => sub ($c) {
    return $c->render(json => {hello => 'world'}) if $c->stash('format') eq 'json';
The default format is C<html> and the renderer will fall back to when necessary.
  get '/hello' => [format => ['json', 'xml']] => {format => undef} => sub ($c) {
  my $route = under [format => ['json', 'yaml']];
  my ($route, $current);
  if (!$name || $name eq 'current') {
    return {} unless $route = $self->endpoint;
    $current = 1;
  }
  my $captures    = $self->stack->[-1] // {};
  my %merged      = (%$captures, format => undef, %values);
  my $pattern     = $route->pattern;
  my $constraints = $pattern->constraints;
  $merged{format} = ($current ? $captures->{format} : undef) // $pattern->defaults->{format}
    if !exists $values{format} && $constraints->{format} && $constraints->{format} ne '1';

  my $path = $route->render(\%merged);
  $regex .= _compile_format($constraints->{format}, exists $defaults->{format}) if $detect;
  my ($format, $has_default) = @_;
  # Default regex
  return '/?(?:\.([^/]+))?$' if $format eq '1';

  return $has_default ? "/?(?:$regex)?\$" : "/?$regex\$";
  my $pattern = Mojolicious::Routes::Pattern->new(format => ['json', 'yaml']);
  $pattern = $pattern->parse(format => ['json', 'yaml']);
  $route->pattern->constraints->{format} //= 1 if defined $format && $format eq '1';
  $r = $r->parse(format => ['json', 'yaml']);
  my $route = $r->under([format => ['json', 'yaml']]);
get '/' => [format => [$yatta]] => {format => undef} => 'index';
get '/dead_template_too' => [format => ['xml']];
get '/dead_handler' => [format => ['xml']] => {handler => 'dead'};
get '/dead_action_epl' => [format => ['xml']] => {handler => 'epl'} => sub {
get '/dead_action' => [format => ['xml', 'json']] => {format => undef} => sub { die "dead action!\n" };
get '/missing_template' => [format => ['xml', 'json', 'txt']] => {exception => 'whatever', format => undef};
  get '/authgroup' => [format => ['txt']] => {text => "You're ok.", format => undef};
get '/variants' => [format => ['txt']] => {layout => 'variants', format => undef} => sub {
  $r->any('/withblock', [format => ['txt']])->to('foo#withBlock');
get '/' => [format => ['html']] => {format => undef} => 'root';
get '/without-format' => [format => ['html']] => {format => undef} => 'without-format';
get '/root' => [format => ['txt']] => {format => undef} => sub { shift->render(text => 'root fallback!') };
get '/template.txt' => [format => ['epl']] => {template => 'template', format => 'txt'};
$pattern->constraints({format => 1});

# Versioned pattern with format
$pattern = Mojolicious::Routes::Pattern->new('/:test/v1.0');
$pattern->defaults({action => 'index', format => 'html'});
$pattern->constraints({format => ['txt']});
any [qw(POST PUT)] => '/json/echo' => [format => ['json']] => {format => undef} => sub {
get '/accepts' => [format => ['html', 'json', 'txt', 'xml']] => {format => undef} => sub {
get '/wants_json' => [format => ['json', 'xml']] => {format => undef} => sub {
get [format => ['json', 'html', 'xml']] => {format => undef} => sub {
post [format => ['json', 'html', 'xml', 'png']] => {format => undef} => sub {
$r->any('/optional/:foo/:bar', [format => 1])->to(bar => 'test');
$r->any('/optional2/:foo', [format => 1])->to(foo => 'one')->any('/:bar')->to(bar => 'two');
# /format.*
$r->any('/format', [format => 1])->to(testcase => 'hello')->to(action => 'you', format => 'html');
# /articles/1/delete.json
$inline->any('/delete', [format => ['json']])->to(testcase => 'articles', action => 'delete', format => undef)
  ->name('articles_delete');
$r->any('/method/get', [format => ['html']])->methods('GET')
  ->to(testcase => 'method', action => 'get', format => undef);
$versioned->any('/1.0')->to(testcase => 'bar')->any('/test', [format => ['xml']])->to(action => 'baz', format => undef);
$versioned->any('/2.4')->to(testcase => 'foo')->any('/test', [format => ['xml']])->to(action => 'bar', format => undef);
my $inactive = $r->any('/');
$source->any('/second', [format => ['xml']])->to('#second', format => undef);
my $third  = $source->any('/third', [format => ['xml']])->to('#third', format => undef);
# /slash.txt
$r->any('/slash')->to(testcase => 'just')->any('/', [format => ['txt']])->to(action => 'slash', format => undef);
ok !$r->find('nodetect')->pattern->constraints->{format}, 'no value';
is $m->path_for->{path}, '/articles/1/delete.json', 'right path';
is $m->path_for->{path}, '/method/get.html', 'right path';
is_deeply $m->stack, [{testcase => 'bar', action => 'baz', format => undef}], 'right structure';
is $m->path_for->{path}, '/versioned/1.0/test.xml', 'right path';
is_deeply $m->stack, [{testcase => 'foo', action => 'bar', format => undef}], 'right structure';
is $m->path_for->{path}, '/versioned/2.4/test.xml', 'right path';
is_deeply $m->stack, [{controller => 'target', action => 'second', format => undef}], 'right structure';
is $m->path_for->{path}, '/target/second.xml', 'right path';
is_deeply $m->stack, [{controller => 'source', action => 'third', format => undef}], 'right structure';
is $m->path_for->{path}, '/source/third.xml', 'right path';
is $m->path_for->{path}, '/slash.txt', 'right path';
get '/rest' => [format => 'html'] => {format => undef} => sub {