Skip to content

Commit 3aaa111

Browse files
committed
fix(routing): Fix handling of POST requests
1 parent b585ae1 commit 3aaa111

File tree

2 files changed

+104
-4
lines changed

2 files changed

+104
-4
lines changed

graphql.services.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ services:
8080
# Upcasting for graphql query request parameters.
8181
graphql.route_enhancer.query:
8282
class: Drupal\graphql\Routing\QueryRouteEnhancer
83-
arguments: ['@graphql.query_provider']
83+
arguments: ['%cors.config%']
8484
tags:
8585
- { name: route_enhancer }
8686

src/Routing/QueryRouteEnhancer.php

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,31 @@
22

33
namespace Drupal\graphql\Routing;
44

5+
use Asm89\Stack\CorsService;
56
use Drupal\Component\Utility\NestedArray;
67
use Drupal\Core\Routing\EnhancerInterface;
78
use Drupal\Core\Routing\RouteObjectInterface;
89
use Drupal\graphql\Utility\JsonHelper;
910
use GraphQL\Server\Helper;
1011
use Symfony\Component\HttpFoundation\Request;
11-
12+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
1213

1314
class QueryRouteEnhancer implements EnhancerInterface {
1415

16+
/**
17+
* The CORS options for Origin header checking.
18+
*
19+
* @var array
20+
*/
21+
protected $corsOptions;
22+
23+
/**
24+
* Constructor.
25+
*/
26+
public function __construct(array $corsOptions) {
27+
$this->corsOptions = $corsOptions;
28+
}
29+
1530
/**
1631
* {@inheritdoc}
1732
*/
@@ -21,6 +36,10 @@ public function enhance(array $defaults, Request $request) {
2136
return $defaults;
2237
}
2338

39+
if ($request->getMethod() === "POST") {
40+
$this->assertValidPostRequestHeaders($request);
41+
}
42+
2443
$helper = new Helper();
2544
$method = $request->getMethod();
2645
$body = $this->extractBody($request);
@@ -37,8 +56,89 @@ public function enhance(array $defaults, Request $request) {
3756
}
3857

3958
return $defaults + [
40-
'operations' => $operations,
41-
];
59+
'operations' => $operations,
60+
];
61+
}
62+
63+
/**
64+
* Ensures that the headers for a POST request have triggered a preflight.
65+
*
66+
* POST requests must be submitted with content-type headers that properly
67+
* trigger a cross-origin preflight request. In case content-headers are used
68+
* that would trigger a "simple" request then custom headers must be provided.
69+
*
70+
* @param \Symfony\Component\HttpFoundation\Request $request
71+
* The request to check.
72+
*
73+
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
74+
* In case the headers indicated a preflight was not performed.
75+
*/
76+
protected function assertValidPostRequestHeaders(Request $request): void {
77+
$content_type = $request->headers->get('content-type');
78+
if ($content_type === NULL) {
79+
throw new BadRequestHttpException("GraphQL requests must specify a valid content type header.");
80+
}
81+
82+
// application/graphql is a non-standard header that's supported by our
83+
// server implementation and triggers CORS.
84+
if ($content_type === "application/graphql") {
85+
return;
86+
}
87+
88+
/** @phpstan-ignore-next-line */
89+
$content_format = method_exists($request, 'getContentTypeFormat') ? $request->getContentTypeFormat() : $request->getContentType();
90+
if ($content_format === NULL) {
91+
// Symfony before 5.4 does not detect "multipart/form-data", check for it
92+
// manually.
93+
if (stripos($content_type, 'multipart/form-data') === 0) {
94+
$content_format = 'form';
95+
}
96+
else {
97+
throw new BadRequestHttpException("The content type '$content_type' is not supported.");
98+
}
99+
}
100+
101+
// JSON requests provide a non-standard header that trigger CORS.
102+
if ($content_format === "json") {
103+
return;
104+
}
105+
106+
// The form content types are considered simple requests and don't trigger
107+
// CORS pre-flight checks, so these require a separate header to prevent
108+
// CSRF. We need to support "form" for file uploads.
109+
if ($content_format === "form") {
110+
// If the client set a custom header then we can be sure CORS was
111+
// respected.
112+
$custom_headers = [
113+
'Apollo-Require-Preflight',
114+
'X-Apollo-Operation-Name',
115+
'x-graphql-yoga-csrf',
116+
];
117+
foreach ($custom_headers as $custom_header) {
118+
if ($request->headers->has($custom_header)) {
119+
return;
120+
}
121+
}
122+
// 1. Allow requests that have set no Origin header at all, for example
123+
// server-to-server requests.
124+
// 2. Allow requests where the Origin matches the site's domain name.
125+
$origin = $request->headers->get('Origin');
126+
if ($origin === NULL || $request->getSchemeAndHttpHost() === $origin) {
127+
return;
128+
}
129+
// Allow other origins as configured in the CORS policy.
130+
if (!empty($this->corsOptions['enabled'])) {
131+
$cors_service = new CorsService($this->corsOptions);
132+
// Drupal 9 compatibility, method name has changed in Drupal 10.
133+
/** @phpstan-ignore-next-line */
134+
if ($cors_service->isActualRequestAllowed($request)) {
135+
return;
136+
}
137+
}
138+
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.");
139+
}
140+
141+
throw new BadRequestHttpException("The content type '$content_type' is not supported.");
42142
}
43143

44144
/**

0 commit comments

Comments
 (0)