Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/detect-waf-block-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"wrangler": patch
---

Detect Cloudflare WAF block pages and include Ray ID in API error messages

When the Cloudflare WAF blocks an API request, the response is an HTML page rather than JSON. Previously, this caused a confusing "Received a malformed response from the API" error with a truncated HTML snippet. Wrangler now detects WAF block pages and displays a clear error message explaining that the request was blocked by the firewall, along with the Cloudflare Ray ID (when available) for use in support tickets.

For other non-JSON responses that aren't WAF blocks, the "malformed response" error also now includes the Ray ID to help reference failing requests in support tickets.
229 changes: 229 additions & 0 deletions packages/wrangler/src/__tests__/cfetch-internal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { COMPLIANCE_REGION_CONFIG_UNKNOWN } from "@cloudflare/workers-utils";
import { http, HttpResponse } from "msw";
import { describe, it } from "vitest";
import { fetchGraphqlResult } from "../cfetch";
import { extractWAFBlockRayId, isWAFBlockResponse } from "../cfetch/internal";
import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
import { msw } from "./helpers/msw";

describe("isWAFBlockResponse", () => {
it("should detect a WAF-mitigated response", ({ expect }) => {
const headers = new Headers({ "cf-mitigated": "challenge" });
expect(isWAFBlockResponse(headers)).toBe(true);
});

it("should return false when cf-mitigated header is absent", ({ expect }) => {
const headers = new Headers();
expect(isWAFBlockResponse(headers)).toBe(false);
});

it("should return false when cf-mitigated has a different value", ({
expect,
}) => {
const headers = new Headers({ "cf-mitigated": "other" });
expect(isWAFBlockResponse(headers)).toBe(false);
});
});

describe("extractWAFBlockRayId", () => {
it("should extract the Ray ID from the cf-ray header", ({ expect }) => {
const headers = new Headers({ "cf-ray": "9e8116df4823e2c5" });
expect(extractWAFBlockRayId(headers)).toBe("9e8116df4823e2c5");
});

it("should return undefined when cf-ray header is absent", ({ expect }) => {
const headers = new Headers();
expect(extractWAFBlockRayId(headers)).toBeUndefined();
});
});

describe("fetchInternal WAF block detection", () => {
mockAccountId({ accountId: null });
mockApiToken();

it("should throw a helpful error when the API returns a WAF block response", async ({
expect,
}) => {
msw.use(
http.post("*/graphql", async () => {
return new HttpResponse("blocked", {
status: 403,
statusText: "Forbidden",
headers: {
"Content-Type": "text/html",
"cf-mitigated": "challenge",
"cf-ray": "9e8116df4823e2c5",
},
});
})
);
await expect(
fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
})
).rejects.toThrow(
"The Cloudflare API responded with a WAF block page instead of the expected JSON response"
);
});

it("should include the Ray ID in the error when cf-ray header is present", async ({
expect,
}) => {
msw.use(
http.post("*/graphql", async () => {
return new HttpResponse("blocked", {
status: 403,
statusText: "Forbidden",
headers: {
"Content-Type": "text/html",
"cf-mitigated": "challenge",
"cf-ray": "9e8116df4823e2c5",
},
});
})
);
try {
await fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
});
expect.unreachable("should have thrown");
} catch (e) {
const error = e as { notes: { text: string }[] };
const rayIdNote = error.notes.find((n: { text: string }) =>
n.text.includes("Cloudflare Ray ID:")
);
expect(rayIdNote).toBeDefined();
expect(rayIdNote?.text).toBe("Cloudflare Ray ID: 9e8116df4823e2c5");
const supportNote = error.notes.find((n: { text: string }) =>
n.text.includes("open a Cloudflare Support ticket")
);
expect(supportNote?.text).toBe(
"If the issue persists, please open a Cloudflare Support ticket and include the Ray ID above."
);
}
});

it("should still throw a WAF error without the Ray ID note when cf-ray header is absent", async ({
expect,
}) => {
msw.use(
http.post("*/graphql", async () => {
return new HttpResponse("blocked", {
status: 403,
statusText: "Forbidden",
headers: {
"Content-Type": "text/html",
"cf-mitigated": "challenge",
},
});
})
);
try {
await fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
});
expect.unreachable("should have thrown");
} catch (e) {
const error = e as { text: string; notes: { text: string }[] };
expect(error.text).toBe(
"The Cloudflare API responded with a WAF block page instead of the expected JSON response"
);
const rayIdNote = error.notes.find((n: { text: string }) =>
n.text.includes("Cloudflare Ray ID:")
);
expect(rayIdNote).toBeUndefined();
const supportNote = error.notes.find((n: { text: string }) =>
n.text.includes("open a Cloudflare Support ticket")
);
expect(supportNote?.text).toBe(
"If the issue persists, please open a Cloudflare Support ticket. You can find the Cloudflare Ray ID on the block page in your browser."
);
}
});

it("should still throw 'malformed response' for non-WAF HTML responses", async ({
expect,
}) => {
msw.use(
http.post("*/graphql", async () => {
return new HttpResponse(
"<html><body>Internal Server Error</body></html>",
{
status: 500,
statusText: "Internal Server Error",
headers: { "Content-Type": "text/html" },
}
);
})
);
await expect(
fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
})
).rejects.toThrow("Received a malformed response from the API");
});

it("should include the Ray ID in 'malformed response' error when cf-ray header is present", async ({
expect,
}) => {
msw.use(
http.post("*/graphql", async () => {
return new HttpResponse(
"<html><body>Internal Server Error</body></html>",
{
status: 500,
statusText: "Internal Server Error",
headers: {
"Content-Type": "text/html",
"cf-ray": "abc123def456",
},
}
);
})
);
try {
await fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
});
expect.unreachable("should have thrown");
} catch (e) {
const error = e as { text: string; notes: { text: string }[] };
expect(error.text).toBe("Received a malformed response from the API");
const rayIdNote = error.notes.find((n: { text: string }) =>
n.text.includes("Cloudflare Ray ID:")
);
expect(rayIdNote).toBeDefined();
expect(rayIdNote?.text).toBe("Cloudflare Ray ID: abc123def456");
}
});

it("should omit the Ray ID in 'malformed response' error when cf-ray header is absent", async ({
expect,
}) => {
msw.use(
http.post("*/graphql", async () => {
return new HttpResponse(
"<html><body>Internal Server Error</body></html>",
{
status: 500,
statusText: "Internal Server Error",
headers: { "Content-Type": "text/html" },
}
);
})
);
try {
await fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
});
expect.unreachable("should have thrown");
} catch (e) {
const error = e as { text: string; notes: { text: string }[] };
expect(error.text).toBe("Received a malformed response from the API");
const rayIdNote = error.notes.find((n: { text: string }) =>
n.text.includes("Cloudflare Ray ID:")
);
expect(rayIdNote).toBeUndefined();
}
});
});
77 changes: 77 additions & 0 deletions packages/wrangler/src/cfetch/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,24 @@ export async function fetchInternal<ResponseType>(
};
}

// Detect Cloudflare WAF block pages via the cf-mitigated response header.
// Without this check, the JSON parser throws a confusing "malformed response" error.
if (isWAFBlockResponse(response.headers)) {
throwWAFBlockError(
response.headers,
method,
resource,
response.status,
response.statusText
);
}

try {
const json = parseJSON(jsonText) as ResponseType;
return { response: json, status: response.status };
} catch {
const rayId = extractWAFBlockRayId(response.headers);

throw new APIError({
text: "Received a malformed response from the API",
notes: [
Expand All @@ -206,6 +220,7 @@ export async function fetchInternal<ResponseType>(
{
text: `${method} ${resource} -> ${response.status} ${response.statusText}`,
},
...(rayId ? [{ text: `Cloudflare Ray ID: ${rayId}` }] : []),
],
status: response.status,
});
Expand All @@ -220,6 +235,68 @@ export function truncate(text: string, maxLength: number): string {
return `${text.substring(0, maxLength)}... (length = ${length})`;
}

/**
* Checks whether the response was blocked by Cloudflare's WAF by inspecting
* the `cf-mitigated` response header. When the WAF blocks or challenges a
* request the response will include `cf-mitigated: challenge`.
*
* @see https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/detect-response/
*
* @param headers - The response headers to inspect.
* @returns `true` if the response was mitigated by the WAF.
*/
export function isWAFBlockResponse(headers: Headers): boolean {
return headers.get("cf-mitigated") === "challenge";
}

/**
* Extracts the Cloudflare Ray ID from the `cf-ray` response header.
*
* @param headers - The response headers to inspect.
* @returns The Ray ID string, or `undefined` if the header is absent.
*/
export function extractWAFBlockRayId(headers: Headers): string | undefined {
return headers.get("cf-ray") ?? undefined;
}

/**
* Throws a descriptive {@link APIError} for a WAF block response.
*
* @param headers - The response headers (used to extract the Ray ID).
* @param method - The HTTP method of the blocked request.
* @param resource - The URL or path that was requested.
* @param status - The HTTP status code returned.
* @param statusText - The HTTP status text returned.
* @throws {APIError} Always — this function never returns.
*/
function throwWAFBlockError(
headers: Headers,
method: string,
resource: string,
status: number,
statusText: string
): never {
const rayId = extractWAFBlockRayId(headers);
throw new APIError({
text: "The Cloudflare API responded with a WAF block page instead of the expected JSON response",
notes: [
{
text: "Cloudflare's firewall (WAF) blocked this API request. This is usually a false positive.",
},
...(rayId ? [{ text: `Cloudflare Ray ID: ${rayId}` }] : []),
{
text: rayId
? "If the issue persists, please open a Cloudflare Support ticket and include the Ray ID above."
: "If the issue persists, please open a Cloudflare Support ticket. You can find the Cloudflare Ray ID on the block page in your browser.",
},
Comment thread
dario-piotrowicz marked this conversation as resolved.
{
text: `${method} ${resource} -> ${status} ${statusText}`,
},
],
status,
});
}

function cloneHeaders(headers: HeadersInit | undefined): Headers {
return new Headers(headers);
}
Expand Down
Loading