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
7 changes: 7 additions & 0 deletions .changeset/upstream-bot-challenge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@executor-js/sdk": patch
"@executor-js/plugin-openapi": patch
"@executor-js/plugin-graphql": patch
---

A request that Cloudflare bot protection challenges before it reaches the API (`cf-mitigated: challenge`) now fails as `upstream_bot_challenge` with the Ray ID, instead of `connection_rejected` with a prompt to re-authenticate and the challenge page's HTML. OpenAPI health checks report such a probe as degraded rather than expired, since the credential was never checked.
6 changes: 6 additions & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,12 @@ export {
insufficientScopeFromEmbeddedJson,
type InsufficientScopeDetection,
} from "./insufficient-scope";
export {
botChallengeMessage,
botChallengeToolFailure,
detectBotChallenge,
type BotChallengeDetection,
} from "./upstream-bot-challenge";

// Endpoint sanitization for span attributes — plugins stamping a user-supplied
// endpoint must strip its credential-bearing parts first.
Expand Down
64 changes: 64 additions & 0 deletions packages/core/sdk/src/upstream-bot-challenge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "@effect/vitest";

import { botChallengeToolFailure, detectBotChallenge } from "./upstream-bot-challenge";

describe("detectBotChallenge", () => {
it("detects a Cloudflare challenge and carries its Ray ID", () => {
expect(
detectBotChallenge({
headers: { "cf-mitigated": "challenge", "cf-ray": "8f1a2b3c4d5e6f70-SJC" },
}),
).toEqual({ provider: "cloudflare", rayId: "8f1a2b3c4d5e6f70-SJC" });
});

it("matches header names and the value case-insensitively", () => {
expect(detectBotChallenge({ headers: { "CF-Mitigated": " Challenge " } })).toEqual({
provider: "cloudflare",
});
});

it("does not classify without the cf-mitigated challenge header", () => {
// A Cloudflare-fronted origin's own 403 carries cf-ray but no mitigation.
expect(
detectBotChallenge({ headers: { server: "cloudflare", "cf-ray": "8f1a2b3c4d5e6f70-SJC" } }),
).toBeNull();
expect(detectBotChallenge({ headers: { "cf-mitigated": "block" } })).toBeNull();
expect(detectBotChallenge({})).toBeNull();
});
});

describe("botChallengeToolFailure", () => {
it("is a non-authentication failure that tells the agent the credential was not checked", () => {
const result = botChallengeToolFailure({
integration: { id: "example_api", scope: "user" },
status: 403,
detection: { provider: "cloudflare", rayId: "8f1a2b3c4d5e6f70-SJC" },
});

expect(result).toMatchObject({
ok: false,
error: {
code: "upstream_bot_challenge",
status: 403,
retryable: false,
message: expect.stringMatching(/Ray ID 8f1a2b3c4d5e6f70-SJC.*credential was not checked/),
details: {
category: "upstream_protection",
integration: { id: "example_api", scope: "user" },
upstream: {
status: 403,
provider: "cloudflare",
mitigation: "challenge",
rayId: "8f1a2b3c4d5e6f70-SJC",
},
},
},
});
const recovery = (result as { error: { details: { recovery: Record<string, string> } } }).error
.details.recovery;
expect(
recovery.createConnectionTool,
"no reconnect hint: a new credential meets the same challenge",
).toBeUndefined();
});
});
91 changes: 91 additions & 0 deletions packages/core/sdk/src/upstream-bot-challenge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Detecting an upstream bot-protection challenge. A site behind Cloudflare
// can answer an API request with a challenge page ("Just a moment...")
// instead of forwarding it: a Managed Challenge, JS challenge, or Bot Fight
// Mode verdict against the caller's network. Hosted executors send from
// datacenter or Workers egress, which is exactly the traffic those rules
// target, so a key that works from a laptop can be challenged here.
//
// The challenge is served by the edge BEFORE the request reaches the API, so
// the credential was never evaluated. Folding it into connection_rejected
// (whose recovery tells the agent to re-authenticate) sends the user off to
// rotate a key that is fine; the fix is on the API operator's side.
//
// Detection is deliberately strict: it keys off the `cf-mitigated: challenge`
// response header, which Cloudflare documents as the signal for a challenged
// request and which an origin cannot produce by accident. The HTML body is
// not inspected: a page that merely mentions Cloudflare never classifies. A
// miss is benign: the failure stays on its existing classification.

import { ToolResult } from "./tool-result";

export type BotChallengeDetection = {
readonly provider: "cloudflare";
/** Cloudflare's per-request Ray ID (`cf-ray`), which the site operator can
* look up in their Security Events to see which rule challenged it. */
readonly rayId?: string;
};

const headerValue = (
headers: Record<string, string> | undefined,
name: string,
): string | undefined => {
for (const [key, value] of Object.entries(headers ?? {})) {
if (key.toLowerCase() === name) return value;
}
return undefined;
};

/** Inspect an upstream response's headers for a bot-protection challenge.
* Returns `null` when nothing matches, so callers fall through to their
* existing classification. */
export const detectBotChallenge = (input: {
readonly headers?: Record<string, string>;
}): BotChallengeDetection | null => {
const mitigated = headerValue(input.headers, "cf-mitigated");
if (mitigated?.trim().toLowerCase() !== "challenge") return null;
const rayId = headerValue(input.headers, "cf-ray")?.trim();
return { provider: "cloudflare", ...(rayId ? { rayId } : {}) };
};

/** One-line explanation of a challenged request, shared by tool failures and
* health-check details so both surfaces say the same thing. */
export const botChallengeMessage = (input: {
readonly integration: string;
readonly status: number;
readonly detection: BotChallengeDetection;
}): string =>
`Cloudflare bot protection in front of "${input.integration}" answered HTTP ${input.status} with a challenge (cf-mitigated: challenge${input.detection.rayId ? `, Ray ID ${input.detection.rayId}` : ""}), so the request never reached the API and the connection's credential was not checked. Re-authenticating will not help; the API operator must exempt API traffic from the challenge.`;

/** The tool result for a challenged request: a typed, non-authentication
* failure that carries the Ray ID instead of the challenge page's HTML. */
export const botChallengeToolFailure = <T = never>(input: {
readonly integration: { readonly id: string; readonly scope?: string };
readonly status: number;
readonly detection: BotChallengeDetection;
}): ToolResult<T> =>
ToolResult.fail({
code: "upstream_bot_challenge",
status: input.status,
message: botChallengeMessage({
integration: input.integration.id,
status: input.status,
detection: input.detection,
}),
// A challenge expects a browser to solve it; replaying the same request
// from the same egress gets the same verdict.
retryable: false,
details: {
category: "upstream_protection",
integration: input.integration,
upstream: {
status: input.status,
provider: input.detection.provider,
mitigation: "challenge",
...(input.detection.rayId ? { rayId: input.detection.rayId } : {}),
},
recovery: {
instructions:
"The upstream's bot protection blocked this request before authentication, so the connection's credential is not the problem. Do not ask the user to re-enter or rotate it. Tell them the API operator has to allow API traffic past the challenge, and pass on the Ray ID so the operator can find the event.",
},
},
});
54 changes: 54 additions & 0 deletions packages/plugins/graphql/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,60 @@ describe("graphqlPlugin real protocol server", () => {
}),
);

it.effect(
"classifies a Cloudflare challenge as upstream_bot_challenge, not an auth failure",
() =>
Effect.gen(function* () {
const server = yield* serveTestHttpApp((request) =>
Effect.gen(function* () {
const webRequest = yield* HttpServerRequest.toWeb(request);
const body = yield* Effect.promise(() => webRequest.text());
if (body.includes("__schema")) {
return HttpServerResponse.jsonUnsafe({ data: introspectionResult });
}
return HttpServerResponse.text("<!DOCTYPE html><title>Just a moment...</title>", {
status: 403,
headers: {
"content-type": "text/html",
"cf-mitigated": "challenge",
"cf-ray": "8f1a2b3c4d5e6f70-SJC",
},
});
}),
);
const executor = yield* makeExecutor();

yield* executor.graphql.addIntegration({
endpoint: server.url("/graphql"),
slug: "challenged_graph",
});
yield* createOrgConnection(executor, {
integration: "challenged_graph",
name: "main",
template: "none",
});

const result = yield* executor.execute(
toolAddr("challenged_graph", "main", "query.hello"),
{
name: "Ada",
},
);

expect(result).toMatchObject({
ok: false,
error: {
code: "upstream_bot_challenge",
status: 403,
details: {
category: "upstream_protection",
upstream: { rayId: "8f1a2b3c4d5e6f70-SJC" },
},
},
});
}),
);

it.effect("invokes OAuth-backed integrations with a rendered bearer token", () =>
Effect.gen(function* () {
const server = yield* serveGraphqlTestServer({
Expand Down
17 changes: 17 additions & 0 deletions packages/plugins/graphql/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { HttpClient } from "effect/unstable/http";

import {
authToolFailure,
botChallengeToolFailure,
detectBotChallenge,
detectInsufficientScope,
AuthTemplateSlug,
definePlugin,
Expand Down Expand Up @@ -1434,6 +1436,21 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => {
// gateway's OAuth error object), and even when it is, the transport
// status is the authoritative signal — labelling it graphql_errors
// would hide the credential problem from the agent entirely.
//
// A bot-protection challenge comes first of all: the edge answered
// before the request reached the endpoint, so neither the credential
// nor the GraphQL layer was involved.
const botChallenge =
result.status < 200 || result.status >= 300
? detectBotChallenge({ headers: result.headers })
: null;
if (botChallenge) {
return botChallengeToolFailure({
integration: { id: integration, scope: credential.owner },
status: result.status,
detection: botChallenge,
});
}
if (result.status === 401 || result.status === 403) {
// A scope-insufficient 403 is not fixable by re-authenticating
// the same grant; give it its own code so the agent stops looping
Expand Down
33 changes: 33 additions & 0 deletions packages/plugins/openapi/src/sdk/backing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
ToolName,
ToolResult,
authToolFailure,
botChallengeMessage,
botChallengeToolFailure,
classifyProbeResponse,
detectBotChallenge,
detectInsufficientScope,
sortHealthCheckCandidatesByIdentity,
extractIdentity,
Expand Down Expand Up @@ -774,6 +777,18 @@ export const invokeOpenApiBackedTool = (input: {
const result = invocation.result;
const ok = result.status >= 200 && result.status < 300;
if (!ok) {
// A bot-protection challenge is served by the edge before the request
// reaches the API, whatever status it carries (403 for a managed
// challenge, 503 for older JS challenges), so it is classified ahead
// of the credential branch: the key was never evaluated.
const botChallenge = detectBotChallenge({ headers: result.headers });
if (botChallenge) {
return botChallengeToolFailure({
integration: { id: integration, scope: input.credential.owner },
status: result.status,
detection: botChallenge,
});
}
if (result.status === 401 || result.status === 403) {
// A 403 naming a scope shortfall (RFC 6750 insufficient_scope,
// Google's ACCESS_TOKEN_SCOPE_INSUFFICIENT) cannot be fixed by
Expand Down Expand Up @@ -1012,6 +1027,24 @@ export const checkHealthOpenApi = (input: {
} satisfies HealthCheckResult;
}

// A bot-protection challenge never reached the API, so it says nothing
// about the credential: degraded, not expired.
const probeOk = probe.result.status >= 200 && probe.result.status < 300;
const botChallenge = probeOk ? null : detectBotChallenge({ headers: probe.result.headers });
if (botChallenge) {
return {
status: "degraded",
httpStatus: probe.result.status,
checkedAt,
detail: botChallengeMessage({
integration: String(input.integration.slug),
status: probe.result.status,
detection: botChallenge,
}),
reason: "upstream_status",
} satisfies HealthCheckResult;
}

// Body-aware: a configuration 403 (Google accessNotConfigured /
// SERVICE_DISABLED) reads misconfigured, not expired.
const status = classifyProbeResponse(probe.result.status, probe.result.error);
Expand Down
Loading
Loading