From 7c14194f2694d7de38b5eb92534152b3228e2d89 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 13 Jun 2026 08:41:46 -0300 Subject: [PATCH] fix(providers): repair qwen-web validation + stop mislabeling benign redirects as SSRF (#3288, #3758) qwen-web had no specialty validator, so validation fell through to the generic OpenAI-compatible path which probed a bogus /api/v2/models URL. That answers with a 307 redirect, the outbound guard blocked it, and the route surfaced it as provider.validation.ssrf_blocked. - Add a qwen-web specialty validator that probes the real session endpoint (GET /api/v2/user, like Chat2API) with the executor's anti-bot headers + cookie-jar replay, bypassing the addModelsSuffix path. - Harden toValidationErrorResult: a blocked redirect is only a security block when its target is a private/internal host; a benign 3xx to a public host is no longer mislabeled as SSRF (affected all web-cookie providers). Refs #3288 #3758 --- CHANGELOG.md | 2 + src/lib/providers/validation.ts | 95 +++++++++++++++- .../provider-validation-specialty.test.ts | 105 ++++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6b77b996e5..8b20b1e6c4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### 🐛 Fixed +- fix(providers): repair qwen-web (chat.qwen.ai) connection validation, which failed with a misleading `provider.validation.ssrf_blocked` error. qwen-web had no specialty validator, so the generic OpenAI-compatible path probed a non-existent `/api/v2/models` URL that answers with a 307 redirect — the outbound guard blocked the redirect and the route mislabeled it as an SSRF security block. Added a `qwen-web` specialty validator that probes the real session endpoint (`GET /api/v2/user`, mirroring the executor's anti-bot headers + cookie-jar replay). Also hardened `toValidationErrorResult` so a blocked redirect is only flagged `securityBlocked` when its target is a private/internal host — a benign 3xx to a public host is no longer mislabeled as an SSRF attempt (this affected every web-cookie provider, not just qwen). ([#3288](https://github.com/diegosouzapw/OmniRoute/issues/3288), [#3758](https://github.com/diegosouzapw/OmniRoute/issues/3758)) + - fix(oauth): stop nulling the stored `refresh_token` of non-rotating providers when a proactive health-check refresh fails with `invalid_grant`. The destructive `refreshToken: null` write in `tokenHealthCheck` was only meant for rotating one-time-use tokens (Codex/OpenAI), but it also fired for Google-family providers (gemini-cli / antigravity / gemini) whose refresh tokens are non-rotating. Once nulled, the connection reported "No valid refresh token available" and could never recover even after re-activation. The token is now preserved (gated on `isRotatingProvider`) so it stays as the recovery artifact. ([#3679](https://github.com/diegosouzapw/OmniRoute/issues/3679) — thanks @3xa228148) - fix(dashboard): self-host the Material Symbols icon font instead of loading it from the Google Fonts CDN. On networks where `fonts.googleapis.com` is unreachable (e.g. mainland China), the icon ligature font never loaded, so every icon rendered as its literal text name (`smart_toy`, `visibility`, …) and the layout broke — especially after importing many models. The font is now bundled locally via the `material-symbols` package (`@import "material-symbols/outlined.css"` in `globals.css`), removing the runtime CDN dependency. ([#3695](https://github.com/diegosouzapw/OmniRoute/issues/3695) — thanks @lqyiwwx) diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index c6387ec805f6..de9200bd13f4 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -30,7 +30,9 @@ import { import { getProviderOutboundGuard, isPrivateHost } from "@/shared/network/outboundUrlGuard"; import { buildGrokCookieHeader, + buildQwenCookieHeader, extractCookieValue, + extractQwenToken, normalizeSessionCookieHeader, } from "@/lib/providers/webCookieAuth"; import { buildJulesApiUrl } from "@/lib/cloudAgent/julesApi.ts"; @@ -359,6 +361,28 @@ async function validationWrite(url: string, init: RequestInit, isLocal: boolean return fetchWithProxyFallback(url, init, SAFE_OUTBOUND_FETCH_PRESETS.validationWrite, isLocal); } +// A validation failure should only be flagged `securityBlocked` (which the route +// surfaces as a `provider.validation.ssrf_blocked` audit event + a security warning in +// the UI) when it is a GENUINE SSRF/guard block — not for every outbound-guard 503. +// A blocked redirect (REDIRECT_BLOCKED) to a PUBLIC host is benign: the redirect was +// never followed, so no SSRF occurred. Web-cookie providers like qwen-web answer their +// probe with a 307 to a public host, which used to be mislabeled as an SSRF block +// (#3288 / #3758). Only treat a blocked redirect as a security event when its target is +// a private/internal host. +export function isSecurityBlockError(error: unknown): boolean { + if (!(error instanceof SafeOutboundFetchError)) return false; + if (error.code === "URL_GUARD_BLOCKED" || error.code === "INVALID_URL") return true; + if (error.code === "REDIRECT_BLOCKED") { + if (!error.location) return false; + try { + return isPrivateHost(new URL(error.location, error.url).hostname); + } catch { + return false; + } + } + return false; +} + function toValidationErrorResult(error: unknown) { const message = error instanceof Error ? error.message : String(error || "Validation failed"); const statusCode = getSafeOutboundFetchErrorStatus(error); @@ -371,7 +395,7 @@ function toValidationErrorResult(error: unknown) { ...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT" ? { timeout: true } : {}), - ...(statusCode === 503 ? { securityBlocked: true } : {}), + ...(isSecurityBlockError(error) ? { securityBlocked: true } : {}), }; } @@ -2813,6 +2837,74 @@ async function validateDeepSeekWebProvider({ apiKey }: any) { } } +// qwen-web has no `modelsUrl` in its registry entry, so the generic OpenAI-compatible +// validator derived a probe URL of `https://chat.qwen.ai/api/v2/models` (via +// addModelsSuffix) — a non-existent path that answers with a 307 redirect, which the +// outbound guard blocked and the route then mislabeled as an SSRF block (#3288/#3758). +// This specialty validator probes the real session-validity endpoint instead +// (`GET /api/v2/user`, the same one Chat2API uses), mirroring the executor's anti-bot +// headers + cookie-jar replay. It uses plain fetch (like the other web-cookie +// validators) so it never hits the addModelsSuffix/redirect path. +async function validateQwenWebProvider({ apiKey }: any) { + const rawCred = String(apiKey ?? "").trim(); + if (!rawCred) { + return { + valid: false, + error: + "Missing Qwen session — paste the full chat.qwen.ai Cookie header (must include token, cna and ssxmod_itna)", + }; + } + + const token = extractQwenToken(rawCred); + const cookieHeader = buildQwenCookieHeader(rawCred); + if (!token && !cookieHeader) { + return { + valid: false, + error: "Could not find a Qwen token/cookie in the pasted value", + }; + } + + try { + const headers: Record = { + Accept: "*/*", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + Origin: "https://chat.qwen.ai", + Referer: "https://chat.qwen.ai/", + source: "web", + "bx-v": "2.5.36", + }; + if (token) headers["Authorization"] = `Bearer ${token}`; + if (cookieHeader) headers["Cookie"] = cookieHeader; + + const resp = await fetch("https://chat.qwen.ai/api/v2/user", { headers }); + const contentType = resp.headers.get("content-type") || ""; + + if (resp.status === 401 || resp.status === 403) { + return { + valid: false, + error: + "Qwen session is invalid or expired — re-login at https://chat.qwen.ai and paste a fresh full Cookie header", + }; + } + // Alibaba's WAF / retired-v1 gateway answers with an HTML challenge page (or 504) + // instead of JSON. A bearer token alone is no longer enough for the v2 endpoint. + if (contentType.includes("text/html") || resp.status === 504) { + return { + valid: false, + error: + "Qwen blocked the request with its anti-bot WAF. Re-login at https://chat.qwen.ai and paste a fresh full Cookie header (must include cna, ssxmod_itna and token) — a bearer token alone is not accepted.", + }; + } + if (!resp.ok) { + return { valid: false, error: `Qwen returned HTTP ${resp.status}` }; + } + return { valid: true, error: null }; + } catch (error) { + return toValidationErrorResult(error); + } +} + async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: any) { try { const token = extractCookieValue(apiKey, "sso"); @@ -3898,6 +3990,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi gigachat: validateGigachatProvider, "deepseek-web": validateDeepSeekWebProvider, "grok-web": validateGrokWebProvider, + "qwen-web": validateQwenWebProvider, "chatgpt-web": validateChatGptWebProvider, "perplexity-web": validatePerplexityWebProvider, "blackbox-web": validateBlackboxWebProvider, diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index e851558a3e14..37d9d41a6e43 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -5,8 +5,11 @@ const { validateProviderApiKey, validateClaudeCodeCompatibleProvider, validateCommandCodeProvider, + isSecurityBlockError, } = await import("../../src/lib/providers/validation.ts"); +const { SafeOutboundFetchError } = await import("../../src/shared/network/safeOutboundFetch.ts"); + const { __setTlsFetchOverrideForTesting: __setPplxTlsFetchOverride } = await import("../../open-sse/services/perplexityTlsClient.ts"); @@ -2501,3 +2504,105 @@ test("gitlawb-gmi validator: accepts custom baseUrl override", async () => { }); assert.equal(result.valid, true); }); + +// #3288 / #3758: qwen-web validation used to fall through to the generic +// OpenAI-compatible validator, which probed a non-existent `/api/v2/models` URL that +// answered with a 307 redirect — blocked by the outbound guard and mislabeled as an +// SSRF block. A specialty validator now probes the real session endpoint instead. +test("qwen-web validator probes /api/v2/user (not /api/v2/models) and returns valid on 200", async () => { + let probedUrl = ""; + let sentHeaders: Record = {}; + globalThis.fetch = async (url, init = {}) => { + probedUrl = String(url); + sentHeaders = toPlainHeaders(init.headers); + return new Response(JSON.stringify({ data: { id: "u-1", name: "Tester" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await validateProviderApiKey({ + provider: "qwen-web", + apiKey: "token=eyJqwen; cna=abc; ssxmod_itna=def", + }); + + assert.equal(probedUrl, "https://chat.qwen.ai/api/v2/user"); + assert.ok(!probedUrl.includes("/api/v2/models"), "must not probe the bogus /api/v2/models URL"); + assert.equal(sentHeaders.Authorization, "Bearer eyJqwen"); + assert.equal(sentHeaders.source, "web"); + assert.match(sentHeaders.Cookie, /token=eyJqwen/); + assert.equal(result.valid, true); +}); + +test("qwen-web validator reports an invalid session (401) without flagging a security block", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + + const result = await validateProviderApiKey({ + provider: "qwen-web", + apiKey: "token=stale; cna=abc; ssxmod_itna=def", + }); + + assert.equal(result.valid, false); + assert.equal((result as { securityBlocked?: boolean }).securityBlocked ?? false, false); + assert.match(result.error ?? "", /invalid or expired/i); +}); + +test("qwen-web validator surfaces the WAF/anti-bot HTML challenge as a re-login hint", async () => { + globalThis.fetch = async () => + new Response("aliyun_waf", { + status: 200, + headers: { "content-type": "text/html" }, + }); + + const result = await validateProviderApiKey({ + provider: "qwen-web", + apiKey: "token=eyJqwen; cna=abc; ssxmod_itna=def", + }); + + assert.equal(result.valid, false); + assert.match(result.error ?? "", /WAF|Cookie header/i); +}); + +// #3288 / #3758: a blocked redirect (REDIRECT_BLOCKED) to a PUBLIC host is benign — the +// redirect was never followed, so it must NOT be mislabeled as an SSRF security block. +// Only a redirect whose target is a private/internal host is a genuine security event. +test("isSecurityBlockError: public-host redirect block is NOT a security block", () => { + const publicRedirect = new SafeOutboundFetchError("Redirect blocked", { + code: "REDIRECT_BLOCKED", + url: "https://chat.qwen.ai/api/v2/models", + method: "GET", + attempts: 1, + status: 307, + location: "https://chat.qwen.ai/login", + isRetryable: false, + }); + assert.equal(isSecurityBlockError(publicRedirect), false); +}); + +test("isSecurityBlockError: private-host redirect block IS a security block", () => { + const privateRedirect = new SafeOutboundFetchError("Redirect blocked", { + code: "REDIRECT_BLOCKED", + url: "https://api.example.com/probe", + method: "GET", + attempts: 1, + status: 302, + location: "http://169.254.169.254/latest/meta-data/", + isRetryable: false, + }); + assert.equal(isSecurityBlockError(privateRedirect), true); +}); + +test("isSecurityBlockError: a URL-guard block remains a security block", () => { + const guardBlock = new SafeOutboundFetchError("Blocked private host", { + code: "URL_GUARD_BLOCKED", + url: "http://10.0.0.5/internal", + method: "GET", + attempts: 1, + isRetryable: false, + }); + assert.equal(isSecurityBlockError(guardBlock), true); +});