fix(grok-web): clearer 403 message for anti-bot/IP-reputation blocks (#3474) (#3830)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-14 02:04:12 -03:00
committed by GitHub
parent 3e79d92744
commit c9e24ae48c
4 changed files with 141 additions and 8 deletions

View File

@@ -9,6 +9,7 @@
### 🐛 Fixed
- **fix(intelligence): run pricing + models.dev sync from the live startup path** — like the Arena ELO sync (v3.8.24), the external **pricing sync** (`PRICING_SYNC_ENABLED`) and the **models.dev capability sync** (Settings → AI toggle) were only initialized from `server-init.ts`, which the Next standalone runtime never executes — and models.dev had no caller at all. Their toggles were inert in production. Both are now initialized from `instrumentation-node.ts` (self-gated, opt-in preserved, non-blocking, never fatal). (thanks @diegosouzapw)
- **fix(grok-web): clearer 403 message for anti-bot / IP-reputation blocks** — a Grok Web subscription validating from a flagged datacenter/VPS IP got a 403 that read like an invalid cookie, sending users to chase a cookie that was actually fine. A non-auth 403 (Cloudflare challenge / anti-bot body) now returns a message stating the cookie is likely OK and the block is IP-reputation-based — retry from a residential IP or configure a proxy (auth-shaped 403s keep the re-paste guidance). ([#3474](https://github.com/diegosouzapw/OmniRoute/issues/3474) — thanks @friedtofu1608)
- **fix(db): make the mass-pending-migrations safety threshold env-overridable** — restoring a backup DB from an older version could trip "Detected N pending migrations … threshold is 50" with no way to override the hardcoded `50`. The threshold is now configurable via `OMNIROUTE_MAX_PENDING_MIGRATIONS` (resolved at startup; `0` disables the check). ([#3416](https://github.com/diegosouzapw/OmniRoute/issues/3416) — thanks @samuraiIT)
- **test(proxy): cover the Vercel-relay `proxyFetch` path** — net-new tests for `buildVercelRelayHeaders` and the `vercel`-type relay short-circuit (`x-relay-target`/`-path`/`-auth`, TCP-skip, missing-auth fail-closed), closing one of the deferred test gaps tracked in [#2743](https://github.com/diegosouzapw/OmniRoute/issues/2743). (thanks @diegosouzapw)
- **fix(cli): surface `omniroute runtime repair` in the native-module error messages** — after a Node major upgrade, `better-sqlite3`'s prebuilt binary mismatches the ABI and the service can crash-loop; the error only mentioned `npm rebuild better-sqlite3` (which fails for global / no-toolchain installs). The startup + SQLite error hints now also point to the existing self-heal command `omniroute runtime repair` (rebuilds into a user-writable runtime), and a top-level `omniroute repair` alias was added. ([#3476](https://github.com/diegosouzapw/OmniRoute/issues/3476) — thanks @Rahulsharma0810)

View File

@@ -87,7 +87,7 @@
"src/lib/evals/evalRunner.ts": 961,
"src/lib/memory/retrieval.ts": 1171,
"src/lib/modelsDevSync.ts": 934,
"src/lib/providers/validation.ts": 4302,
"src/lib/providers/validation.ts": 4348,
"src/lib/tailscaleTunnel.ts": 1189,
"src/lib/usage/callLogs.ts": 975,
"src/lib/usage/providerLimits.ts": 949,
@@ -116,5 +116,6 @@
"_rebaseline_2026_06_13_3786_agy_fallback": "Re-baseline #3786 (agy Pro-family upstream-id fallback chain): antigravity.ts 1572→1649 (+77). Crescimento = split de execute() em driver + executeOnce(modelIdOverride) para retentar ids alternativos no 400 (gemini-3.1-pro-high→gemini-pro-agent→gemini-3-pro-high), threading do override em transformRequest/cleanModelName. Lógica coesa de retry no executor — não é bloco movível (chama this.executeOnce). A parte pura (ANTIGRAVITY_PRO_FALLBACK_CHAINS + getAntigravityModelFallbacks) ficou em antigravityModelAliases.ts. Os 3 drifts release-wide (ProxyRegistryManager/sidebarVisibility/schemas) são do #3809 do owner, não deste PR.",
"_rebaseline_2026_06_13_3782_hide_persist": "Re-baseline #3782 (preservar modelos eye-hidden no auto-sync): models.ts 1132→1180 (+48). Crescimento = flag distinto isDeleted em ModelCompatOverride/ModelCompatPatch + handling em mergeModelCompatOverride + helper getModelIsDeleted, para separar 'deletado' (trash, dropado no re-sync #3199) de 'oculto' (eye toggle, preservado). Lógica coesa de visibilidade no módulo db; não-extraível. Os 3 drifts release-wide (ProxyRegistryManager/sidebarVisibility/schemas) são do #3809 do owner, não deste PR.",
"_rebaseline_2026_06_13_3758_chat_early_eof": "Re-baseline #3758 (#3817 mergeado): chat.ts 1392→1425 (+33). Crescimento = retry bounded de STREAM_EARLY_EOF no handleSingleModelChat (contador streamEarlyEofRetries + bloco de retry guardado por shouldRetryStreamEarlyEof). Lógica coesa no handler de chat; não-extraível. Reconciliação tardia — o bump foi esquecido no PR do fix (o de antigravity/models foi feito).",
"_rebaseline_2026_06_13_3416_migration_threshold": "Re-baseline #3416 (threshold de migrações pendentes via env): migrationRunner.ts 1100→1125 (+25). Crescimento = helper resolveMaxPendingMigrations() que lê OMNIROUTE_MAX_PENDING_MIGRATIONS em call-time (valida finito+>=0, fallback 50) + JSDoc. Lógica coesa de config no runner; não-extraível."
"_rebaseline_2026_06_13_3416_migration_threshold": "Re-baseline #3416 (threshold de migrações pendentes via env): migrationRunner.ts 1100→1125 (+25). Crescimento = helper resolveMaxPendingMigrations() que lê OMNIROUTE_MAX_PENDING_MIGRATIONS em call-time (valida finito+>=0, fallback 50) + JSDoc. Lógica coesa de config no runner; não-extraível.",
"_rebaseline_2026_06_13_3474_grok_403": "Re-baseline #3474 (mensagem clara no 403 anti-bot do Grok): validation.ts 4302→4348 (+46). Crescimento = helper isGrokAntiBotBlock() + branch 403 de 3 tiers (auth-shaped / anti-bot-IP-reputation / upstream-error). Lógica coesa de classificação no validator; não-extraível."
}

View File

@@ -2905,6 +2905,35 @@ async function validateQwenWebProvider({ apiKey }: any) {
}
}
/**
* Heuristic for a Grok 403 that is an anti-bot / IP-reputation block rather than
* a genuine upstream API error (issue #3474).
*
* Returns true when the body reads like an anti-bot rejection — Grok's literal
* "Request rejected by anti-bot rules." text, or a bare/non-structured forbidden
* body that carries no parseable upstream `error.message`. Returns false for a
* structured upstream API error (e.g. `{"error":{"message":"Model is not found"}}`),
* which must keep surfacing its body to the user/maintainer.
*
* Callers should run `isCloudflareChallenge()` first; this covers the non-HTML
* anti-bot cases that Cloudflare-challenge detection does not.
*/
function isGrokAntiBotBlock(body: string | null | undefined): boolean {
const text = (body || "").trim();
if (!text) return true; // empty 403 body — pre-auth block, treat as anti-bot
if (/anti-bot|forbidden|access denied|blocked|rate.?limit/i.test(text)) return true;
// A structured upstream API error has a parseable JSON `error.message`; if one
// is present this is a real upstream error, not an anti-bot block.
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed?.error?.message === "string") return false;
} catch {
// Non-JSON 403 body with no recognizable structure → treat as anti-bot block.
return true;
}
return false;
}
async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: any) {
try {
const token = extractCookieValue(apiKey, "sso");
@@ -3022,17 +3051,34 @@ async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: an
}
if (response.status === 403) {
// Grok uses 403 for auth failures, entitlement issues, geo blocks, and
// resource errors. Default-deny: only the auth-shaped 403 gets the
// re-paste hint; anything else surfaces the upstream body so the user
// (or maintainer, if upstream renames the probe model) sees the real
// cause instead of a misleading "valid" verdict.
// Grok uses 403 for auth failures, entitlement issues, geo blocks,
// anti-bot/IP-reputation rejections, and resource errors. Classify before
// messaging — a misleading "invalid cookie" verdict on an IP-reputation
// block (issue #3474) sends users chasing a cookie that is actually fine.
//
// 1. Auth-shaped → the cookie/session is the problem; re-paste it.
if (/invalid-credentials|unauthenticated|unauthorized/i.test(errorDetail)) {
return {
valid: false,
error: "Invalid SSO cookie — re-paste from grok.com DevTools → Cookies → sso",
};
}
// 2. Anti-bot / Cloudflare / IP-reputation block → the cookie is likely
// fine; the request was rejected before auth was even evaluated. This is
// not code-fixable: the datacenter/VPS IP is flagged. A Cloudflare
// challenge body, Grok's "anti-bot rules" rejection, or a bare/non-JSON
// forbidden body (no structured upstream `error.message`) all map here.
if (isCloudflareChallenge(errorDetail) || isGrokAntiBotBlock(errorDetail)) {
return {
valid: false,
error:
"Grok returned 403 (anti-bot/Cloudflare block). Your sso cookie is likely fine — " +
"this is an IP-reputation block on the request, not an auth failure. Retry from a " +
"residential IP or configure a proxy for grok-web.",
};
}
// 3. Structured upstream error (e.g. probe model renamed) → surface the body
// so the user/maintainer sees the real cause instead of a wrong verdict.
return {
valid: false,
error: `Grok rejected validation (403)${errorDetail ? `: ${errorDetail.slice(0, 160)}` : ""}`,

View File

@@ -478,14 +478,99 @@ test("grok-web validator: non-auth 403 is reported as failure with upstream body
assert.match(result.error || "", /Model is not found/);
});
test("grok-web validator: generic 403 forbidden is rejected, not silently passed", async () => {
test("grok-web validator: generic non-auth 403 maps to IP-reputation guidance, not 'invalid cookie' (#3474)", async () => {
__setGrokTlsFetchOverride(async () => {
return { status: 403, headers: new Headers(), text: "Forbidden", body: null };
});
const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "any-cookie" });
assert.equal(result.valid, false);
// A bare/non-auth 403 from grok.com is almost always an anti-bot/IP-reputation
// block — the cookie itself is likely fine. The message must point the user to a
// residential IP or proxy, NOT tell them the cookie is invalid.
assert.match(result.error || "", /residential IP|proxy/i);
assert.doesNotMatch(result.error || "", /invalid SSO cookie/i);
});
test("grok-web validator: anti-bot 'Request rejected' 403 maps to IP-reputation guidance (#3474)", async () => {
__setGrokTlsFetchOverride(async () => {
return {
status: 403,
headers: new Headers(),
text: "Request rejected by anti-bot rules.",
body: null,
};
});
const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" });
assert.equal(result.valid, false);
assert.match(result.error || "", /residential IP|proxy/i);
// Cookie may be fine — must not claim it is invalid/expired.
assert.doesNotMatch(result.error || "", /invalid SSO cookie|expired/i);
});
test("grok-web validator: Cloudflare challenge returned with a 403 status maps to IP guidance (#3474)", async () => {
__setGrokTlsFetchOverride(async () => {
return {
status: 403,
headers: new Headers(),
text: "<html><title>Just a moment...</title><script>window._cf_chl_opt</script></html>",
body: null,
};
});
const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "sso=abc" });
assert.equal(result.valid, false);
assert.match(result.error || "", /residential IP|proxy/i);
});
test("grok-web validator: IP-reputation 403 message does not leak a stack/raw error (Hard Rule #12) (#3474)", async () => {
__setGrokTlsFetchOverride(async () => {
return {
status: 403,
headers: new Headers(),
text: "Request rejected by anti-bot rules.\n at GrokWeb.validate (/app/secret/path.ts:42:13)",
body: null,
};
});
const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" });
assert.equal(result.valid, false);
assert.match(result.error || "", /residential IP|proxy/i);
assert.ok(!(result.error || "").includes("at GrokWeb.validate"));
assert.ok(!(result.error || "").includes("/app/secret/path.ts"));
assert.ok(!(result.error || "").includes(" at "));
});
test("grok-web validator: structured non-auth 403 (resource error) still surfaces upstream body for maintainers (#3474)", async () => {
// Distinct from an anti-bot block: a genuine upstream API error (e.g. the probe
// model was renamed) carries a structured error.message and must NOT be masked
// by the IP-reputation guidance — the maintainer needs to see the real cause.
__setGrokTlsFetchOverride(async () => {
return {
status: 403,
headers: new Headers(),
text: JSON.stringify({ error: { code: 7, message: "Model is not found", details: [] } }),
body: null,
};
});
const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" });
assert.equal(result.valid, false);
assert.match(result.error || "", /Grok rejected validation \(403\)/);
assert.match(result.error || "", /Model is not found/);
assert.doesNotMatch(result.error || "", /residential IP|proxy/i);
});
test("grok-web validator: auth-shaped 401 keeps the re-paste/re-authenticate guidance (no regression) (#3474)", async () => {
__setGrokTlsFetchOverride(async () => {
return { status: 401, headers: new Headers(), text: "Unauthorized", body: null };
});
const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "expired-cookie" });
assert.equal(result.valid, false);
assert.match(result.error || "", /Invalid SSO cookie|re-paste/i);
assert.doesNotMatch(result.error || "", /residential IP|proxy/i);
});
test("grok-web validator: 403 with credential-rejection body is treated as auth-failed", async () => {