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.d/fixes/7523-codex-oauth-remote-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The PKCE OAuth start (`/api/oauth/[provider]/start-callback-server`, used by Codex/Windsurf/Devin) now detects when OmniRoute is being driven from a remote host and returns a reverse-tunnel hint (`remoteHost`, `tunnelCommand`, `message`) instead of hanging silently: the callback server binds the *server's* localhost:PORT, so a browser on a different machine would redirect to its own localhost and never complete. Loopback access is unchanged (#7523).
31 changes: 31 additions & 0 deletions src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { isLoopbackHost } from "@/server/authz/routeGuard";

export type RemoteOAuthHint =
| { remoteHost: false }
| { remoteHost: true; tunnelCommand: string; message: string };

/**
* #7523: The PKCE callback server binds the SERVER's loopback (localhost:PORT).
* If the operator drives the OAuth flow from a different machine (OmniRoute on
* a remote host/VPS), the provider redirects the browser to the operator's OWN
* localhost:PORT, not the server's — the confirmation screen hangs forever.
* When the request's Host is non-loopback, return the reverse-tunnel hint so
* the UI can show it instead of a silent hang.
*
* The Host header is spoofable, so this drives only a UI hint, never an
* auth/security decision.
*/
export function buildRemoteOAuthHint(hostHeader: string | null, port: number): RemoteOAuthHint {
if (hostHeader == null || isLoopbackHost(hostHeader)) {
return { remoteHost: false };
}
return {
remoteHost: true,
tunnelCommand: `ssh -L ${port}:127.0.0.1:${port} <user>@<omniroute-host>`,
message:
`OmniRoute appears to be running on a remote host (${hostHeader}). ` +
`The OAuth callback returns to localhost:${port} on THIS machine, not the server, ` +
`so the login will hang. Open a reverse tunnel first (see tunnelCommand), then retry — ` +
`or use the token import flow instead.`,
};
}
Comment on lines +18 to +31

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

Security & Robustness Improvement

  1. Sanitize hostHeader to Prevent XSS/HTML Injection:
    The hostHeader is retrieved directly from the user-controlled Host or X-Forwarded-Host headers. If the UI renders the returned message string directly (e.g., using dangerouslySetInnerHTML or equivalent), an attacker could spoof these headers to inject malicious HTML/JS. Sanitizing the hostHeader to only allow valid hostname/IP characters (a-zA-Z0-9.:[\ ]-) completely mitigates this risk.

  2. Handle Empty/Whitespace Host Headers:
    If the hostHeader is an empty string or contains only whitespace, isLoopbackHost will return false, causing buildRemoteOAuthHint to return remoteHost: true with an empty host name in the message. Adding a check for empty/whitespace-only headers ensures we fail-open safely.

21 changes: 19 additions & 2 deletions src/app/api/oauth/[provider]/[action]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { keychainImportOnlyGuard } from "./keychainImportOnly";
import { buildRemoteOAuthHint } from "./remoteOAuthHint";

// Use globalThis to persist callback server state across Next.js HMR reloads
if (!globalThis.__codexCallbackState) {
Expand Down Expand Up @@ -242,7 +243,7 @@ export async function GET(
}

if (action === "start-callback-server") {
return await handleStartCallbackServer(provider, searchParams);
return await handleStartCallbackServer(provider, searchParams, request);
}

if (action === "public-link-status") {
Expand All @@ -268,7 +269,11 @@ export async function GET(
* Codex uses fixed port 1455; Windsurf/Devin CLI use a random free port (port 0).
* Returns the auth URL and stores codeVerifier for later exchange.
*/
async function handleStartCallbackServer(provider: string, searchParams: URLSearchParams) {
async function handleStartCallbackServer(
provider: string,
searchParams: URLSearchParams,
request?: Request
) {
if (!PKCE_CALLBACK_PROVIDERS.has(provider)) {
return NextResponse.json(
{ error: `Callback server not supported for provider: ${provider}` },
Expand Down Expand Up @@ -323,11 +328,23 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear
}
}, 300000);

// #7523: the PKCE callback server listens on the SERVER's loopback
// (localhost:PORT). When the operator drives the OAuth flow from a
// *different* machine (OmniRoute running on a remote host/VPS), the
// provider redirects the browser to the operator's own localhost:PORT,
// not the server's — so the final confirmation screen hangs forever.
// Detect a non-loopback Host and surface the reverse-tunnel instruction
// (or steer to the paste/import flow) instead of a silent hang.
const hostHeader =
request?.headers.get("x-forwarded-host") || request?.headers.get("host") || null;
const remoteHint = buildRemoteOAuthHint(hostHeader, port);

return NextResponse.json({
authUrl: authData.authUrl,
codeVerifier: authData.codeVerifier,
redirectUri,
serverPort: port,
...remoteHint,
});
} catch (error) {
console.error("OAuth start-callback-server error:", error);
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/codex-oauth-remote-host-hint-7523.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Regression test for #7523: the Codex (and Windsurf/Devin) PKCE OAuth callback
// server binds the SERVER's loopback (localhost:PORT). When OmniRoute runs on a
// remote host (e.g. the VPS) and the operator drives the browser from a different
// machine, the provider redirects to the operator's OWN localhost:PORT — the
// login confirmation screen hangs forever with no explanation.
//
// buildRemoteOAuthHint() detects a non-loopback Host and surfaces the
// reverse-tunnel instruction so the start-callback-server response carries it
// (the UI shows it instead of a silent hang). Loopback access is unaffected.

import test from "node:test";
import assert from "node:assert/strict";

import { buildRemoteOAuthHint } from "../../src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts";

test("loopback Host → no remote hint (local access is unaffected)", () => {
for (const host of ["localhost", "localhost:20128", "127.0.0.1:20128", "[::1]:20128", "::1"]) {
const hint = buildRemoteOAuthHint(host, 1455);
assert.equal(hint.remoteHost, false, `expected no hint for loopback host ${host}`);
}
});

test("null Host → no remote hint (fail-open: never block a local flow on a missing header)", () => {
const hint = buildRemoteOAuthHint(null, 1455);
assert.equal(hint.remoteHost, false);
});

test("remote Host → returns the reverse-tunnel hint with the exact callback port", () => {
const hint = buildRemoteOAuthHint("192.168.0.15:20128", 1455);
assert.equal(hint.remoteHost, true);
assert.ok(hint.remoteHost === true); // narrow the union
// The tunnel must forward the SAME port the callback server bound, both sides.
assert.equal(hint.tunnelCommand, "ssh -L 1455:127.0.0.1:1455 <user>@<omniroute-host>");
assert.match(hint.message, /remote host \(192\.168\.0\.15:20128\)/);
assert.match(hint.message, /hang/i);
});

test("remote Host honours a random callback port (Windsurf/Devin OS-assigned port)", () => {
const hint = buildRemoteOAuthHint("omniroute.example.com", 54321);
assert.ok(hint.remoteHost === true);
assert.equal(hint.tunnelCommand, "ssh -L 54321:127.0.0.1:54321 <user>@<omniroute-host>");
});
Loading