Repository: wp-graphql/wp-graphql (language: php)

Issue:
Missing Content-Type request header results in 500 response code
The `Content-Type` header is rightfully required to properly interpret the POST body of a GraphQL request. When this header is not specified, the response includes a 500 response code indicating a server error. Since this error is caused by a misbehaving client, I would expect a response in the 400 range indicating a client error. Specifically, I expect either a 400 directly (Bad Request) or a 415 (Unsupported Media Type). 

I discovered this using a testing tool that didn't send the `Content-Type` header automatically, but this can be easily reproduced from a shell with curl by suppressing the default header:

`$ curl --request POST --data '{"query": "query QP { posts(first:10) { edges { node { id } } } }"}' -H 'Content-Type:' -o /dev/null -w "%{http_code}" $GRAPHQL_ENDPOINT_URL`

WP GraphQL Version: 1.1.2
Platform: WP Engine; PHP 7.4



Discussion / hints:
Related: https://github.com/apollographql/apollo-server/issues/1841#issuecomment-525258746
Somewhat related: https://github.com/webonyx/graphql-php/issues/309
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

This issue has been automatically closed because it has not had recent activity.  If you believe this issue is still valid, please open a new issue and mark this as a related issue.

I'm currently running into this...

That said GraphiQL is doing this and it even appears to be sending the header...

This is a very special situation. See:

https://playground.wordpress.net/?php=latest&wp=latest&plugin=wp-graphql&url=%2Fwp-admin%2Fadmin.php%3Fpage%3Dgraphiql-ide&mode=browser&login=1
I was able to replicate locally: `curl -o -  --request POST --data '{"query": "query QP { posts(first:10) { edges { node { id } } } }"
}' -H 'Content-Type:' -o /dev/null -w "%{http_code}" http://wptest.dvl.to/graphql`
![image](https://github.com/wp-graphql/wp-graphql/assets/29322304/0ab3ad60-ffbc-4a0e-b496-e69b4c052456)

But according to my code search, thats coming from [graphql-php](https://github.com/webonyx/graphql-php/blob/d9c2fdebc6aa01d831bc2969da00e8588cffef19/src/Server/Helper.php#L106).

@jasonbahl what did you have in mind when you marked this `actionable`? 
@moonmeister everything works fine for me at that link (no errored responses at all). Are there additional steps that need to happen to replicate?
@justlevine Sorry, it got fixed. The issue was Playground and PHP renaming `HTTP_Content_Type` headers to `Content_Type`. see https://github.com/WordPress/wordpress-playground/issues/480
My issue was on the server, not the client, I think the question becomes. Is a 500 the correct response or should WPGraphQL be catching this from GraphQL PHP and returning a 4xx error.
Per today's triage:

The ability to configure whether these sorts of errors should return a `500` or something else has been added upstream in https://github.com/webonyx/graphql-php/pull/1167 . Once we're able to update we should hopefully be able to expose this.



Fix patch:
diff --git a/src/Request.php b/src/Request.php
index 699c2a91c..d0360db6b 100644
--- a/src/Request.php
+++ b/src/Request.php
@@ -678,6 +678,10 @@ function ( $data ) {
 	 * @throws \Exception
 	 */
 	public function execute_http() {
+		if ( ! $this->is_valid_http_content_type() ) {
+			return $this->get_invalid_content_type_response();
+		}
+
 		/**
 		 * Parse HTTP request.
 		 */
@@ -705,6 +709,69 @@ public function execute_http() {
 		return $this->after_execute( $response );
 	}
 
+	/**
+	 * Validates the content type for HTTP POST requests
+	 */
+	private function is_valid_http_content_type(): bool {
+		if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
+			return true;
+		}
+
+		$content_type = $this->get_content_type();
+		if ( empty( $content_type ) ) {
+			return false;
+		}
+
+		return 0 === stripos( $content_type, 'application/json' );
+	}
+
+	/**
+	 * Gets the content type from the request headers
+	 */
+	private function get_content_type(): string {
+		if ( isset( $_SERVER['CONTENT_TYPE'] ) ) {
+			return sanitize_text_field( $_SERVER['CONTENT_TYPE'] );
+		}
+
+		if ( isset( $_SERVER['HTTP_CONTENT_TYPE'] ) ) {
+			return sanitize_text_field( $_SERVER['HTTP_CONTENT_TYPE'] );
+		}
+
+		return '';
+	}
+
+	/**
+	 * Returns the error response for invalid content type
+	 *
+	 * @return array<string,mixed>
+	 */
+	private function get_invalid_content_type_response(): array {
+		$content_type = $this->get_content_type();
+
+		/**
+		 * Filter the status code to return when the content type is invalid
+		 *
+		 * @param int    $status_code The status code to return
+		 * @param string $content_type The content type header value that was received
+		 */
+		$filtered_status_code = apply_filters( 'graphql_invalid_content_type_status_code', 415, $content_type );
+
+		// validate that the status code is in valid http status code ranges (100-599)
+		if ( is_numeric( $filtered_status_code ) && ( $filtered_status_code > 100 && $filtered_status_code < 599 ) ) {
+			// Set status code to 415 (Unsupported Media Type)
+			Router::$http_status_code = $filtered_status_code;
+		}
+
+		return [
+			'errors' => [
+				[
+					// translators: %s is the content type header value that was received
+					'message' => sprintf( esc_html__( 'HTTP POST requests must have Content-Type: application/json header. Received: %s', 'wp-graphql' ), $content_type ),
+				],
+			],
+		];
+	}
+
 	/**
 	 * Get the operation params for the request.
 	 *
diff --git a/src/Router.php b/src/Router.php
index c1f30bb62..66cb78f91 100644
--- a/src/Router.php
+++ b/src/Router.php
@@ -282,10 +282,19 @@ public static function send_header( $key, $value ) {
 	/**
 	 * Sends an HTTP status code.
 	 *
+	 * @param int|null $status_code The status code to send.
+	 *
 	 * @return void
 	 */
-	protected static function set_status() {
-		status_header( self::$http_status_code );
+	protected static function set_status( ?int $status_code = null ) {
+		$status_code = null === $status_code ? self::$http_status_code : $status_code;
+
+		// validate that the status code is a valid http status code
+		if ( ! is_numeric( $status_code ) || $status_code < 100 || $status_code > 599 ) {
+			$status_code = 500;
+		}
+
+		status_header( $status_code );
 	}
 
 	/**
@@ -365,7 +374,7 @@ public static function set_headers() {
 			/**
 			 * Set the HTTP response status
 			 */
-			self::set_status();
+			self::set_status( self::$http_status_code );
 
 			/**
 			 * Get the response headers


Test patch:
diff --git a/tests/functional/ContentTypeHeaderCept.php b/tests/functional/ContentTypeHeaderCept.php
new file mode 100644
index 000000000..ce346b132
--- /dev/null
+++ b/tests/functional/ContentTypeHeaderCept.php
@@ -0,0 +1,47 @@
+<?php
+
+$I = new FunctionalTester($scenario);
+
+$I->wantTo('Ensure 415 status code is returned when content-type header is missing or incorrect');
+
+// Test with no content-type header
+$I->sendPOST('http://localhost/graphql', json_encode([
+    'query' => '{posts{nodes{id}}}'
+]));
+$I->seeResponseCodeIs(415);
+$I->seeResponseContainsJson([
+    'errors' => [
+        [
+            'message' => 'HTTP POST requests must have Content-Type: application/json header. Received: '
+        ]
+    ]
+]);
+
+// Test with incorrect content-type header
+$I->haveHttpHeader('Content-Type', 'text/plain');
+$I->sendPOST('http://localhost/graphql', json_encode([
+    'query' => '{posts{nodes{id}}}'
+]));
+$I->seeResponseCodeIs(415);
+$I->seeResponseContainsJson([
+    'errors' => [
+        [
+            'message' => 'HTTP POST requests must have Content-Type: application/json header. Received: text/plain'
+        ]
+    ]
+]);
+
+// Verify that application/json content-type works correctly
+$I->haveHttpHeader('Content-Type', 'application/json');
+$I->sendPOST('http://localhost/graphql', json_encode([
+    'query' => '{posts{nodes{id}}}'
+]));
+$I->seeResponseCodeIs(200);
+
+// Verify that application/json with charset works
+$I->haveHttpHeader('Content-Type', 'application/json; charset=utf-8');
+$I->sendPOST('http://localhost/graphql', json_encode([
+    'query' => '{posts{nodes{id}}}'
+]));
+$I->seeResponseCodeIs(200);
+