Updated: Comment #N

Problem/Motivation

In Drupal 7 we had 2 protections against DoS attacks from people putting paths with a large number of parts into the Drupal URL and forcing the menu router to try to find them.

  1. We had the MENU_MAX_PARTS limit of 7 (later 9)
  2. We have the menu_masks from the variable which we use to filter out path patterns the do not exists in the router

In Drupal 8 we have removed both of these leaving us vulnerable. The problem is that the Drupal\Core\Routing\RouteProvider generates an IN() query whose length grows exponentially with the number of path parts.

Proposed resolution

record in State the max number of parts of a path - 404 immediately if there are more parts in the requested path.

reduce the length of the IN query. It seems that the current code is a carry-over when we allowed matching on a path that had unmatched trailing path parts. We can reduce this from a exponential number of parts to a linear number.

Remaining tasks

Write the code - test the 404 for an over-long path.

User interface changes

N/A

API changes

N/A

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

dawehner’s picture

reduce the length of the IN query. It seems that the current code is a carry-over when we allowed matching on a path that had unmatched trailing path parts. We can reduce this from a exponential number of parts to a linear number.

Not really. Let's have the following examples of routes:

  • '/some/path/{value}' with {value} being optional
  • '/some/path/{foo}' with {foo} not being optional
  • '/some/path/here'

In order to find the first one we don't store the {value} as placeholder, so '/some/path' also matches. One strategy certainly could be to
store both the optional and not optional elements in the DB and limit the lenght of the ancestors, though this seems to be worse how we do it at the moment.

pwolanin’s picture

Issue summary: View changes

Updating the summary after discussion with Daniel

dawehner’s picture

Issue summary: View changes
Status: Active » Needs review
FileSize
19.13 KB

Status: Needs review » Needs work

The last submitted patch, 3: routing-2224777-2.patch, failed testing.

dawehner’s picture

Status: Needs work » Needs review
FileSize
19.32 KB

.

Status: Needs review » Needs work

The last submitted patch, 5: routing-2224777-5.patch, failed testing.

pwolanin’s picture

Per discussion with klausi:

The path column in the table should change to be the 1st path part.

we should create an index on num_parts and 1st path part

route name needs to change to an index, not unique.

For routes with optional trailing parts, we insert multiple rows, so we can match just on the number of parts in the request path.

The query to find the route matches the number of parts and the 1st path part (which can never be a wildcard).

This way we avoid the need to use state AND avoid the need to build menu masks to achieve even better performance at the cost of a few extra DB rows.

pwolanin’s picture

Status: Needs work » Needs review
FileSize
8.32 KB
22.84 KB

Working on this more, the plan above seems wrong. We should just go back to something like the masks from Drupal 7.

Updated patch attached.

Status: Needs review » Needs work

The last submitted patch, 8: routing-2224777-8.patch, failed testing.

pwolanin’s picture

Status: Needs work » Needs review
FileSize
612 bytes
22.44 KB

oops, let's not delete the path column yet.

Status: Needs review » Needs work

The last submitted patch, 10: routing-2224777-10.patch, failed testing.

klausi’s picture

  1. +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/MatcherDumperTest.php
    @@ -7,6 +7,8 @@
    @@ -27,6 +29,13 @@ class MatcherDumperTest extends UnitTestBase {
    

    This test case should also check the state variable routing.menu_masks after the dump() invocation to make sure it exists and is populated as expected.

  2. +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/MatcherDumperTest.php
    @@ -143,11 +153,31 @@ public function testDump() {
       /**
    +   * Tests the determination of the max parts length.
    +   */
    +  public function testMaxPathPartsLength() {
    

    So that does not really belong into this patch, because we are not doing the max parts thing yet.

pwolanin’s picture

Status: Needs work » Needs review
FileSize
2.93 KB
22.92 KB

fixes test and fit logic.

klausi’s picture

Looks very good!

+++ b/core/lib/Drupal/Core/Routing/RouteProvider.php
@@ -202,7 +209,27 @@ public function getCandidateOutlines(array $parts) {

The only thing missing is now a security test for this function: pass it a higher number of parts (15?) and check that it does not return a gazillion candidate outlines. That way we protect ourselves from future DoS regressions.

pwolanin’s picture

FileSize
5.14 KB
25.49 KB

Here's with added test cases. Also removed the check against max mask length - turns out that would exclude routes with trailing optional parts.

Status: Needs review » Needs work

The last submitted patch, 15: routing-2224777-15.patch, failed testing.

klausi’s picture

Status: Needs work » Needs review

15: routing-2224777-15.patch queued for re-testing.

Status: Needs review » Needs work

The last submitted patch, 15: routing-2224777-15.patch, failed testing.

dawehner’s picture

Status: Needs work » Needs review

15: routing-2224777-15.patch queued for re-testing.

Status: Needs review » Needs work

The last submitted patch, 15: routing-2224777-15.patch, failed testing.

pwolanin’s picture

Status: Needs work » Needs review
FileSize
25.49 KB

re-posting the patch so the last segfult is not obscured.

klausi’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: -Security +Security improvements

Discussed this extensively at the dev days sprint, and this looks good now. It covers the DoS security issue and brings back a nice performance improvement from D7.

pwolanin’s picture

pwolanin’s picture

dawehner’s picture

+1 for the current way. Further optimizations can come in.

catch’s picture

+++ b/core/lib/Drupal/Core/Routing/RouteProvider.php
@@ -202,7 +209,25 @@ public function getCandidateOutlines(array $parts) {
+    elseif ($number_parts <= 3) {
+      // Optimization - don't query the state system for short paths. This also
+      // protects against the masks going missing for common user-facing paths.
+      $masks = range($end, 1);
+    }

This is nice, I was concerned about the extra state get on every page, then saw this :)

Giving this a bit more time before commit, but no complaints from me.

Crell’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/lib/Drupal/Core/Routing/RouteProvider.php
    @@ -202,7 +209,25 @@ public function getCandidateOutlines(array $parts) {
    +      // Optimization - don't query the state system for short paths. This also
    +      // protects against the masks going missing for common user-facing paths.
    

    How does it protect against that?

  2. +++ b/core/lib/Drupal/Core/Routing/RouteProvider.php
    @@ -202,7 +209,25 @@ public function getCandidateOutlines(array $parts) {
    +    else {
    +      if ($number_parts <= 0) {
    +        // No path can match, short-circuit the process.
    +        $masks = array();
    +      }
    +      else {
    +        // Get the actual patterns that exist out of state.
    +        $masks = (array) $this->state->get('routing.menu_masks.' . $this->tableName, array());
    +      }
    +    }
    

    This shouldn't be an embedded if. Just another elseif.

  3. +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/MatcherDumperTest.php
    @@ -143,11 +153,40 @@ public function testDump() {
    +      bindec('1011111'),
    +      bindec('10111'),
    +      bindec('111'),
    +      bindec('101'),
    

    These can be integer literals in 5.4 thanks to the new syntax: http://php.net/manual/en/migration54.new-features.php

    0b10111 (etc.)

Going just by the patch, I have NFI what this code is doing or why. Without the issue summary or the explanation I just got in IRC, this is all just so much extra nonsense. That's why I removed it from the routing system when porting from the old one to the new one; I couldn't figure out what it was actually doing for functionality, I didn't need it, so I dropped it.

Before this goes in it MUST have better docs to explain what a mask is and WHY we're caching them and doing all of this stuff. As is, from the code I couldn't tell you which means it's going to get optimized out again by some well-meaning developer who doesn't know that it's there for DDOS reasons. There is absolutely nothing in the code or comments right now that even hints at DDOS as a reason.

pwolanin’s picture

Status: Needs work » Needs review
FileSize
2.96 KB
26.63 KB

@Crell - the replacement was an exponential O(2^n) algorithm, which should be obviously unacceptable. If you had NFI what it was doing, you could have asked.

I don't think the binary number format is clearer than bindec() - this is just test code, so not a performance issue.

Here's a patch with added comments and making the branch into an elseif

dawehner’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: +beta blocker

No matter what we do here, it can't get worse than it is now.
@crell
Just in case you want to reproduce that DDOS attack goto /node/1/foo/bar/baz/foo/bar/baz/foo/bar/baz/foo/bar/baz/foo/bar/baz/foo/bar/baz/foo/bar/baz/

catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed/pushed to 8.x, thanks!

  • Commit 8ad79ce on 8.x by catch:
    Issue #2224777 by pwolanin, dawehner: Unlimited allowed length of path...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.