Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/backend/src/checks/graphql-suggestions/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown[]> => {
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);
});
});
100 changes: 100 additions & 0 deletions packages/backend/src/checks/graphql-suggestions/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>).message;
if (typeof message === "string" && DID_YOU_MEAN_REGEX.test(message)) {
return true;
}

const extensions = (error as Record<string, unknown>).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<Record<never, never>>(({ 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,
};
});
3 changes: 3 additions & 0 deletions packages/backend/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -97,6 +99,7 @@ export const checks = [
exposedEnvScan,
gitConfigScan,
hashDisclosureScan,
graphqlSuggestionsScan,
jsonHtmlResponseScan,
missingContentTypeScan,
openRedirectScan,
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ export class ConfigStore {
checkID: Checks.MISSING_CONTENT_TYPE,
enabled: true,
},
{
checkID: Checks.GRAPHQL_SUGGESTIONS_ENABLED,
enabled: true,
},
],
},
{
Expand Down