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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ _In development — bullets added per PR; finalized at release._
### 🐛 Fixed

- **fix(translator): Gemini accepts HTTP/HTTPS image URLs (no longer silently dropped)** — OpenAI-style `image_url` parts whose URL was `http://…` or `https://…` reached `convertOpenAIContentToParts` (the OpenAI→Gemini request helper) and were dropped with only a `console.warn`, because Gemini's `inlineData` requires base64 and the helper is synchronous (it cannot fetch + encode). Gemini's `Part` schema, however, natively accepts `fileData: { fileUri }` for remote URIs — the model fetches the asset itself. The helper now emits a `fileData` part (`mimeType: "image/*"`, inferred upstream on fetch) instead of dropping, so vision requests that pass a URL — not a data: URI — now reach Gemini intact. `data:` URIs still go through `inlineData` unchanged; unsupported schemes (e.g. `ftp:`) are still skipped. (thanks @East-rayyy)
- **fix(security): OAuth callback page no longer relays `code`/`state` to a wildcard `postMessage` target** — the OAuth callback at `/callback` posted `{ code, state, ... }` to `window.opener.postMessage(..., "*")` whenever the opener was cross-origin (a fallback for the legitimate remote-dashboard + local-loopback callback scenario). A hostile page that opened the callback URL in a popup against the well-known redirect URI would therefore receive the OAuth code+state and could complete the OAuth flow as the user. The wildcard fallback is replaced with an iteration over a fixed allowlist of trusted target origins (same-origin + Codex's loopback helper at `localhost:1455` / `127.0.0.1:1455`); the browser silently drops the message for any opener whose origin is not in the list. Methods 2 (`BroadcastChannel`) and 3 (`localStorage`) — already in the page — still cover same-origin parents when the opener was severed by COOP. (thanks @aeonframework)
- **fix(compliance): startup cleanup honors the dashboard data-retention setting instead of always trimming to 7 days** — on every restart, `cleanupExpiredLogs()` (run at startup) read retention only from the `CALL_LOG_RETENTION_DAYS` / `APP_LOG_RETENTION_DAYS` env vars, which default to **7 days** when unset, and trimmed `usage_history` (the Usage Analysis data) before the dashboard-based `runAutoCleanup()` — which respects the configured retention — ever ran. So a dashboard "Data Retention" of 90 days was silently overridden and the Usage Analysis page only ever showed the last 7 days after a restart. Retention now follows the precedence **explicit env var → dashboard DB setting → 7-day default**, per table (`usage_history`→`usageHistory`, `call_logs`/`proxy_logs`/`request_detail_logs`→`callLogs`, `mcp_tool_audit`→`mcpAudit`); an operator who sets the env var still wins, and non-DB deployments still fall back to it. ([#4354](https://github.com/diegosouzapw/OmniRoute/issues/4354) — thanks @akbardwi)
- **fix(providers): bailian-coding-plan static fallback catalog matches the registry (10 models)** — the provider-model sweep (#4324) added four current Model Studio coding-plan models (`qwen3.7-plus`, `qwen3-coder-plus`, `qwen3-coder-next`, `glm-4.7`) to the `bailian-coding-plan` registry entry but missed the static fallback mirror in `staticModels.ts`, which still listed only the older six. The static catalog (served when live discovery is unavailable) therefore diverged from the registry, and the existing static↔registry parity test went red on the release branch (only surfacing when test-impact analysis happened to select it). The static mirror now carries all ten models in registry order, restoring parity. ([#4324](https://github.com/diegosouzapw/OmniRoute/pull/4324))
- **fix(executors): ArenaLLM accepts LMArena's split Supabase SSR auth cookie** — LMArena migrated to `@supabase/ssr` chunked auth cookies: the single `arena-auth-prod-v1` cookie is now empty and the real session is split across `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). A user who pasted the (now-empty) single cookie therefore sent an empty session and upstream rejected it as "invalid cookie". The LMArena executor now reconstructs the single cookie from its chunks — reading `.0`, `.1`, … in ascending numeric order until one is missing and concatenating their raw values (`@supabase/ssr`'s `combineChunks` rule: plain `join("")`, no base64-decode, no JSON-parse, the `base64-` prefix kept verbatim) — while preserving the rest of the pasted jar. A non-empty single cookie is still forwarded unchanged (back-compat). The credential UX now instructs pasting the **full Cookie header** and tracks the `.0`/`.1` storage keys. ([#4271](https://github.com/diegosouzapw/OmniRoute/issues/4271) — thanks @caussao)
Expand Down
39 changes: 20 additions & 19 deletions src/app/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,29 +52,30 @@ export default function CallbackPage() {

// Method 1: postMessage to opener (popup mode).
// May be null when Google OAuth's COOP header severs the opener reference.
// For remote OmniRoute + local loopback callbacks, the callback page origin
// is http://127.0.0.1:<port> while the opener is the public OmniRoute origin.
// Use a wildcard fallback only for the opener that initiated this popup; the
// parent validates the OAuth state before accepting the callback.
//
// Only relay {code, state} to a known-trusted target origin. A wildcard "*"
// here would leak the OAuth code/state to a hostile opener — e.g. a page
// that opened this callback URL in a popup to phish the code. The browser
// delivers postMessage only when the opener's origin matches `targetOrigin`,
// so iterating over an allowlist lets the same-origin parent and Codex's
// fixed loopback helper receive it while silently dropping it for any other
// origin. Methods 2 (BroadcastChannel) and 3 (localStorage) cover the
// same-origin fallback when the opener was severed by COOP.
const trustedTargetOrigins = [
window.location.origin, // Same origin (dashboard popup mode).
"http://localhost:1455", // Codex helper (fixed loopback port).
"http://127.0.0.1:1455", // Same Codex helper, IPv4 literal form.
];
Comment on lines +64 to +68

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.

medium

To prevent sending duplicate postMessage events to the opener (which can trigger duplicate event handlers or race conditions in the parent window), we should deduplicate the trustedTargetOrigins array. This is especially important if the application is running on one of the hardcoded loopback origins (e.g., during local development or testing), where window.location.origin would match one of the other entries.

    const trustedTargetOrigins = Array.from(
      new Set([
        window.location.origin, // Same origin (dashboard popup mode).
        "http://localhost:1455", // Codex helper (fixed loopback port).
        "http://127.0.0.1:1455", // Same Codex helper, IPv4 literal form.
      ])
    );

if (window.opener) {
try {
// Target this origin specifically — popup mode is only used when isTrueLocalhost,
// so the opener is always on the same origin as the callback page.
window.opener.postMessage(
{ type: "oauth_callback", data: callbackData },
window.location.origin
);
sent = true;
} catch (e) {
console.log("postMessage failed:", e);
}

if (!openerSameOrigin) {
for (const origin of trustedTargetOrigins) {
try {
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*");
window.opener.postMessage(
{ type: "oauth_callback", data: callbackData },
origin
);
sent = true;
} catch (e) {
console.log("cross-origin postMessage failed:", e);
console.log("postMessage failed:", e);
}
}
}
Expand Down
111 changes: 111 additions & 0 deletions tests/unit/ui/oauth-callback-postmessage-scope.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Mock next-intl translations (the page imports useTranslations("auth")).
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));

import CallbackPage from "@/app/callback/page";

/**
* Regression guard for ported upstream PR decolua/9router#998 (security):
* the OAuth callback page must never relay {code, state} to a wildcard
* postMessage target ("*"), as a hostile opener can read the code/state and
* complete the OAuth flow as the user. Only the same-origin parent and
* Codex's fixed loopback helper (127.0.0.1:1455) are trusted targets.
*/
describe("OAuth callback page — postMessage target origin scope (#998)", () => {
let container: HTMLDivElement;
let root: Root;
let postMessageSpy: ReturnType<typeof vi.fn>;
let originalOpener: typeof window.opener;

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
postMessageSpy = vi.fn();
originalOpener = window.opener;

// Set the callback URL with OAuth params (triggers the postMessage send).
window.history.replaceState({}, "", "/callback?code=test_code_abc123&state=test_state_xyz789");

// Stub window.opener as a CROSS-ORIGIN opener: same-origin probe must throw
// (mimics a real cross-origin window.opener), which means the page falls into
// the fallback path that previously used a wildcard "*" target origin.
Object.defineProperty(window, "opener", {
configurable: true,
writable: true,
value: {
postMessage: postMessageSpy,
get location(): never {
throw new Error("cross-origin access blocked");
},
},
});
});

afterEach(() => {
act(() => root.unmount());
container.remove();
Object.defineProperty(window, "opener", {
configurable: true,
writable: true,
value: originalOpener,
});
vi.clearAllMocks();
});

it("never targets the wildcard '*' origin even when opener is cross-origin", async () => {
await act(async () => {
root.render(<CallbackPage />);
});
// Give useEffect a microtask to flush.
await act(async () => {
await Promise.resolve();
});

const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]);
expect(targetOrigins).not.toContain("*");
});

it("only targets trusted origins (same-origin + Codex 127.0.0.1:1455)", async () => {
await act(async () => {
root.render(<CallbackPage />);
});
await act(async () => {
await Promise.resolve();
});

const trusted = new Set([window.location.origin, "http://localhost:1455", "http://127.0.0.1:1455"]);
const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]);
expect(targetOrigins.length).toBeGreaterThan(0);
for (const origin of targetOrigins) {
expect(trusted.has(origin)).toBe(true);
}
});

it("delivers the OAuth code/state payload at least once via postMessage", async () => {
await act(async () => {
root.render(<CallbackPage />);
});
await act(async () => {
await Promise.resolve();
});

// Sanity: the scoped postMessage path still actually attempts delivery.
expect(postMessageSpy).toHaveBeenCalled();
const firstCall = postMessageSpy.mock.calls[0];
expect(firstCall[0]).toMatchObject({
type: "oauth_callback",
data: expect.objectContaining({
code: "test_code_abc123",
state: "test_state_xyz789",
}),
});
});
});
Loading