Commit 23765209 authored by Klaus Purer's avatar Klaus Purer
Browse files

fix(routing): Fix handling of POST requests

parent f55a29d5
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -87,7 +87,7 @@ services:
  # Upcasting for graphql query request parameters.
  graphql.route_enhancer.query:
    class: Drupal\graphql\Routing\QueryRouteEnhancer
    arguments: ['@request_stack']
    arguments: ['%cors.config%']
    tags:
      - { name: route_enhancer }

+97 −0
Original line number Diff line number Diff line
@@ -2,12 +2,14 @@

namespace Drupal\graphql\Routing;

use Asm89\Stack\CorsService;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Routing\EnhancerInterface;
use Drupal\Core\Routing\RouteObjectInterface;
use Drupal\graphql\GraphQL\Utility\JsonHelper;
use GraphQL\Server\Helper;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Route;

/**
@@ -15,6 +17,20 @@ use Symfony\Component\Routing\Route;
 */
class QueryRouteEnhancer implements EnhancerInterface {

  /**
   * The CORS options for Origin header checking.
   *
   * @var array
   */
  protected $corsOptions;

  /**
   * Constructor.
   */
  public function __construct(array $corsOptions) {
    $this->corsOptions = $corsOptions;
  }

  /**
   * Returns whether the enhancer runs on the current route.
   *
@@ -38,6 +54,10 @@ class QueryRouteEnhancer implements EnhancerInterface {
      return $defaults;
    }

    if ($request->getMethod() === "POST") {
      $this->assertValidPostRequestHeaders($request);
    }

    $helper = new Helper();
    $method = $request->getMethod();
    $body = $this->extractBody($request);
@@ -47,6 +67,83 @@ class QueryRouteEnhancer implements EnhancerInterface {
    return $defaults + ['operations' => $operations];
  }

  /**
   * Ensures that the headers for a POST request have triggered a preflight.
   *
   * POST requests must be submitted with content-type headers that properly
   * trigger a cross-origin preflight request. In case content-headers are used
   * that would trigger a "simple" request then custom headers must be provided.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request to check.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
   *    In case the headers indicated a preflight was not performed.
   */
  protected function assertValidPostRequestHeaders(Request $request) : void {
    $content_type = $request->headers->get('content-type');
    if ($content_type === NULL) {
      throw new BadRequestHttpException("GraphQL requests must specify a valid content type header.");
    }

    // application/graphql is a non-standard header that's supported by our
    // server implementation and triggers CORS.
    if ($content_type === "application/graphql") {
      return;
    }

    /** @phpstan-ignore-next-line */
    $content_format = method_exists($request, 'getContentTypeFormat') ? $request->getContentTypeFormat() : $request->getContentType();
    if ($content_format === NULL) {
      // Symfony before 5.4 does not detect "multipart/form-data", check for it
      // manually.
      if (stripos($content_type, 'multipart/form-data') === 0) {
        $content_format = 'form';
      }
      else {
        throw new BadRequestHttpException("The content type '$content_type' is not supported.");
      }
    }

    // JSON requests provide a non-standard header that trigger CORS.
    if ($content_format === "json") {
      return;
    }

    // The form content types are considered simple requests and don't trigger
    // CORS pre-flight checks, so these require a separate header to prevent
    // CSRF. We need to support "form" for file uploads.
    if ($content_format === "form") {
      // If the client set a custom header then we can be sure CORS was
      // respected.
      $custom_headers = ['Apollo-Require-Preflight', 'X-Apollo-Operation-Name', 'x-graphql-yoga-csrf'];
      foreach ($custom_headers as $custom_header) {
        if ($request->headers->has($custom_header)) {
          return;
        }
      }
      // 1. Allow requests that have set no Origin header at all, for example
      // server-to-server requests.
      // 2. Allow requests where the Origin matches the site's domain name.
      $origin = $request->headers->get('Origin');
      if ($origin === NULL || $request->getSchemeAndHttpHost() === $origin) {
        return;
      }
      // Allow other origins as configured in the CORS policy.
      if (!empty($this->corsOptions['enabled'])) {
        $cors_service = new CorsService($this->corsOptions);
        // Drupal 9 compatibility, method name has changed in Drupal 10.
        /** @phpstan-ignore-next-line */
        if ($cors_service->isActualRequestAllowed($request)) {
          return;
        }
      }
      throw new BadRequestHttpException("Form requests must include a Apollo-Require-Preflight HTTP header or the Origin HTTP header value needs to be in the allowedOrigins in the CORS settings.");
    }

    throw new BadRequestHttpException("The content type '$content_type' is not supported.");
  }

  /**
   * Extracts the query parameters from a request.
   *
+2 −2
Original line number Diff line number Diff line
@@ -72,7 +72,7 @@ class AutomaticPersistedQueriesTest extends GraphQLTestBase {

    // Post query to endpoint with a not matching hash.
    $content = json_encode(['query' => $query] + $parameters);
    $request = Request::create($endpoint, 'POST', [], [], [], [], $content);
    $request = Request::create($endpoint, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $content);
    $result = $this->container->get('http_kernel')->handle($request);
    $this->assertSame(200, $result->getStatusCode());
    $this->assertSame([
@@ -88,7 +88,7 @@ class AutomaticPersistedQueriesTest extends GraphQLTestBase {
    $parameters['extensions']['persistedQuery']['sha256Hash'] = hash('sha256', $query);

    $content = json_encode(['query' => $query] + $parameters);
    $request = Request::create($endpoint, 'POST', [], [], [], [], $content);
    $request = Request::create($endpoint, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $content);
    $result = $this->container->get('http_kernel')->handle($request);
    $this->assertSame(200, $result->getStatusCode());
    $this->assertSame(['data' => ['field_one' => 'this is the field one']], json_decode($result->getContent(), TRUE));
+181 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\graphql\Kernel\Framework;

use Drupal\graphql\Routing\QueryRouteEnhancer;
use Drupal\Tests\graphql\Kernel\GraphQLTestBase;
use Symfony\Component\HttpFoundation\Request;

/**
 * Test CSRF protection on mutations.
 *
 * @group graphql
 */
class CsrfTest extends GraphQLTestBase {

  /**
   * Helper state variable that will be flipped when the test mutation executes.
   *
   * @var bool
   */
  protected $mutationTriggered = FALSE;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $schema = <<<GQL
      schema {
        mutation: Mutation
      }

      type Mutation {
        write: Boolean
      }
GQL;
    $this->setUpSchema($schema);
    $this->mockResolver('Mutation', 'write',
      function () {
        $this->mutationTriggered = TRUE;
        return TRUE;
      }
    );
  }

  /**
   * Tests that a simple request from an evil origin is not executed.
   *
   * @dataProvider provideSimpleContentTypes
   */
  public function testEvilOrigin(string $content_type): void {
    $request = Request::create('https://example.com/graphql/test', 'POST', [], [], [], [
      'CONTENT_TYPE' => $content_type,
      'HTTP_ORIGIN' => 'https://evil.example.com',
    ], '{ "query": "mutation { write }" }');

    /** @var \Symfony\Component\HttpFoundation\Response $response */
    $response = $this->container->get('http_kernel')->handle($request);
    $this->assertFalse($this->mutationTriggered, 'Mutation was triggered');
    $this->assertSame(400, $response->getStatusCode());
  }

  /**
   * Data provider for testContentTypeCsrf().
   */
  public function provideSimpleContentTypes(): array {
    // Three content types that can be sent with simple no-cors POST requests.
    return [
      ['text/plain'],
      ['application/x-www-form-urlencoded'],
      ['multipart/form-data'],
    ];
  }

  /**
   * Tests that a simple multipart form data no-cors request is not executed.
   */
  public function testMultipartFormDataCsrf(): void {
    $request = Request::create('https://example.com/graphql/test', 'POST',
      [
        'operations' => '[{ "query": "mutation { write }" }]',
      ],
      [],
      [],
      [
        'CONTENT_TYPE' => 'multipart/form-data',
        'HTTP_ORIGIN' => 'https://evil.example.com',
      ]
    );

    /** @var \Symfony\Component\HttpFoundation\Response $response */
    $response = $this->container->get('http_kernel')->handle($request);
    $this->assertFalse($this->mutationTriggered, 'Mutation was triggered');
    $this->assertSame(400, $response->getStatusCode());
    $result = json_decode($response->getContent());
    $this->assertSame("Form requests must include a Apollo-Require-Preflight HTTP header or the Origin HTTP header value needs to be in the allowedOrigins in the CORS settings.", $result->message);
  }

  /**
   * Test that the JSON content types always work, cannot be forged with CSRF.
   *
   * @dataProvider provideAllowedJsonHeaders
   */
  public function testAllowedJsonRequests(array $headers): void {
    $request = Request::create('https://example.com/graphql/test', 'POST', [], [], [],
      $headers, '{ "query": "mutation { write }" }');

    /** @var \Symfony\Component\HttpFoundation\Response $response */
    $response = $this->container->get('http_kernel')->handle($request);
    $this->assertTrue($this->mutationTriggered, 'Mutation was triggered');
    $this->assertSame(200, $response->getStatusCode());
  }

  /**
   * Data provider for testAllowedJsonRequests().
   */
  public function provideAllowedJsonHeaders(): array {
    return [
      [['CONTENT_TYPE' => 'application/json']],
      [['CONTENT_TYPE' => 'application/graphql']],
    ];
  }

  /**
   * Test that a form request with the correct headers against CSRF are allowed.
   *
   * @dataProvider provideAllowedFormRequests
   */
  public function testAllowedFormRequests(array $headers, array $allowedDomains = []): void {
    $request = Request::create('https://example.com/graphql/test', 'POST',
      [
        'operations' => '[{ "query": "mutation { write }" }]',
      ], [], [], $headers);

    if (!empty($allowedDomains)) {
      // Replace the QueryRouteEnhancer to inject CORS config we want to test.
      $this->container->set('graphql.route_enhancer.query', new QueryRouteEnhancer([
        'enabled' => TRUE,
        'allowedOrigins' => $allowedDomains,
      ]));
    }
    /** @var \Symfony\Component\HttpFoundation\Response $response */
    $response = $this->container->get('http_kernel')->handle($request);
    $this->assertTrue($this->mutationTriggered, 'Mutation was triggered');
    $this->assertSame(200, $response->getStatusCode());
  }

  /**
   * Data provider for testAllowedFormRequests().
   */
  public function provideAllowedFormRequests(): array {
    return [
      // Omitting the Origin and Apollo-Require-Preflight is allowed.
      [['CONTENT_TYPE' => 'multipart/form-data']],
      // The custom Apollo-Require-Preflight header overrules any evil Origin
      // header.
      [[
        'CONTENT_TYPE' => 'multipart/form-data',
        'HTTP_APOLLO_REQUIRE_PREFLIGHT' => 'test',
        'HTTP_ORIGIN' => 'https://evil.example.com',
      ]],
      // The Origin header alone with the correct domain is allowed.
      [[
        'CONTENT_TYPE' => 'multipart/form-data',
        'HTTP_ORIGIN' => 'https://example.com',
      ]],
      // The Origin header with an allowed domain.
      [[
        'CONTENT_TYPE' => 'multipart/form-data',
        'HTTP_ORIGIN' => 'https://allowed.example.com',
      ], ['https://allowed.example.com']],
      // The Origin header with any allowed domain.
      [[
        'CONTENT_TYPE' => 'multipart/form-data',
        'HTTP_ORIGIN' => 'https://allowed.example.com',
      ], ['*']],
    ];
  }

}
+2 −2
Original line number Diff line number Diff line
@@ -56,7 +56,7 @@ trait HttpRequestTrait {
      $request = Request::create($endpoint, $method, $data);
    }
    else {
      $request = Request::create($endpoint, $method, [], [], [], [], json_encode($data));
      $request = Request::create($endpoint, $method, [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($data));
    }

    return $this->container->get('http_kernel')->handle($request);
@@ -81,7 +81,7 @@ trait HttpRequestTrait {

    $queries = json_encode($queries);
    $endpoint = $this->server->get('endpoint');
    $request = Request::create($endpoint, 'POST', [], [], [], [], $queries);
    $request = Request::create($endpoint, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $queries);
    return $this->container->get('http_kernel')->handle($request);
  }