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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
95 changes: 94 additions & 1 deletion src/lib/providers/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -371,7 +395,7 @@ function toValidationErrorResult(error: unknown) {
...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT"
? { timeout: true }
: {}),
...(statusCode === 503 ? { securityBlocked: true } : {}),
...(isSecurityBlockError(error) ? { securityBlocked: true } : {}),
};
}

Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To support proxy configurations and the outbound URL guard, we should pass isLocal to validateQwenWebProvider so it can be forwarded to fetchWithProxyFallback.

async function validateQwenWebProvider({ apiKey, isLocal }: 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<string, string> = {
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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Using the global fetch directly bypasses the outbound URL guard (outboundUrlGuard) and proxy configurations. This introduces a potential security risk (such as SSRF via redirects or DNS rebinding if the target domain is compromised or spoofed) and can cause validation to fail in environments that require a proxy for outbound connections.

Additionally, by using fetchWithProxyFallback, any blocked redirects will correctly throw a SafeOutboundFetchError, which allows the newly hardened isSecurityBlockError logic to be properly exercised and tested for this provider.

    const resp = await fetchWithProxyFallback(
      "https://chat.qwen.ai/api/v2/user",
      { headers },
      SAFE_OUTBOUND_FETCH_PRESETS.validationWrite,
      isLocal
    );

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");
Expand Down Expand Up @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions tests/unit/provider-validation-specialty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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<string, string> = {};
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("<html>aliyun_waf</html>", {
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);
});