From 551a29b0ba38175f2dcd39cc61f568f437527016 Mon Sep 17 00:00:00 2001 From: Joseph Thacker Date: Thu, 23 Oct 2025 14:09:44 -0400 Subject: [PATCH] feat: flag graphql suggestions leaks (#96) --- .../checks/graphql-suggestions/index.spec.ts | 68 ++++++++++++ .../src/checks/graphql-suggestions/index.ts | 100 ++++++++++++++++++ packages/backend/src/checks/index.ts | 3 + packages/backend/src/stores/config.ts | 4 + 4 files changed, 175 insertions(+) create mode 100644 packages/backend/src/checks/graphql-suggestions/index.spec.ts create mode 100644 packages/backend/src/checks/graphql-suggestions/index.ts diff --git a/packages/backend/src/checks/graphql-suggestions/index.spec.ts b/packages/backend/src/checks/graphql-suggestions/index.spec.ts new file mode 100644 index 0000000..cb18dfb --- /dev/null +++ b/packages/backend/src/checks/graphql-suggestions/index.spec.ts @@ -0,0 +1,68 @@ +import { createMockRequest, createMockResponse, runCheck } from "engine"; +import { describe, expect, it } from "vitest"; + +import graphqlSuggestionsCheck from "./index"; + +const executeCheck = async (body: string): Promise => { + const request = createMockRequest({ + id: "req-graphql-suggestions", + host: "example.com", + method: "POST", + path: "/graphql", + headers: { Host: ["example.com"], "Content-Type": ["application/json"] }, + }); + + const response = createMockResponse({ + id: "res-graphql-suggestions", + code: 400, + headers: { "content-type": ["application/json"] }, + body, + }); + + const execution = await runCheck(graphqlSuggestionsCheck, [ + { request, response }, + ]); + + return execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? []; +}; + +describe("GraphQL suggestions enabled check", () => { + it("detects suggestion messages", async () => { + const findings = await executeCheck( + JSON.stringify({ + errors: [ + { + message: + 'Cannot query field "uers" on type "Query". Did you mean "users"?', + }, + ], + }), + ); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + name: "GraphQL suggestions enabled", + severity: "low", + }); + }); + + it("detects didYouMean extensions", async () => { + const findings = await executeCheck( + JSON.stringify({ + errors: [ + { + message: 'Cannot query field "uers" on type "Query".', + extensions: { didYouMean: ["users"] }, + }, + ], + }), + ); + + expect(findings).toHaveLength(1); + }); + + it("ignores responses without suggestions", async () => { + const findings = await executeCheck(JSON.stringify({ errors: [] })); + expect(findings).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/checks/graphql-suggestions/index.ts b/packages/backend/src/checks/graphql-suggestions/index.ts new file mode 100644 index 0000000..830ed73 --- /dev/null +++ b/packages/backend/src/checks/graphql-suggestions/index.ts @@ -0,0 +1,100 @@ +import { defineCheck, done, Severity } from "engine"; + +import { Tags } from "../../types"; +import { keyStrategy } from "../../utils/key"; + +const DID_YOU_MEAN_REGEX = /did you mean/i; + +const hasSuggestion = (body: string): boolean => { + try { + const parsed = JSON.parse(body) as Record; + if (parsed === null || typeof parsed !== "object") { + return false; + } + + const errors = parsed.errors; + if (!Array.isArray(errors)) { + return false; + } + + for (const error of errors) { + if (error !== null && typeof error === "object") { + const message = (error as Record).message; + if (typeof message === "string" && DID_YOU_MEAN_REGEX.test(message)) { + return true; + } + + const extensions = (error as Record).extensions; + if ( + extensions !== null && + typeof extensions === "object" && + "didYouMean" in extensions + ) { + return true; + } + } + } + } catch { + // Ignore invalid JSON + } + + return DID_YOU_MEAN_REGEX.test(body); +}; + +const description = [ + "The GraphQL endpoint returns field suggestions in error responses.", + "", + "When introspection is disabled but suggestions remain active, attackers can still enumerate field names by triggering typos and reviewing the `Did you mean ...` hints.", + "", + "Disable GraphQL query suggestions in production environments to reduce information leakage.", +].join("\n"); + +export default defineCheck>(({ step }) => { + step("detectSuggestions", (state, context) => { + const { response } = context.target; + + if (response === undefined) { + return done({ state }); + } + + const body = response.getBody()?.toText(); + if (body === undefined || body.length === 0) { + return done({ state }); + } + + if (!hasSuggestion(body)) { + return done({ state }); + } + + return done({ + state, + findings: [ + { + name: "GraphQL suggestions enabled", + description, + severity: Severity.LOW, + correlation: { + requestID: context.target.request.getId(), + locations: [], + }, + }, + ], + }); + }); + + return { + metadata: { + id: "graphql-suggestions-enabled", + name: "GraphQL suggestions enabled", + description: + 'Detects GraphQL error responses that disclose field suggestions ("Did you mean ...").', + type: "passive", + tags: [Tags.INFORMATION_DISCLOSURE], + severities: [Severity.LOW], + aggressivity: { minRequests: 0, maxRequests: 0 }, + }, + initState: () => ({}), + dedupeKey: keyStrategy().withHost().withPath().build(), + when: (target) => target.response !== undefined, + }; +}); diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..e969605 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -17,6 +17,7 @@ import directoryListingScan from "./directory-listing"; import emailDisclosureScan from "./email-disclosure"; import exposedEnvScan from "./exposed-env"; import gitConfigScan from "./git-config"; +import graphqlSuggestionsScan from "./graphql-suggestions"; import hashDisclosureScan from "./hash-disclosure"; import jsonHtmlResponseScan from "./json-html-response"; import missingContentTypeScan from "./missing-content-type"; @@ -57,6 +58,7 @@ export const Checks = { EXPOSED_ENV: "exposed-env", GIT_CONFIG: "git-config", HASH_DISCLOSURE: "hash-disclosure", + GRAPHQL_SUGGESTIONS_ENABLED: "graphql-suggestions-enabled", JSON_HTML_RESPONSE: "json-html-response", MISSING_CONTENT_TYPE: "missing-content-type", OPEN_REDIRECT: "open-redirect", @@ -97,6 +99,7 @@ export const checks = [ exposedEnvScan, gitConfigScan, hashDisclosureScan, + graphqlSuggestionsScan, jsonHtmlResponseScan, missingContentTypeScan, openRedirectScan, diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..ce8c5b8 100644 --- a/packages/backend/src/stores/config.ts +++ b/packages/backend/src/stores/config.ts @@ -188,6 +188,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.GRAPHQL_SUGGESTIONS_ENABLED, + enabled: true, + }, ], }, {