Skip to content

Commit d5e3c57

Browse files
Detect Cloudflare WAF block pages and show a helpful error message in stead of "Received a malformed response from the API" (#13574)
1 parent f138e83 commit d5e3c57

3 files changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Detect Cloudflare WAF block pages and include Ray ID in API error messages
6+
7+
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.
8+
9+
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.
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { COMPLIANCE_REGION_CONFIG_UNKNOWN } from "@cloudflare/workers-utils";
2+
import { http, HttpResponse } from "msw";
3+
import { describe, it } from "vitest";
4+
import { fetchGraphqlResult } from "../cfetch";
5+
import { extractWAFBlockRayId, isWAFBlockResponse } from "../cfetch/internal";
6+
import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
7+
import { msw } from "./helpers/msw";
8+
9+
describe("isWAFBlockResponse", () => {
10+
it("should detect a WAF-mitigated response", ({ expect }) => {
11+
const headers = new Headers({ "cf-mitigated": "challenge" });
12+
expect(isWAFBlockResponse(headers)).toBe(true);
13+
});
14+
15+
it("should return false when cf-mitigated header is absent", ({ expect }) => {
16+
const headers = new Headers();
17+
expect(isWAFBlockResponse(headers)).toBe(false);
18+
});
19+
20+
it("should return false when cf-mitigated has a different value", ({
21+
expect,
22+
}) => {
23+
const headers = new Headers({ "cf-mitigated": "other" });
24+
expect(isWAFBlockResponse(headers)).toBe(false);
25+
});
26+
});
27+
28+
describe("extractWAFBlockRayId", () => {
29+
it("should extract the Ray ID from the cf-ray header", ({ expect }) => {
30+
const headers = new Headers({ "cf-ray": "9e8116df4823e2c5" });
31+
expect(extractWAFBlockRayId(headers)).toBe("9e8116df4823e2c5");
32+
});
33+
34+
it("should return undefined when cf-ray header is absent", ({ expect }) => {
35+
const headers = new Headers();
36+
expect(extractWAFBlockRayId(headers)).toBeUndefined();
37+
});
38+
});
39+
40+
describe("fetchInternal WAF block detection", () => {
41+
mockAccountId({ accountId: null });
42+
mockApiToken();
43+
44+
it("should throw a helpful error when the API returns a WAF block response", async ({
45+
expect,
46+
}) => {
47+
msw.use(
48+
http.post("*/graphql", async () => {
49+
return new HttpResponse("blocked", {
50+
status: 403,
51+
statusText: "Forbidden",
52+
headers: {
53+
"Content-Type": "text/html",
54+
"cf-mitigated": "challenge",
55+
"cf-ray": "9e8116df4823e2c5",
56+
},
57+
});
58+
})
59+
);
60+
await expect(
61+
fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
62+
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
63+
})
64+
).rejects.toThrow(
65+
"The Cloudflare API responded with a WAF block page instead of the expected JSON response"
66+
);
67+
});
68+
69+
it("should include the Ray ID in the error when cf-ray header is present", async ({
70+
expect,
71+
}) => {
72+
msw.use(
73+
http.post("*/graphql", async () => {
74+
return new HttpResponse("blocked", {
75+
status: 403,
76+
statusText: "Forbidden",
77+
headers: {
78+
"Content-Type": "text/html",
79+
"cf-mitigated": "challenge",
80+
"cf-ray": "9e8116df4823e2c5",
81+
},
82+
});
83+
})
84+
);
85+
try {
86+
await fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
87+
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
88+
});
89+
expect.unreachable("should have thrown");
90+
} catch (e) {
91+
const error = e as { notes: { text: string }[] };
92+
const rayIdNote = error.notes.find((n: { text: string }) =>
93+
n.text.includes("Cloudflare Ray ID:")
94+
);
95+
expect(rayIdNote).toBeDefined();
96+
expect(rayIdNote?.text).toBe("Cloudflare Ray ID: 9e8116df4823e2c5");
97+
const supportNote = error.notes.find((n: { text: string }) =>
98+
n.text.includes("open a Cloudflare Support ticket")
99+
);
100+
expect(supportNote?.text).toBe(
101+
"If the issue persists, please open a Cloudflare Support ticket and include the Ray ID above."
102+
);
103+
}
104+
});
105+
106+
it("should still throw a WAF error without the Ray ID note when cf-ray header is absent", async ({
107+
expect,
108+
}) => {
109+
msw.use(
110+
http.post("*/graphql", async () => {
111+
return new HttpResponse("blocked", {
112+
status: 403,
113+
statusText: "Forbidden",
114+
headers: {
115+
"Content-Type": "text/html",
116+
"cf-mitigated": "challenge",
117+
},
118+
});
119+
})
120+
);
121+
try {
122+
await fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
123+
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
124+
});
125+
expect.unreachable("should have thrown");
126+
} catch (e) {
127+
const error = e as { text: string; notes: { text: string }[] };
128+
expect(error.text).toBe(
129+
"The Cloudflare API responded with a WAF block page instead of the expected JSON response"
130+
);
131+
const rayIdNote = error.notes.find((n: { text: string }) =>
132+
n.text.includes("Cloudflare Ray ID:")
133+
);
134+
expect(rayIdNote).toBeUndefined();
135+
const supportNote = error.notes.find((n: { text: string }) =>
136+
n.text.includes("open a Cloudflare Support ticket")
137+
);
138+
expect(supportNote?.text).toBe(
139+
"If the issue persists, please open a Cloudflare Support ticket. You can find the Cloudflare Ray ID on the block page in your browser."
140+
);
141+
}
142+
});
143+
144+
it("should still throw 'malformed response' for non-WAF HTML responses", async ({
145+
expect,
146+
}) => {
147+
msw.use(
148+
http.post("*/graphql", async () => {
149+
return new HttpResponse(
150+
"<html><body>Internal Server Error</body></html>",
151+
{
152+
status: 500,
153+
statusText: "Internal Server Error",
154+
headers: { "Content-Type": "text/html" },
155+
}
156+
);
157+
})
158+
);
159+
await expect(
160+
fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
161+
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
162+
})
163+
).rejects.toThrow("Received a malformed response from the API");
164+
});
165+
166+
it("should include the Ray ID in 'malformed response' error when cf-ray header is present", async ({
167+
expect,
168+
}) => {
169+
msw.use(
170+
http.post("*/graphql", async () => {
171+
return new HttpResponse(
172+
"<html><body>Internal Server Error</body></html>",
173+
{
174+
status: 500,
175+
statusText: "Internal Server Error",
176+
headers: {
177+
"Content-Type": "text/html",
178+
"cf-ray": "abc123def456",
179+
},
180+
}
181+
);
182+
})
183+
);
184+
try {
185+
await fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
186+
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
187+
});
188+
expect.unreachable("should have thrown");
189+
} catch (e) {
190+
const error = e as { text: string; notes: { text: string }[] };
191+
expect(error.text).toBe("Received a malformed response from the API");
192+
const rayIdNote = error.notes.find((n: { text: string }) =>
193+
n.text.includes("Cloudflare Ray ID:")
194+
);
195+
expect(rayIdNote).toBeDefined();
196+
expect(rayIdNote?.text).toBe("Cloudflare Ray ID: abc123def456");
197+
}
198+
});
199+
200+
it("should omit the Ray ID in 'malformed response' error when cf-ray header is absent", async ({
201+
expect,
202+
}) => {
203+
msw.use(
204+
http.post("*/graphql", async () => {
205+
return new HttpResponse(
206+
"<html><body>Internal Server Error</body></html>",
207+
{
208+
status: 500,
209+
statusText: "Internal Server Error",
210+
headers: { "Content-Type": "text/html" },
211+
}
212+
);
213+
})
214+
);
215+
try {
216+
await fetchGraphqlResult(COMPLIANCE_REGION_CONFIG_UNKNOWN, {
217+
body: JSON.stringify({ query: "{ viewer { __typename } }" }),
218+
});
219+
expect.unreachable("should have thrown");
220+
} catch (e) {
221+
const error = e as { text: string; notes: { text: string }[] };
222+
expect(error.text).toBe("Received a malformed response from the API");
223+
const rayIdNote = error.notes.find((n: { text: string }) =>
224+
n.text.includes("Cloudflare Ray ID:")
225+
);
226+
expect(rayIdNote).toBeUndefined();
227+
}
228+
});
229+
});

packages/wrangler/src/cfetch/internal.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,24 @@ export async function fetchInternal<ResponseType>(
193193
};
194194
}
195195

196+
// Detect Cloudflare WAF block pages via the cf-mitigated response header.
197+
// Without this check, the JSON parser throws a confusing "malformed response" error.
198+
if (isWAFBlockResponse(response.headers)) {
199+
throwWAFBlockError(
200+
response.headers,
201+
method,
202+
resource,
203+
response.status,
204+
response.statusText
205+
);
206+
}
207+
196208
try {
197209
const json = parseJSON(jsonText) as ResponseType;
198210
return { response: json, status: response.status };
199211
} catch {
212+
const rayId = extractWAFBlockRayId(response.headers);
213+
200214
throw new APIError({
201215
text: "Received a malformed response from the API",
202216
notes: [
@@ -206,6 +220,7 @@ export async function fetchInternal<ResponseType>(
206220
{
207221
text: `${method} ${resource} -> ${response.status} ${response.statusText}`,
208222
},
223+
...(rayId ? [{ text: `Cloudflare Ray ID: ${rayId}` }] : []),
209224
],
210225
status: response.status,
211226
});
@@ -220,6 +235,68 @@ export function truncate(text: string, maxLength: number): string {
220235
return `${text.substring(0, maxLength)}... (length = ${length})`;
221236
}
222237

238+
/**
239+
* Checks whether the response was blocked by Cloudflare's WAF by inspecting
240+
* the `cf-mitigated` response header. When the WAF blocks or challenges a
241+
* request the response will include `cf-mitigated: challenge`.
242+
*
243+
* @see https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/detect-response/
244+
*
245+
* @param headers - The response headers to inspect.
246+
* @returns `true` if the response was mitigated by the WAF.
247+
*/
248+
export function isWAFBlockResponse(headers: Headers): boolean {
249+
return headers.get("cf-mitigated") === "challenge";
250+
}
251+
252+
/**
253+
* Extracts the Cloudflare Ray ID from the `cf-ray` response header.
254+
*
255+
* @param headers - The response headers to inspect.
256+
* @returns The Ray ID string, or `undefined` if the header is absent.
257+
*/
258+
export function extractWAFBlockRayId(headers: Headers): string | undefined {
259+
return headers.get("cf-ray") ?? undefined;
260+
}
261+
262+
/**
263+
* Throws a descriptive {@link APIError} for a WAF block response.
264+
*
265+
* @param headers - The response headers (used to extract the Ray ID).
266+
* @param method - The HTTP method of the blocked request.
267+
* @param resource - The URL or path that was requested.
268+
* @param status - The HTTP status code returned.
269+
* @param statusText - The HTTP status text returned.
270+
* @throws {APIError} Always — this function never returns.
271+
*/
272+
function throwWAFBlockError(
273+
headers: Headers,
274+
method: string,
275+
resource: string,
276+
status: number,
277+
statusText: string
278+
): never {
279+
const rayId = extractWAFBlockRayId(headers);
280+
throw new APIError({
281+
text: "The Cloudflare API responded with a WAF block page instead of the expected JSON response",
282+
notes: [
283+
{
284+
text: "Cloudflare's firewall (WAF) blocked this API request. This is usually a false positive.",
285+
},
286+
...(rayId ? [{ text: `Cloudflare Ray ID: ${rayId}` }] : []),
287+
{
288+
text: rayId
289+
? "If the issue persists, please open a Cloudflare Support ticket and include the Ray ID above."
290+
: "If the issue persists, please open a Cloudflare Support ticket. You can find the Cloudflare Ray ID on the block page in your browser.",
291+
},
292+
{
293+
text: `${method} ${resource} -> ${status} ${statusText}`,
294+
},
295+
],
296+
status,
297+
});
298+
}
299+
223300
function cloneHeaders(headers: HeadersInit | undefined): Headers {
224301
return new Headers(headers);
225302
}

0 commit comments

Comments
 (0)