I’ve observed that normal errors in Apollo Server trigger the logging behavior in didEncounterErrors
method, while the CSRF-related error does not.
In this scenario, where a subgraph in the cloud is requested by many internal and external services, it’s a must to identify headers and routes from the services doing the requests.
The most simple and “logical” dev action is extending the error logging with context data, but this affects the resolvers, and they are not reached in the CSRF alert scenario, because the flow stops before reaching them. We could try intercepting the built-in error logic using a built-in hook in the “plugins” array, but it’s not reached in the CSRF alert scenario, because the flow jumps over them. We could try extending the contextual data provided to the error object, but it’s not enabled in the CSRF alert scenario, because the flow doesn’t give the data to the error object.
So, in this scenario, it’s imperative to know which of the many services calling the subgraph is causing the CSRF error to happen, even if it’s a false positive (lack of Content-Type header) or if it’s a real CSRF attack. How can we do that when it isn’t possible to log the headers of the incoming request raising the alert?
Verify this code: It doesn’t log for CSRF alert, steps to reproduce:
- Copy/paste the requestHook into your server file, and include it in the plugins array
- Include the formatError in your server config
- Cases:
a. Positive case: Trigger a wrong query, the headers will be shown, and all the logs will be logged.
b. Negative case: Trigger a CSRF alert (execute the same query with “Content-Type” equal to “application/x-www-form-urlencoded”), the headers are undefined/empty, and none of the logs will be logged.
const requestHook = {
async requestDidStart(requestContext) {
console.log(">>>>> Incoming Headers:", requestContext.request.http.headers);
return {
async didEncounterErrors(requestContext) {
console.error(">>>>> if statement in didEncounterErrors")
const http = requestContext.request.http;
if (http) {
requestContext.errors.forEach((error) => {
if (!error.extensions) {
error.extensions = {};
}
error.extensions.headers = http.headers || {};
error.extensions.url = http.url || 'Unknown URL';
});
}
else {
console.error(">>>>> else statement in didEncounterErrors")
}
},
};
},
}
// Set up Apollo Server
const server = new ApolloServer({
...other configs...
formatError: (error, context: CustomError) => {
if (error.extensions?.statusCode === 404) {
console.warn(error.message);
} else {
const { headers, url } = error.extensions
const errorDetails = {
errorMessage: error.message,
requestDetails: {
status: context?.extensions.http.status,
code: context?.extensions.code,
headers: headers || 'Headers not available',
url: url || 'URL not available',
},
error: error
};
console.error('Error details:', errorDetails);
}
return error;
},
// plugins array
plugins: [requestHook, ...your other stuff],
...other configs...
});
Error examples:
- Positive case: wrong query with Content-Type application/json
// Query
query{__schema}
// Error
>>>>> Incoming Headers: HeaderMap2(8) [Map] {
'content-type' => 'application/json',
'user-agent' => 'PostmanRuntime/7.37.3',
'accept' => '*/*',
'host' => 'localhost:3001',
'accept-encoding' => 'gzip, deflate, br',
'connection' => 'keep-alive',
'content-length' => '517',
__identity: Symbol(HeaderMap)
}
>>>>> if statement in didEncounterErrors
Error details: {
errorMessage: 'Field "__schema" of type "__Schema!" must have a selection of subfields. Did you mean "__schema { ... }"?',
requestDetails: {
status: 400,
code: 'GRAPHQL_VALIDATION_FAILED',
headers: HeaderMap2(8) [Map] {
'content-type' => 'application/json',
'user-agent' => 'PostmanRuntime/7.37.3',
'accept' => '*/*',
'host' => 'localhost:3001',
'accept-encoding' => 'gzip, deflate, br',
'connection' => 'keep-alive',
'content-length' => '220',
__identity: Symbol(HeaderMap)
},
url: 'Unknown URL'
},
error: {
message: 'Field "__schema" of type "__Schema!" must have a selection of subfields. Did you mean "__schema { ... }"?',
locations: [ [Object] ],
extensions: {
code: 'GRAPHQL_VALIDATION_FAILED',
headers: [HeaderMap2 [Map]],
url: 'Unknown URL'
}
}
}
- Negative case: wrong query with Content-Type application/x-www-form-urlencoded
// Query
query{__schema}
// Error
Error details: {
errorMessage: "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please either specify a 'content-type' header (with a type that is not one of application/x-www-form-urlencoded, multipart/form-data, text/plain) or provide a non-empty value for one of the following headers: x-apollo-operation-name, apollo-require-preflight\n",
requestDetails: {
status: 400,
code: 'BAD_REQUEST',
headers: 'Headers not available',
url: 'URL not available'
},
error: {
message: "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please either specify a 'content-type' header (with a type that is not one of application/x-www-form-urlencoded, multipart/form-data, text/plain) or provide a non-empty value for one of the following headers: x-apollo-operation-name, apollo-require-preflight\n",
extensions: { code: 'BAD_REQUEST' }
}
}
Side note: In the Apollo docs, the context object is shown as valid in Typescript but it raises
Object literal may only specify known properties, and 'context' does not exist in type 'ApolloServerOptionsWithSchema<BaseContext>'.ts(2353)