Customize CSRF alerts/errors

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:

  1. Copy/paste the requestHook into your server file, and include it in the plugins array
  2. Include the formatError in your server config
  3. 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)

You can handle this error with invalidRequestWasReceived. It’s not possible to handle this with didEncounterErrors — that comes farther down the pipeline once we’ve already successfully interpreted the request (converting from HTTPGraphQLRequest to GraphQLRequest).

Unfortunately that callback doesn’t get much information about the request. I think we would take a PR that adds an httpGraphQLRequest: HTTPGraphQLRequest field to the invalidRequestWasReceived argument.

Hi @glasser
Moving the error object to invalidRequestWasReceived doesn’t provide access to the request headers or route, but it intercepts the request.

I tried to modify the library in the local environment but it doesn’t run the changes, maybe if you can support this task I could do the PR.

So, in this scenario, where many services are calling the subgraph, and one is causing the CSRF error, how can we get the headers of the incoming requests?