bug #31240 Fix url matcher edge cases with trailing slash (arjenm)
This PR was squashed before being merged into the 4.2 branch (closes #31240).
Discussion
----------
Fix url matcher edge cases with trailing slash
| Q | A
| ------------- | ---
| Branch? | master / 4.2 (not sure whether this should've been against 4.2)
| Bug fix? | yes
| New feature? | no
| BC breaks? | no (I think... if so, in obscure route configurations like the ones that broke in 4.2.7 ;) )
| Deprecations? | no
| Tests pass? | yes
| Fixed tickets | #30721
| License | MIT
As stated in #30721 the "redirecting" (compiled) UrlMatcher ignored host-requirements when doing a 'matched url except for trailing slash difference'-redirect.
With routes like this, you'd get 404's rather than 200's on the second routes.
```yaml
host1.withTrail:
path: /foo/ # host2/foo would become host2/foo/ due to this partial path-match
host: host1
host2.withoutTrail: # A request for host2/foo should match this route
path: /foo # host2/foo/ does not match this path
host: host2
```
This was caused by too eagerly testing whether a route could've worked with an additional trailing slash.
If it could be, that would result in an attempt to redirect _before_ testing the host.
The original url would get the additional slash and prior to actually redirecting, it'd be retested against the available routes. _That new url_ would actually match no routes, since now the host check for the first routes would actually be executed and fail the match for those routes. The adjusted path in the route does not match the second sets of routes...
This PR moves the trailing-slash check to after the host checks. I've added several tests with variants on these edge cases.
*Note:* This is a bug in 4.2 (and 4.3).
I fixed it against master. It appears 4.2's PhpMatcherTrait was renamed to CompiledUrlMatcherTrait in 4.3. But that made it loose its history.
So I'm not sure how to proceed from here. I can rebase it on 4.2 and do the change in PhpMatcherTrait, but that would probably fail to merge in master? I'm not nearly proficient enough in Symfony PR's to know how to solve all this ;)
@nicolas-grekas also asked for these or similar tests to be added to 3.4 as 'proof' those routes worked in that version as well. I have not (yet) done so.
The tests did work on the non-redirecting UrlMatcher without change (i.e. just running UrlMatcherTest), which implies - to me at least - the routes where properly defined and should not result in 404's simply because a RedirectableUrlMatcherInterface _can_ do redirects.
Actually, since I needed to change UrlMatcher as well, I'm not convinced these bugs where totally absent in 3.4. They didn't occur with the compiled version, since the host check was the very first if-statement in that humongous if-tree it generated.
PS, the branch name is a bit dramatic ;)
Commits
-------
4fcfa9d85c
Fix url matcher edge cases with trailing slash
This commit is contained in:
commit
ada9aa0a07
@ -15,11 +15,14 @@ use Symfony\Component\Routing\Exception\MethodNotAllowedException;
|
||||
use Symfony\Component\Routing\Exception\NoConfigurationException;
|
||||
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
|
||||
use Symfony\Component\Routing\Matcher\RedirectableUrlMatcherInterface;
|
||||
use Symfony\Component\Routing\RequestContext;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @property RequestContext $context
|
||||
*/
|
||||
trait PhpMatcherTrait
|
||||
{
|
||||
@ -89,13 +92,6 @@ trait PhpMatcherTrait
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('/' !== $pathinfo && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) {
|
||||
if ($supportsRedirections && (!$requiredMethods || isset($requiredMethods['GET']))) {
|
||||
return $allow = $allowSchemes = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($requiredHost) {
|
||||
if ('#' !== $requiredHost[0] ? $requiredHost !== $host : !preg_match($requiredHost, $host, $hostMatches)) {
|
||||
continue;
|
||||
@ -106,6 +102,13 @@ trait PhpMatcherTrait
|
||||
}
|
||||
}
|
||||
|
||||
if ('/' !== $pathinfo && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) {
|
||||
if ($supportsRedirections && (!$requiredMethods || isset($requiredMethods['GET']))) {
|
||||
return $allow = $allowSchemes = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasRequiredScheme = !$requiredSchemes || isset($requiredSchemes[$context->getScheme()]);
|
||||
if ($requiredMethods && !isset($requiredMethods[$canonicalMethod]) && !isset($requiredMethods[$requestMethod])) {
|
||||
if ($hasRequiredScheme) {
|
||||
@ -113,6 +116,7 @@ trait PhpMatcherTrait
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$hasRequiredScheme) {
|
||||
$allowSchemes += $requiredSchemes;
|
||||
continue;
|
||||
|
@ -32,6 +32,7 @@ class UrlMatcher implements UrlMatcherInterface, RequestMatcherInterface
|
||||
const REQUIREMENT_MISMATCH = 1;
|
||||
const ROUTE_MATCH = 2;
|
||||
|
||||
/** @var RequestContext */
|
||||
protected $context;
|
||||
|
||||
/**
|
||||
@ -166,14 +167,6 @@ class UrlMatcher implements UrlMatcherInterface, RequestMatcherInterface
|
||||
}
|
||||
}
|
||||
|
||||
if ('/' !== $pathinfo && !$hasTrailingVar && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) {
|
||||
if ($supportsTrailingSlash && (!$requiredMethods || \in_array('GET', $requiredMethods))) {
|
||||
return $this->allow = $this->allowSchemes = [];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hostMatches = [];
|
||||
if ($compiledRoute->getHostRegex() && !preg_match($compiledRoute->getHostRegex(), $this->context->getHost(), $hostMatches)) {
|
||||
continue;
|
||||
@ -185,6 +178,14 @@ class UrlMatcher implements UrlMatcherInterface, RequestMatcherInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('/' !== $pathinfo && !$hasTrailingVar && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) {
|
||||
if ($supportsTrailingSlash && (!$requiredMethods || \in_array('GET', $requiredMethods))) {
|
||||
return $this->allow = $this->allowSchemes = [];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasRequiredScheme = !$route->getSchemes() || $route->hasScheme($this->context->getScheme());
|
||||
if ($requiredMethods) {
|
||||
if (!\in_array($method, $requiredMethods)) {
|
||||
|
@ -85,9 +85,8 @@ class UrlMatcherTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testMatch()
|
||||
public function testPatternMatchAndParameterReturn()
|
||||
{
|
||||
// test the patterns are matched and parameters are returned
|
||||
$collection = new RouteCollection();
|
||||
$collection->add('foo', new Route('/foo/{bar}'));
|
||||
$matcher = $this->getUrlMatcher($collection);
|
||||
@ -96,14 +95,21 @@ class UrlMatcherTest extends TestCase
|
||||
$this->fail();
|
||||
} catch (ResourceNotFoundException $e) {
|
||||
}
|
||||
$this->assertEquals(['_route' => 'foo', 'bar' => 'baz'], $matcher->match('/foo/baz'));
|
||||
|
||||
$this->assertEquals(['_route' => 'foo', 'bar' => 'baz'], $matcher->match('/foo/baz'));
|
||||
}
|
||||
|
||||
public function testDefaultsAreMerged()
|
||||
{
|
||||
// test that defaults are merged
|
||||
$collection = new RouteCollection();
|
||||
$collection->add('foo', new Route('/foo/{bar}', ['def' => 'test']));
|
||||
$matcher = $this->getUrlMatcher($collection);
|
||||
$this->assertEquals(['_route' => 'foo', 'bar' => 'baz', 'def' => 'test'], $matcher->match('/foo/baz'));
|
||||
}
|
||||
|
||||
public function testMethodIsIgnoredIfNoMethodGiven()
|
||||
{
|
||||
// test that route "method" is ignored if no method is given in the context
|
||||
$collection = new RouteCollection();
|
||||
$collection->add('foo', new Route('/foo', [], [], [], '', [], ['get', 'head']));
|
||||
@ -123,8 +129,10 @@ class UrlMatcherTest extends TestCase
|
||||
$this->assertInternalType('array', $matcher->match('/foo'));
|
||||
$matcher = $this->getUrlMatcher($collection, new RequestContext('', 'head'));
|
||||
$this->assertInternalType('array', $matcher->match('/foo'));
|
||||
}
|
||||
|
||||
// route with an optional variable as the first segment
|
||||
public function testRouteWithOptionalVariableAsFirstSegment()
|
||||
{
|
||||
$collection = new RouteCollection();
|
||||
$collection->add('bar', new Route('/{bar}/foo', ['bar' => 'bar'], ['bar' => 'foo|bar']));
|
||||
$matcher = $this->getUrlMatcher($collection);
|
||||
@ -136,8 +144,10 @@ class UrlMatcherTest extends TestCase
|
||||
$matcher = $this->getUrlMatcher($collection);
|
||||
$this->assertEquals(['_route' => 'bar', 'bar' => 'foo'], $matcher->match('/foo'));
|
||||
$this->assertEquals(['_route' => 'bar', 'bar' => 'bar'], $matcher->match('/'));
|
||||
}
|
||||
|
||||
// route with only optional variables
|
||||
public function testRouteWithOnlyOptionalVariables()
|
||||
{
|
||||
$collection = new RouteCollection();
|
||||
$collection->add('bar', new Route('/{foo}/{bar}', ['foo' => 'foo', 'bar' => 'bar'], []));
|
||||
$matcher = $this->getUrlMatcher($collection);
|
||||
@ -512,6 +522,141 @@ class UrlMatcherTest extends TestCase
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'bar', 'locale' => 'en'], $matcher->match('/bar/bar'));
|
||||
}
|
||||
|
||||
public function testVariationInTrailingSlashWithHosts()
|
||||
{
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('foo', new Route('/foo/', [], [], [], 'foo.example.com'));
|
||||
$coll->add('bar', new Route('/foo', [], [], [], 'bar.example.com'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'foo.example.com'));
|
||||
$this->assertEquals(['_route' => 'foo'], $matcher->match('/foo/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'bar.example.com'));
|
||||
$this->assertEquals(['_route' => 'bar'], $matcher->match('/foo'));
|
||||
}
|
||||
|
||||
public function testVariationInTrailingSlashWithHostsInReverse()
|
||||
{
|
||||
// The order should not matter
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('bar', new Route('/foo', [], [], [], 'bar.example.com'));
|
||||
$coll->add('foo', new Route('/foo/', [], [], [], 'foo.example.com'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'foo.example.com'));
|
||||
$this->assertEquals(['_route' => 'foo'], $matcher->match('/foo/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'bar.example.com'));
|
||||
$this->assertEquals(['_route' => 'bar'], $matcher->match('/foo'));
|
||||
}
|
||||
|
||||
public function testVariationInTrailingSlashWithHostsAndVariable()
|
||||
{
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('foo', new Route('/{foo}/', [], [], [], 'foo.example.com'));
|
||||
$coll->add('bar', new Route('/{foo}', [], [], [], 'bar.example.com'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'foo.example.com'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'foo'], $matcher->match('/bar/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'bar.example.com'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'bar'], $matcher->match('/bar'));
|
||||
}
|
||||
|
||||
public function testVariationInTrailingSlashWithHostsAndVariableInReverse()
|
||||
{
|
||||
// The order should not matter
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('bar', new Route('/{foo}', [], [], [], 'bar.example.com'));
|
||||
$coll->add('foo', new Route('/{foo}/', [], [], [], 'foo.example.com'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'foo.example.com'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'foo'], $matcher->match('/bar/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'bar.example.com'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'bar'], $matcher->match('/bar'));
|
||||
}
|
||||
|
||||
public function testVariationInTrailingSlashWithMethods()
|
||||
{
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('foo', new Route('/foo/', [], [], [], '', [], ['POST']));
|
||||
$coll->add('bar', new Route('/foo', [], [], [], '', [], ['GET']));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'POST'));
|
||||
$this->assertEquals(['_route' => 'foo'], $matcher->match('/foo/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET'));
|
||||
$this->assertEquals(['_route' => 'bar'], $matcher->match('/foo'));
|
||||
}
|
||||
|
||||
public function testVariationInTrailingSlashWithMethodsInReverse()
|
||||
{
|
||||
// The order should not matter
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('bar', new Route('/foo', [], [], [], '', [], ['GET']));
|
||||
$coll->add('foo', new Route('/foo/', [], [], [], '', [], ['POST']));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'POST'));
|
||||
$this->assertEquals(['_route' => 'foo'], $matcher->match('/foo/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET'));
|
||||
$this->assertEquals(['_route' => 'bar'], $matcher->match('/foo'));
|
||||
}
|
||||
|
||||
public function testVariableVariationInTrailingSlashWithMethods()
|
||||
{
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('foo', new Route('/{foo}/', [], [], [], '', [], ['POST']));
|
||||
$coll->add('bar', new Route('/{foo}', [], [], [], '', [], ['GET']));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'POST'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'foo'], $matcher->match('/bar/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'bar'], $matcher->match('/bar'));
|
||||
}
|
||||
|
||||
public function testVariableVariationInTrailingSlashWithMethodsInReverse()
|
||||
{
|
||||
// The order should not matter
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('bar', new Route('/{foo}', [], [], [], '', [], ['GET']));
|
||||
$coll->add('foo', new Route('/{foo}/', [], [], [], '', [], ['POST']));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'POST'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'foo'], $matcher->match('/bar/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'bar'], $matcher->match('/bar'));
|
||||
}
|
||||
|
||||
public function testMixOfStaticAndVariableVariationInTrailingSlashWithHosts()
|
||||
{
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('foo', new Route('/foo/', [], [], [], 'foo.example.com'));
|
||||
$coll->add('bar', new Route('/{foo}', [], [], [], 'bar.example.com'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'foo.example.com'));
|
||||
$this->assertEquals(['_route' => 'foo'], $matcher->match('/foo/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'bar.example.com'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'bar'], $matcher->match('/bar'));
|
||||
}
|
||||
|
||||
public function testMixOfStaticAndVariableVariationInTrailingSlashWithMethods()
|
||||
{
|
||||
$coll = new RouteCollection();
|
||||
$coll->add('foo', new Route('/foo/', [], [], [], '', [], ['POST']));
|
||||
$coll->add('bar', new Route('/{foo}', [], [], [], '', [], ['GET']));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'POST'));
|
||||
$this->assertEquals(['_route' => 'foo'], $matcher->match('/foo/'));
|
||||
|
||||
$matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET'));
|
||||
$this->assertEquals(['foo' => 'bar', '_route' => 'bar'], $matcher->match('/bar'));
|
||||
$this->assertEquals(['foo' => 'foo', '_route' => 'bar'], $matcher->match('/foo'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Symfony\Component\Routing\Exception\ResourceNotFoundException
|
||||
*/
|
||||
|
Reference in New Issue
Block a user