CVE-2021-47208: Insufficient Information (NVD-CWE-noinfo)
value: The Mojolicious module before 9.11 for Perl has a bug in format detection that can potentially be exploited for denial of service.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-04-08T00:15Z
Repository: https://github.com/mojolicious/mojo
Language: Other
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 date: 2021-03-20 02:42:30 +0100
Commit message: Flip format detection to fix a security issue (closes #1736)

Diff: diff --git a/Changes b/Changes
index fd8affeae..cddb1b18a 100644
--- a/Changes
+++ b/Changes
@@ -1,7 +1,21 @@
 
-9.11  2021-03-18
+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');
   - Improved Mojo::UserAgent to include "Content-Length: 0" with non-GET requests for better compatibility with broken
     web servers.
 
 9.10  2021-03-14
   - Fixed top-level --help and -h options in Mojolicious::Commands.
diff --git a/lib/Mojolicious/Guides/Routing.pod b/lib/Mojolicious/Guides/Routing.pod
index d7ac8be89..e81a76612 100644
--- a/lib/Mojolicious/Guides/Routing.pod
+++ b/lib/Mojolicious/Guides/Routing.pod
@@ -550,51 +550,34 @@ L<Mojolicious::Controller/"match">.
   # Action of the fourth dispatch cycle
   my $action = $c->match->stack->[3]{action};
 
 =head2 Formats
 
-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.
+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.
 
   # /foo.txt -> undef
   # /foo.rss -> {controller => 'foo', action => 'bar', format => 'rss'}
   # /foo.xml -> {controller => 'foo', action => 'bar', format => 'xml'}
   $r->get('/foo' => [format => ['rss', 'xml']])->to('foo#bar');
 
+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);
+
 A C<format> value can also be passed to L<Mojolicious::Controller/"url_for">.
 
   # /foo/23.txt -> {controller => 'foo', action => 'bar', id => 23, format => 'txt'}
   $r->get('/foo/:id')->to('foo#bar')->name('baz');
 
   # Generate URL "/foo/24.txt" for route "baz"
   my $url = $c->url_for('baz', id => 24, format => 'txt');
 
-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');
-
 =head2 WebSockets
 
 With the method L<Mojolicious::Routes::Route/"websocket"> you can restrict access to WebSocket handshakes, which are
 normal C<GET> requests with some additional information.
 
diff --git a/lib/Mojolicious/Guides/Tutorial.pod b/lib/Mojolicious/Guides/Tutorial.pod
index 29632962e..26e34329a 100644
--- a/lib/Mojolicious/Guides/Tutorial.pod
+++ b/lib/Mojolicious/Guides/Tutorial.pod
@@ -562,18 +562,17 @@ L<Mojolicious::Lite/"under">.
   app->start;
 
 =head2 Formats
 
 Formats can be automatically detected from file extensions like C<.html>, they are used to find the right template and
-generate the correct C<Content-Type> header.
+generate the correct C<Content-Type> header. Use a restrictive placeholder to declare the possible values.
 
   use Mojolicious::Lite -signatures;
 
-  # /detection
   # /detection.html
   # /detection.txt
-  get '/detection' => sub ($c) {
+  get '/detection' => [format => ['html', 'txt']] => sub ($c) {
     $c->render(template => 'detected');
   };
 
   app->start;
   __DATA__
@@ -586,41 +585,25 @@ generate the correct C<Content-Type> header.
   </html>
 
   @@ detected.txt.ep
   TXT was detected.
 
-The default format is C<html>, and restrictive placeholders can be used to limit possible values.
+And just like with placeholders you can use a default value to make the format optional.
 
   use Mojolicious::Lite -signatures;
 
+  # /hello
   # /hello.json
   # /hello.txt
-  get '/hello' => [format => ['json', 'txt']] => sub ($c) {
-    return $c->render(json => {hello => 'world'})
-      if $c->stash('format') eq 'json';
+  get '/hello' => [format => ['json', 'txt']] => {format => 'txt'} => sub ($c) {
+    return $c->render(json => {hello => 'world'}) if $c->stash('format') eq 'json';
     $c->render(text => 'hello world');
   };
 
   app->start;
 
-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;
+The default format is C<html> and the renderer will fall back to when necessary.
 
 =head2 Content negotiation
 
 For resources with different representations and that require truly RESTful content negotiation you can also use
 L<Mojolicious::Plugin::DefaultHelpers/"respond_to">.
@@ -631,11 +614,11 @@ L<Mojolicious::Plugin::DefaultHelpers/"respond_to">.
   # /hello (Accept: application/xml)
   # /hello.json
   # /hello.xml
   # /hello?format=json
   # /hello?format=xml
-  get '/hello' => sub ($c) {
+  get '/hello' => [format => ['json', 'xml']] => {format => undef} => sub ($c) {
     $c->respond_to(
       json => {json => {hello => 'world'}},
       xml  => {text => '<hello>world</hello>'},
       any  => {data => '', status => 204}
     );
diff --git a/lib/Mojolicious/Lite.pm b/lib/Mojolicious/Lite.pm
index f6b3aecb7..bbdf80445 100644
--- a/lib/Mojolicious/Lite.pm
+++ b/lib/Mojolicious/Lite.pm
@@ -298,11 +298,11 @@ L<Mojolicious::Guides::Tutorial> and L<Mojolicious::Guides::Routing> for more in
   my $route = under sub ($c) {...};
   my $route = under '/:foo' => sub ($c) {...};
   my $route = under '/:foo' => {foo => 'bar'};
   my $route = under '/:foo' => [foo => qr/\w+/];
   my $route = under '/:foo' => (agent => qr/Firefox/);
-  my $route = under [format => 0];
+  my $route = under [format => ['json', 'yaml']];
 
 Generate nested route with L<Mojolicious::Routes::Route/"under">, to which all following routes are automatically
 appended. See L<Mojolicious::Guides::Tutorial> and L<Mojolicious::Guides::Routing> for more information.
 
 =head2 websocket
diff --git a/lib/Mojolicious/Routes/Match.pm b/lib/Mojolicious/Routes/Match.pm
index 4e1ffb735..e871d5518 100644
--- a/lib/Mojolicious/Routes/Match.pm
+++ b/lib/Mojolicious/Routes/Match.pm
@@ -11,24 +11,28 @@ sub find { $_[0]->_match($_[0]->root, $_[1], $_[2]) }
 
 sub path_for {
   my ($self, $name, %values) = (shift, Mojo::Util::_options(@_));
 
   # Current route
-  my $route;
-  if (!$name || $name eq 'current') { return {} unless $route = $self->endpoint }
+  my ($route, $current);
+  if (!$name || $name eq 'current') {
+    return {} unless $route = $self->endpoint;
+    $current = 1;
+  }
 
   # Find endpoint
   else { return {path => $name} unless $route = $self->root->lookup($name) }
 
   # Merge values (clear format)
-  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);
+  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);
   return {path => $path, websocket => $route->has_websocket};
 }
 
 sub _match {
   my ($self, $r, $c, $options) = @_;
diff --git a/lib/Mojolicious/Routes/Pattern.pm b/lib/Mojolicious/Routes/Pattern.pm
index 30bd2c5c8..17110137b 100644
--- a/lib/Mojolicious/Routes/Pattern.pm
+++ b/lib/Mojolicious/Routes/Pattern.pm
@@ -123,27 +123,27 @@ sub _compile {
 
   # Not rooted with a slash
   $regex = $block . $regex if $block;
 
   # Format
-  $regex .= _compile_format($constraints->{format}, $defaults->{format}) if $detect;
+  $regex .= _compile_format($constraints->{format}, exists $defaults->{format}) if $detect;
 
   $self->regex(qr/^$regex/ps);
 }
 
 sub _compile_format {
-  my ($format, $default) = @_;
-
-  # Default regex
-  return '/?(?:\.([^/]+))?$' unless defined $format;
+  my ($format, $has_default) = @_;
 
   # No regex
   return '' unless $format;
 
+  # Default regex
+  return '/?(?:\.([^/]+))?$' if $format eq '1';
+
   # Compile custom regex
   my $regex = '\.' . _compile_req($format);
-  return $default ? "/?(?:$regex)?\$" : "/?$regex\$";
+  return $has_default ? "/?(?:$regex)?\$" : "/?$regex\$";
 }
 
 sub _compile_req {
   my $req = shift;
   return "($req)" if ref $req ne 'ARRAY';
@@ -342,19 +342,19 @@ Match pattern against path and remove matching parts, format detection is disabl
 =head2 new
 
   my $pattern = Mojolicious::Routes::Pattern->new;
   my $pattern = Mojolicious::Routes::Pattern->new('/users/:id');
   my $pattern = Mojolicious::Routes::Pattern->new('/user/:id', id => qr/\d+/);
-  my $pattern = Mojolicious::Routes::Pattern->new(format => 0);
+  my $pattern = Mojolicious::Routes::Pattern->new(format => ['json', 'yaml']);
 
 Construct a new L<Mojolicious::Routes::Pattern> object and L</"parse"> pattern if necessary.
 
 =head2 parse
 
   $pattern = $pattern->parse('/user/:id');
   $pattern = $pattern->parse('/user/:id', id=> qr/\d+/);
-  $pattern = $pattern->parse(format => 0);
+  $pattern = $pattern->parse(format => ['json', 'yaml']);
 
 Parse pattern.
 
 =head2 render
 
diff --git a/lib/Mojolicious/Routes/Route.pm b/lib/Mojolicious/Routes/Route.pm
index 5d8532647..2b2f723c9 100644
--- a/lib/Mojolicious/Routes/Route.pm
+++ b/lib/Mojolicious/Routes/Route.pm
@@ -219,11 +219,11 @@ sub _route {
 
   my $route = $self->add_child(__PACKAGE__->new->parse(@_))->children->[-1];
   croak qq{Route pattern "@{[$route->pattern->unparsed]}" contains a reserved stash value}
     if grep { $self->is_reserved($_) } @{$route->pattern->placeholders};
   my $format = $self->pattern->constraints->{format};
-  $route->pattern->constraints->{format} //= 0 if defined $format && !$format;
+  $route->pattern->constraints->{format} //= 1 if defined $format && $format eq '1';
 
   return $route;
 }
 
 1;
@@ -459,11 +459,11 @@ L<Mojolicious::Guides::Routing> for more information.
 
 =head2 parse
 
   $r = $r->parse('/user/:id');
   $r = $r->parse('/user/:id', id => qr/\d+/);
-  $r = $r->parse(format => 0);
+  $r = $r->parse(format => ['json', 'yaml']);
 
 Parse pattern.
 
 =head2 patch
 
@@ -587,11 +587,11 @@ Stringify the whole route.
   my $route = $r->under(sub ($c) {...});
   my $route = $r->under('/:foo' => sub ($c) {...});
   my $route = $r->under('/:foo' => {foo => 'bar'});
   my $route = $r->under('/:foo' => [foo => qr/\w+/]);
   my $route = $r->under('/:foo' => (agent => qr/Firefox/));
-  my $route = $r->under([format => 0]);
+  my $route = $r->under([format => ['json', 'yaml']]);
 
 Generate L<Mojolicious::Routes::Route> object for a nested route with its own intermediate destination, takes the same
 arguments as L</"any"> (except for the HTTP methods to match, which are not available). See
 L<Mojolicious::Guides::Tutorial> and L<Mojolicious::Guides::Routing> for more information.
 
diff --git a/t/mojolicious/charset_lite_app.t b/t/mojolicious/charset_lite_app.t
index 7113e4a6a..4197add69 100644
--- a/t/mojolicious/charset_lite_app.t
+++ b/t/mojolicious/charset_lite_app.t
@@ -25,11 +25,11 @@ app->renderer->add_handler(
     delete $options->{encoding};
     $$output = b($c->stash->{test})->encode('UTF-8')->to_string;
   }
 );
 
-get '/' => 'index';
+get '/' => [format => [$yatta]] => {format => undef} => 'index';
 
 post '/' => sub {
   my $c = shift;
   $c->render(text => "foo: " . $c->param('foo'));
 };
diff --git a/t/mojolicious/exception_lite_app.t b/t/mojolicious/exception_lite_app.t
index 83a0583c6..6994ede4a 100644
--- a/t/mojolicious/exception_lite_app.t
+++ b/t/mojolicious/exception_lite_app.t
@@ -49,23 +49,23 @@ get '/logger' => sub {
 
 get '/custom_exception' => sub { die Mojo::Base->new };
 
 get '/dead_template';
 
-get '/dead_template_too';
+get '/dead_template_too' => [format => ['xml']];
 
-get '/dead_handler' => {handler => 'dead'};
+get '/dead_handler' => [format => ['xml']] => {handler => 'dead'};
 
-get '/dead_action_epl' => {handler => 'epl'} => sub {
+get '/dead_action_epl' => [format => ['xml']] => {handler => 'epl'} => sub {
   die "dead action epl!\n";
 };
 
 get '/dead_included_template';
 
 get '/dead_template_with_layout';
 
-get '/dead_action' => sub { die "dead action!\n" };
+get '/dead_action' => [format => ['xml', 'json']] => {format => undef} => sub { die "dead action!\n" };
 
 get '/double_dead_action_☃' => sub {
   eval { die 'double dead action!' };
   die $@;
 };
@@ -74,11 +74,11 @@ get '/trapped' => sub {
   my $c = shift;
   eval { die {foo => 'bar'} };
   $c->render(text => $@->{foo} || 'failed');
 };
 
-get '/missing_template' => {exception => 'whatever'};
+get '/missing_template' => [format => ['xml', 'json', 'txt']] => {exception => 'whatever', format => undef};
 
 get '/missing_template/too' => sub {
   my $c = shift;
   $c->render('does_not_exist');
 };
diff --git a/t/mojolicious/group_lite_app.t b/t/mojolicious/group_lite_app.t
index 9899987c0..5748767fd 100644
--- a/t/mojolicious/group_lite_app.t
+++ b/t/mojolicious/group_lite_app.t
@@ -160,11 +160,11 @@ group {
     return 1 if $c->req->param('ok');
     $c->render(text => "You're not ok.");
     return !!0;
   };
 
-  get '/authgroup' => {text => "You're ok."};
+  get '/authgroup' => [format => ['txt']] => {text => "You're ok.", format => undef};
 };
 
 get '/noauthgroup' => {inline => 'Whatever <%= $foo %>.'};
 
 # Disable format detection
diff --git a/t/mojolicious/layouted_lite_app.t b/t/mojolicious/layouted_lite_app.t
index 77e248b87..8756f9658 100644
--- a/t/mojolicious/layouted_lite_app.t
+++ b/t/mojolicious/layouted_lite_app.t
@@ -99,11 +99,11 @@ get '/inline' => {inline => '<%= "inline!" %>'};
 
 get '/inline/again' => {inline => 0};
 
 get '/data' => {data => 0};
 
-get '/variants' => {layout => 'variants'} => sub {
+get '/variants' => [format => ['txt']] => {layout => 'variants', format => undef} => sub {
   my $c = shift;
   $c->stash->{variant} = $c->param('device');
   $c->render('variants');
 };
 
diff --git a/t/mojolicious/lib/MojoliciousTest.pm b/t/mojolicious/lib/MojoliciousTest.pm
index c83aff158..0dcf88346 100644
--- a/t/mojolicious/lib/MojoliciousTest.pm
+++ b/t/mojolicious/lib/MojoliciousTest.pm
@@ -114,11 +114,11 @@ sub startup {
 
   # /test10 (controller in both namespaces)
   $r->any('/test10')->to('baz#index');
 
   # /withblock (template with blocks)
-  $r->any('/withblock')->to('foo#withBlock');
+  $r->any('/withblock', [format => ['txt']])->to('foo#withBlock');
 
   # /staged (authentication with intermediate destination)
   my $b = $r->any('/staged')->inline(1)->to('foo#stage1', return => 1);
   $b->any->to(action => 'stage2');
 
diff --git a/t/mojolicious/lite_app.t b/t/mojolicious/lite_app.t
index 902420f5a..31b3037ca 100644
--- a/t/mojolicious/lite_app.t
+++ b/t/mojolicious/lite_app.t
@@ -74,11 +74,11 @@ get '/uni/aäb' => sub {
 get '/unicode/:0' => sub {
   my $c = shift;
   $c->render(text => $c->param('0') . $c->url_for);
 };
 
-get '/' => 'root';
+get '/' => [format => ['html']] => {format => undef} => 'root';
 
 get '/alternatives/:char' => [char => [qw(☃ ♥)]] => sub {
   my $c = shift;
   $c->render(text => $c->url_for);
 };
@@ -130,11 +130,11 @@ get '/data/exception' => 'dies';
 
 get '/template/exception' => 'dies_too';
 
 get '/with-format' => {format => 'html'} => 'with-format';
 
-get '/without-format' => 'without-format';
+get '/without-format' => [format => ['html']] => {format => undef} => 'without-format';
 
 any '/json_too' => {json => {hello => 'world'}};
 
 get '/null/:null' => sub {
   my $c = shift;
@@ -196,13 +196,13 @@ get '/finished' => sub {
 
 get '/привет/мир' => sub { shift->render(text => 'привет мир') };
 
 get '/root.html' => 'root_path';
 
-get '/root' => sub { shift->render(text => 'root fallback!') };
+get '/root' => [format => ['txt']] => {format => undef} => sub { shift->render(text => 'root fallback!') };
 
-get '/template.txt' => {template => 'template', format => 'txt'};
+get '/template.txt' => [format => ['epl']] => {template => 'template', format => 'txt'};
 
 get ':number' => [number => qr/0/] => sub {
   my $c   = shift;
   my $url = $c->req->url->to_abs;
   $c->res->headers->header('X-Original' => $c->tx->original_remote_address);
diff --git a/t/mojolicious/pattern.t b/t/mojolicious/pattern.t
index 554415a02..e63e1506c 100644
--- a/t/mojolicious/pattern.t
+++ b/t/mojolicious/pattern.t
@@ -124,10 +124,11 @@ is_deeply $pattern->match("/$value"), {test => $value}, 'right structure';
 is $pattern->render({test => $value}), "/$value", 'right result';
 
 # Format detection
 $pattern = Mojolicious::Routes::Pattern->new('/test');
 $pattern->defaults({action => 'index'});
+$pattern->constraints({format => 1});
 ok !$pattern->regex, 'no regex';
 is_deeply $pattern->match('/test.xml', 1), {action => 'index', format => 'xml'}, 'right structure';
 ok $pattern->regex, 'regex has been compiled on demand';
 $pattern = Mojolicious::Routes::Pattern->new('/test.json');
 $pattern->defaults({action => 'index'});
@@ -167,10 +168,15 @@ $pattern->defaults({action => 'index', format => 'html'});
 my $result = $pattern->match('/foo/v1.0', 1);
 is_deeply $result, {test => 'foo', action => 'index', format => 'html'}, 'right structure';
 is $pattern->render($result), '/foo/v1.0', 'right result';
 is $pattern->render($result,                     1), '/foo/v1.0.html', 'right result';
 is $pattern->render({%$result, format => undef}, 1), '/foo/v1.0',      'right result';
+
+# Versioned pattern with format
+$pattern = Mojolicious::Routes::Pattern->new('/:test/v1.0');
+$pattern->defaults({action => 'index', format => 'html'});
+$pattern->constraints({format => ['txt']});
 $result = $pattern->match('/foo/v1.0.txt', 1);
 is_deeply $result, {test => 'foo', action => 'index', format => 'txt'}, 'right structure';
 is $pattern->render($result), '/foo/v1.0', 'right result';
 is $pattern->render($result, 1), '/foo/v1.0.txt', 'right result';
 ok !$pattern->match('/foo/v2.0', 1), 'no result';
diff --git a/t/mojolicious/restful_lite_app.t b/t/mojolicious/restful_lite_app.t
index 489d15ee6..c4c3578fa 100644
--- a/t/mojolicious/restful_lite_app.t
+++ b/t/mojolicious/restful_lite_app.t
@@ -5,37 +5,37 @@ BEGIN { $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll' }
 use Test::Mojo;
 use Test::More;
 use Mojo::JSON qw(false true);
 use Mojolicious::Lite;
 
-any [qw(POST PUT)] => '/json/echo' => sub {
+any [qw(POST PUT)] => '/json/echo' => [format => ['json']] => {format => undef} => sub {
   my $c = shift;
   $c->respond_to(json => {json => $c->req->json});
 };
 
-get '/accepts' => sub {
+get '/accepts' => [format => ['html', 'json', 'txt', 'xml']] => {format => undef} => sub {
   my $c = shift;
   $c->render(json => {best => $c->accepts('html', 'json', 'txt')});
 };
 
-get '/wants_json' => sub {
+get '/wants_json' => [format => ['json', 'xml']] => {format => undef} => sub {
   my $c = shift;
   $c->render(json => {wants_json => $c->accepts('', 'json') ? \1 : \0});
 };
 
 under '/rest';
 
-get sub {
+get [format => ['json', 'html', 'xml']] => {format => undef} => sub {
   my $c = shift;
   $c->respond_to(
     json => sub { $c->render(json => {just => 'works'}) },
     html => sub { $c->render(data => '<html><body>works') },
     xml  => sub { $c->render(data => '<just>works</just>') }
   );
 };
 
-post sub {
+post [format => ['json', 'html', 'xml', 'png']] => {format => undef} => sub {
   my $c = shift;
   $c->respond_to(
     json => {json => {just => 'works too'}},
     html => {text => '<html><body>works too'},
     xml  => {data => '<just>works too</just>'},
diff --git a/t/mojolicious/routes.t b/t/mojolicious/routes.t
index bd028a480..fc19966ef 100644
--- a/t/mojolicious/routes.t
+++ b/t/mojolicious/routes.t
@@ -34,16 +34,16 @@ $r->any('/alternatives3/:foo', [foo => [qw(foo foobar)]]);
 # /alternatives4/foo.bar
 $r->any('/alternatives4/:foo', [foo => [qw(foo foo.bar)]]);
 
 # /optional/*
 # /optional/*/*
-$r->any('/optional/:foo/:bar')->to(bar => 'test');
+$r->any('/optional/:foo/:bar', [format => 1])->to(bar => 'test');
 
 # /optional2
 # /optional2/*
 # /optional2/*/*
-$r->any('/optional2/:foo')->to(foo => 'one')->any('/:bar')->to(bar => 'two');
+$r->any('/optional2/:foo', [format => 1])->to(foo => 'one')->any('/:bar')->to(bar => 'two');
 
 # /*/test
 my $test = $r->any('/:testcase/test')->to(action => 'test');
 
 # /*/test/edit
@@ -84,12 +84,12 @@ $r->any('/wildcards/3/<*wildcard>/foo')->to(testcase => 'very', action => 'dange
 
 # /wildcards/4/*/foo
 $r->any('/wildcards/4/*wildcard/foo')->to(testcase => 'somewhat', action => 'dangerous');
 
 # /format
-# /format.html
-$r->any('/format')->to(testcase => 'hello')->to(action => 'you', format => 'html');
+# /format.*
+$r->any('/format', [format => 1])->to(testcase => 'hello')->to(action => 'you', format => 'html');
 
 # /format2.txt
 $r->any('/format2', [format => qr/txt/])->to(testcase => 'we', action => 'howdy');
 
 # /format3.txt
@@ -115,16 +115,19 @@ $r->any('/format7', [format => [qw(foo foobar)]])->to('perl#rocks');
 $r->add_type(my_num => [23, 24]);
 $r->any('/type/<id:my_num>')->to('foo#bar');
 
 # /articles/1/edit
 # /articles/1/delete
+# /articles/1/delete.json
 my $inline = $r->any('/articles/:id')->inline(1)->to(testcase => 'articles', action => 'load', format => 'html');
 $inline->any('/edit')->to(testcase => 'articles', action => 'edit');
-$inline->any('/delete')->to(testcase => 'articles', action => 'delete', format => undef)->name('articles_delete');
+$inline->any('/delete', [format => ['json']])->to(testcase => 'articles', action => 'delete', format => undef)
+  ->name('articles_delete');
 
 # GET /method/get
-$r->any('/method/get')->methods('GET')->to(testcase => 'method', action => 'get');
+$r->any('/method/get', [format => ['html']])->methods('GET')
+  ->to(testcase => 'method', action => 'get', format => undef);
 
 # POST /method/post
 $r->any('/method/post')->methods('post')->to(testcase => 'method', action => 'post');
 
 # POST|GET /method/post_get
@@ -140,12 +143,12 @@ $r->any('/regex/alternatives/:alternatives', [alternatives => qr/foo|bar|baz/])
 # /versioned/1.0/test
 # /versioned/1.0/test.xml
 # /versioned/2.4/test
 # /versioned/2.4/test.xml
 my $versioned = $r->any('/versioned');
-$versioned->any('/1.0')->to(testcase => 'bar')->any('/test')->to(action => 'baz');
-$versioned->any('/2.4')->to(testcase => 'foo')->any('/test')->to(action => 'bar');
+$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);
 
 # /versioned/too/1.0
 my $too = $r->any('/versioned/too')->to('too#');
 $too->any('/1.0')->to('#foo');
 $too->any('/2.0', [format => 0])->to('#bar');
@@ -156,23 +159,23 @@ $multi->any('/foo.bar', [format => 0])->to('just#works');
 $multi->any('/bar.baz')->to('works#too', format => 'xml');
 
 # /nodetect
 # /nodetect2.txt
 # /nodetect2.html
-my $inactive = $r->any('/', [format => 0]);
+my $inactive = $r->any('/');
 $inactive->any('/nodetect')->to('foo#none');
 $inactive->any('/nodetect2', [format => ['txt', 'html']])->to('bar#hyper');
 
 # /target/first
 # /target/second
 # /target/second.xml
 # /source/third
 # /source/third.xml
 my $source = $r->any('/source')->to('source#');
 my $first  = $source->any('/', [format => 0])->any('/first')->to('#first');
-$source->any('/second')->to('#second');
-my $third  = $source->any('/third')->to('#third');
+$source->any('/second', [format => ['xml']])->to('#second', format => undef);
+my $third  = $source->any('/third', [format => ['xml']])->to('#third', format => undef);
 my $target = $r->remove->any('/target')->to('target#');
 my $second = $r->find('second');
 is $second->render({}), '/source/second', 'right result';
 $second->remove;
 is $second->render({}), '/second', 'right result';
@@ -181,11 +184,12 @@ is $second->render({}), '/target/second', 'right result';
 
 # /websocket
 $r->websocket('/websocket' => {testcase => 'ws'})->any('/')->to(action => 'just')->any->to(works => 1);
 
 # /slash
-$r->any('/slash')->to(testcase => 'just')->any('/')->to(action => 'slash');
+# /slash.txt
+$r->any('/slash')->to(testcase => 'just')->any('/', [format => ['txt']])->to(action => 'slash', format => undef);
 
 # /missing/*/name
 # /missing/too
 # /missing/too/test
 $r->any('/missing/:/name')->to('missing#placeholder');
@@ -238,11 +242,11 @@ is_deeply $m->stack, [], 'empty stack';
 # Introspect
 is $r->find('very_clean')->to_string,      '/clean',               'right pattern';
 is $r->find('0')->to_string,               '/0',                   'right pattern';
 is $r->find('test_edit')->to_string,       '/:testcase/test/edit', 'right pattern';
 is $r->find('articles_delete')->to_string, '/articles/:id/delete', 'right pattern';
-is $r->find('nodetect')->pattern->constraints->{format}, 0, 'right value';
+ok !$r->find('nodetect')->pattern->constraints->{format}, 'no value';
 is $r->find('nodetect')->to->{controller}, 'foo', 'right testcase';
 
 # Null route
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/0'});
@@ -402,11 +406,11 @@ $m->find($c => {method => 'GET', path => '/articles/1/delete.json'});
 @stack = (
   {testcase => 'articles', action => 'load',   id => 1, format => 'json'},
   {testcase => 'articles', action => 'delete', id => 1, format => 'json'}
 );
 is_deeply $m->stack, \@stack, 'right structure';
-is $m->path_for->{path}, '/articles/1/delete', 'right path';
+is $m->path_for->{path}, '/articles/1/delete.json', 'right path';
 
 # Root
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/'});
 is_deeply $m->stack, [{testcase => 'hello', action => 'world'}], 'right structure';
@@ -609,11 +613,11 @@ is_deeply $m->stack, [], 'empty stack';
 
 # Request methods
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/method/get.html'});
 is_deeply $m->stack, [{testcase => 'method', action => 'get', format => 'html'}], 'right structure';
-is $m->path_for->{path}, '/method/get', 'right path';
+is $m->path_for->{path}, '/method/get.html', 'right path';
 is $m->endpoint->suggested_method, 'GET', 'right method';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'POST', path => '/method/post'});
 is_deeply $m->stack, [{testcase => 'method', action => 'post'}], 'right structure';
 is $m->path_for->{path}, '/method/post', 'right path';
@@ -663,24 +667,24 @@ $m->find($c => {method => 'GET', path => '/regex/alternatives/yada'});
 is_deeply $m->stack, [], 'empty stack';
 
 # Route with version
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/versioned/1.0/test'});
-is_deeply $m->stack, [{testcase => 'bar', action => 'baz'}], 'right structure';
+is_deeply $m->stack, [{testcase => 'bar', action => 'baz', format => undef}], 'right structure';
 is $m->path_for->{path}, '/versioned/1.0/test', 'right path';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/versioned/1.0/test.xml'});
 is_deeply $m->stack, [{testcase => 'bar', action => 'baz', format => 'xml'}], 'right structure';
-is $m->path_for->{path}, '/versioned/1.0/test', 'right path';
+is $m->path_for->{path}, '/versioned/1.0/test.xml', 'right path';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/versioned/2.4/test'});
-is_deeply $m->stack, [{testcase => 'foo', action => 'bar'}], 'right structure';
+is_deeply $m->stack, [{testcase => 'foo', action => 'bar', format => undef}], 'right structure';
 is $m->path_for->{path}, '/versioned/2.4/test', 'right path';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/versioned/2.4/test.xml'});
 is_deeply $m->stack, [{testcase => 'foo', action => 'bar', format => 'xml'}], 'right structure';
-is $m->path_for->{path}, '/versioned/2.4/test', 'right path';
+is $m->path_for->{path}, '/versioned/2.4/test.xml', 'right path';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/versioned/3.0/test'});
 is_deeply $m->stack, [], 'empty stack';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/versioned/3.4/test'});
@@ -746,27 +750,27 @@ is_deeply $m->stack, [], 'empty stack';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/source/first'});
 is_deeply $m->stack, [], 'empty stack';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/target/second'});
-is_deeply $m->stack, [{controller => 'target', action => 'second'}], 'right structure';
+is_deeply $m->stack, [{controller => 'target', action => 'second', format => undef}], 'right structure';
 is $m->path_for->{path}, '/target/second', 'right path';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/target/second.xml'});
 is_deeply $m->stack, [{controller => 'target', action => 'second', format => 'xml'}], 'right structure';
-is $m->path_for->{path}, '/target/second', 'right path';
+is $m->path_for->{path}, '/target/second.xml', 'right path';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/source/second'});
 is_deeply $m->stack, [], 'empty stack';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/source/third'});
-is_deeply $m->stack, [{controller => 'source', action => 'third'}], 'right structure';
+is_deeply $m->stack, [{controller => 'source', action => 'third', format => undef}], 'right structure';
 is $m->path_for->{path}, '/source/third', 'right path';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/source/third.xml'});
 is_deeply $m->stack, [{controller => 'source', action => 'third', format => 'xml'}], 'right structure';
-is $m->path_for->{path}, '/source/third', 'right path';
+is $m->path_for->{path}, '/source/third.xml', 'right path';
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/target/third'});
 is_deeply $m->stack, [], 'empty stack';
 
 # WebSocket
@@ -780,11 +784,11 @@ ok $m->path_for->{websocket}, 'is a websocket';
 
 # Just a slash with a format after a path
 $m = Mojolicious::Routes::Match->new(root => $r);
 $m->find($c => {method => 'GET', path => '/slash.txt'});
 is_deeply $m->stack, [{testcase => 'just', action => 'slash', format => 'txt'}], 'right structure';
-is $m->path_for->{path}, '/slash', 'right path';
+is $m->path_for->{path}, '/slash.txt', 'right path';
 ok !$m->path_for->{websocket}, 'not a websocket';
 is $m->path_for(format => 'html')->{path}, '/slash.html', 'right path';
 
 # Nameless placeholder
 $m = Mojolicious::Routes::Match->new(root => $r);
diff --git a/t/mojolicious/twinkle_lite_app.t b/t/mojolicious/twinkle_lite_app.t
index 7846187e6..cb66ad0e5 100644
--- a/t/mojolicious/twinkle_lite_app.t
+++ b/t/mojolicious/twinkle_lite_app.t
@@ -49,11 +49,11 @@ subtest 'Configuration' => sub {
 
 get '/' => {name => '<sebastian>'} => 'index';
 
 get '/advanced' => 'advanced';
 
-get '/rest' => sub {
+get '/rest' => [format => 'html'] => {format => undef} => sub {
   shift->respond_to(foo => {text => 'foo works!'}, html => {text => 'html works!'})
     ->res->headers->header('X-Rest' => 1);
 };
 
 get '/dead' => sub {die};

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 {