Skip to content

Commit e46fdd4

Browse files
saravmajesticclaude
andcommitted
fix: [AI-7520] harden CLI gateway loopback (PR review majors)
Address sahrizvi's review on #1001: - Register the pending flow in a state-keyed Map synchronously in authorize() BEFORE opening the browser, and have callback() await that promise — an already-signed-in user's instant redirect is no longer dropped as CSRF, and concurrent /auth flows no longer clobber each other - Bind the callback server to 127.0.0.1 (was all-interfaces / LAN-reachable) - Validate `state` BEFORE honoring the `?error=` branch, so an unauthenticated request can't cancel an in-progress sign-in - HTML-escape reflected values in the callback error page (localhost XSS) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d7f0f2c commit e46fdd4

1 file changed

Lines changed: 46 additions & 36 deletions

File tree

packages/opencode/src/altimate/plugin/altimate.ts

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import open from "open"
55
import { AltimateApi } from "../api/client"
66

77
// Loopback port the CLI listens on for the browser to deliver the gateway
8-
// credential after Google sign-in. Must match the redirect the web authorize
9-
// page posts back to. 7317 is otherwise unused in this codebase.
8+
// credential after sign-in. Must match the redirect the web authorize page posts
9+
// back to. 7317 is otherwise unused in this codebase.
1010
const CALLBACK_PORT = 7317
1111

1212
// Web app that hosts the signup/login (authorize) page. Overridable for
@@ -15,14 +15,23 @@ const DEFAULT_WEB_URL = "https://app.myaltimate.com"
1515
// Fallback gateway API base if the callback omits one.
1616
const DEFAULT_API_URL = "https://api.myaltimate.com"
1717

18+
// Escape reflected values before interpolating them into the callback HTML — the
19+
// error text originates from the URL query string, so it must not be trusted.
20+
function escapeHtml(s: string): string {
21+
return s.replace(
22+
/[&<>"']/g,
23+
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
24+
)
25+
}
26+
1827
const HTML_SUCCESS = `<!doctype html><meta charset="utf-8"><title>Altimate Code</title>
1928
<body style="font-family:system-ui;text-align:center;padding:64px">
2029
<h2>Signed in ✓</h2><p>You can return to your terminal.</p>
2130
<script>setTimeout(()=>window.close(),1500)</script></body>`
2231

2332
const HTML_ERROR = (msg: string) => `<!doctype html><meta charset="utf-8"><title>Altimate Code</title>
2433
<body style="font-family:system-ui;text-align:center;padding:64px">
25-
<h2>Connection failed</h2><p>${msg}</p><p>Please return to your terminal and try again.</p></body>`
34+
<h2>Connection failed</h2><p>${escapeHtml(msg)}</p><p>Please return to your terminal and try again.</p></body>`
2635

2736
interface CallbackResult {
2837
api_url: string
@@ -31,13 +40,16 @@ interface CallbackResult {
3140
}
3241

3342
interface Pending {
34-
state: string
3543
resolve: (creds: CallbackResult) => void
3644
reject: (err: Error) => void
3745
}
3846

3947
let server: ReturnType<typeof createServer> | undefined
40-
let pending: Pending | undefined
48+
// Pending flows keyed by the unguessable `state`. Registered synchronously in
49+
// authorize() BEFORE the browser opens, so an instant redirect (an already
50+
// signed-in user) is matched instead of dropped; keying by state also lets two
51+
// concurrent /auth flows coexist without clobbering each other.
52+
const pending = new Map<string, Pending>()
4153

4254
async function startCallbackServer(): Promise<void> {
4355
if (server) return
@@ -54,22 +66,20 @@ async function startCallbackServer(): Promise<void> {
5466
res.end(body)
5567
}
5668

57-
const error = url.searchParams.get("error")
58-
if (error) {
59-
pending?.reject(new Error(error))
60-
pending = undefined
61-
html(200, HTML_ERROR(error))
69+
// Validate `state` FIRST — before honoring `error` — so a request without a
70+
// known state can neither cancel an in-progress flow nor deliver anything.
71+
const state = url.searchParams.get("state")
72+
const entry = state ? pending.get(state) : undefined
73+
if (!state || !entry) {
74+
html(400, HTML_ERROR("Invalid or unknown sign-in state"))
6275
return
6376
}
77+
pending.delete(state)
6478

65-
const state = url.searchParams.get("state")
66-
// Bind the callback to the unguessable state the CLI generated — rejects a
67-
// stray/malicious local request that didn't originate from our browser tab.
68-
if (!pending || !state || state !== pending.state) {
69-
const msg = "Invalid state — possible CSRF"
70-
pending?.reject(new Error(msg))
71-
pending = undefined
72-
html(400, HTML_ERROR(msg))
79+
const error = url.searchParams.get("error")
80+
if (error) {
81+
entry.reject(new Error(error))
82+
html(200, HTML_ERROR(error))
7383
return
7484
}
7585

@@ -78,20 +88,19 @@ async function startCallbackServer(): Promise<void> {
7888
const apiUrl = url.searchParams.get("url") || DEFAULT_API_URL
7989
if (!apiKey || !instance) {
8090
const msg = "Missing credential in callback"
81-
pending.reject(new Error(msg))
82-
pending = undefined
91+
entry.reject(new Error(msg))
8392
html(400, HTML_ERROR(msg))
8493
return
8594
}
8695

87-
const current = pending
88-
pending = undefined
89-
current.resolve({ api_url: apiUrl, instance, api_key: apiKey })
96+
entry.resolve({ api_url: apiUrl, instance, api_key: apiKey })
9097
html(200, HTML_SUCCESS)
9198
})
9299

93100
await new Promise<void>((resolve, reject) => {
94-
server!.listen(CALLBACK_PORT, () => resolve())
101+
// Bind to loopback only — the credential/abort endpoints must not be reachable
102+
// from the LAN.
103+
server!.listen(CALLBACK_PORT, "127.0.0.1", () => resolve())
95104
server!.on("error", reject)
96105
})
97106
}
@@ -103,16 +112,15 @@ function stopCallbackServer() {
103112
}
104113
}
105114

106-
function waitForCallback(state: string, timeoutMs = 5 * 60 * 1000): Promise<CallbackResult> {
115+
// Register a pending flow keyed by `state` and return its promise. Called
116+
// synchronously in authorize() before the browser opens; the server handler
117+
// resolves/rejects it by state, so a fast redirect is never lost.
118+
function registerPending(state: string, timeoutMs = 5 * 60 * 1000): Promise<CallbackResult> {
107119
return new Promise<CallbackResult>((resolve, reject) => {
108120
const timeout = setTimeout(() => {
109-
if (pending) {
110-
pending = undefined
111-
reject(new Error("Timed out waiting for browser sign-in"))
112-
}
121+
if (pending.delete(state)) reject(new Error("Timed out waiting for browser sign-in"))
113122
}, timeoutMs)
114-
pending = {
115-
state,
123+
pending.set(state, {
116124
resolve: (creds) => {
117125
clearTimeout(timeout)
118126
resolve(creds)
@@ -121,7 +129,7 @@ function waitForCallback(state: string, timeoutMs = 5 * 60 * 1000): Promise<Call
121129
clearTimeout(timeout)
122130
reject(err)
123131
},
124-
}
132+
})
125133
})
126134
}
127135

@@ -134,10 +142,11 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
134142
type: "oauth",
135143
label: "Altimate LLM Gateway",
136144
async authorize() {
137-
// Bind the port BEFORE opening the browser so the credential can
138-
// only be delivered to this process.
139145
const state = randomBytes(16).toString("hex")
140146
await startCallbackServer()
147+
// Register the pending flow BEFORE opening the browser so an instant
148+
// redirect can be matched by state rather than dropped as CSRF.
149+
const result = registerPending(state)
141150

142151
const webUrl = (process.env.ALTIMATE_WEB_URL || DEFAULT_WEB_URL).replace(/\/+$/, "")
143152
const redirect = `http://localhost:${CALLBACK_PORT}/callback`
@@ -156,7 +165,7 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
156165
method: "auto",
157166
async callback() {
158167
try {
159-
const creds = await waitForCallback(state)
168+
const creds = await result
160169
// Persist to ~/.altimate/altimate.json — the provider loader
161170
// reads this first (it carries the instance/tenant + api_url
162171
// the generic auth.json store can't).
@@ -169,7 +178,8 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
169178
} catch {
170179
return { type: "failed" }
171180
} finally {
172-
stopCallbackServer()
181+
// Keep the shared server up while another flow is still waiting.
182+
if (pending.size === 0) stopCallbackServer()
173183
}
174184
},
175185
}

0 commit comments

Comments
 (0)