Repository navigation
fix(oauth): surface tunnel hint when Codex OAuth runs on a remote host (#7523) #7527
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
diegosouzapw
merged 1 commit into
release/v3.8.49
from
fix/7523-codex-oauth-remote-host
Jul 17, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.`, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>"); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security & Robustness Improvement
Sanitize
hostHeaderto Prevent XSS/HTML Injection:The
hostHeaderis retrieved directly from the user-controlledHostorX-Forwarded-Hostheaders. If the UI renders the returnedmessagestring directly (e.g., usingdangerouslySetInnerHTMLor equivalent), an attacker could spoof these headers to inject malicious HTML/JS. Sanitizing thehostHeaderto only allow valid hostname/IP characters (a-zA-Z0-9.:[\ ]-) completely mitigates this risk.Handle Empty/Whitespace Host Headers:
If the
hostHeaderis an empty string or contains only whitespace,isLoopbackHostwill returnfalse, causingbuildRemoteOAuthHintto returnremoteHost: truewith an empty host name in the message. Adding a check for empty/whitespace-only headers ensures we fail-open safely.