diff --git a/graphql.services.yml b/graphql.services.yml index 083782704..7bfe007c6 100644 --- a/graphql.services.yml +++ b/graphql.services.yml @@ -80,7 +80,7 @@ services: # Upcasting for graphql query request parameters. graphql.route_enhancer.query: class: Drupal\graphql\Routing\QueryRouteEnhancer - arguments: ['@graphql.query_provider'] + arguments: ['%cors.config%'] tags: - { name: route_enhancer } diff --git a/src/Routing/QueryRouteEnhancer.php b/src/Routing/QueryRouteEnhancer.php index 2b0bca8be..b92c0f50e 100644 --- a/src/Routing/QueryRouteEnhancer.php +++ b/src/Routing/QueryRouteEnhancer.php @@ -2,16 +2,31 @@ 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\Utility\JsonHelper; use GraphQL\Server\Helper; use Symfony\Component\HttpFoundation\Request; - +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class QueryRouteEnhancer implements EnhancerInterface { + /** + * The CORS options for Origin header checking. + * + * @var array + */ + protected $corsOptions; + + /** + * Constructor. + */ + public function __construct(array $corsOptions) { + $this->corsOptions = $corsOptions; + } + /** * {@inheritdoc} */ @@ -21,6 +36,10 @@ public function enhance(array $defaults, Request $request) { return $defaults; } + if ($request->getMethod() === "POST") { + $this->assertValidPostRequestHeaders($request); + } + $helper = new Helper(); $method = $request->getMethod(); $body = $this->extractBody($request); @@ -37,8 +56,89 @@ public function enhance(array $defaults, Request $request) { } return $defaults + [ - 'operations' => $operations, - ]; + '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."); } /**