From 6ff3238e8da5d05dc3554d2996b89c41bad8249c Mon Sep 17 00:00:00 2001 From: S0yora <78859542+S0yora@users.noreply.github.com> Date: Sun, 31 May 2026 16:20:32 +0300 Subject: [PATCH] fix(oauth): address Trae review feedback (loopback origin, stream cleanup, scripts) - Restrict the /authorize postMessage and the modal listener to the loopback origin pair (localhost + 127.0.0.1) instead of "*"/single-origin. The dashboard runs on localhost while Trae forces the callback onto 127.0.0.1, so a single window.location.origin target silently dropped the success message (popup never closed). Addresses CWE-359 without breaking the cross-loopback flow. - TraeExecutor: cancel the SSE reader on completion, abort upfront when the caller signal is already aborted, and accept string elements in array message content. - Move ad-hoc dev scripts to scripts/ad-hoc/ per the repo style guide. --- open-sse/executors/trae.ts | 14 ++++++++++++-- scripts/{ => ad-hoc}/diag-trae-auth.mjs | 14 ++++++++++---- scripts/{ => ad-hoc}/smoke-trae.mjs | 10 +++++----- src/app/authorize/route.ts | 20 +++++++++++++++++--- src/shared/components/TraeAuthModal.tsx | 17 ++++++++++++++++- 5 files changed, 60 insertions(+), 15 deletions(-) rename scripts/{ => ad-hoc}/diag-trae-auth.mjs (88%) rename scripts/{ => ad-hoc}/smoke-trae.mjs (83%) diff --git a/open-sse/executors/trae.ts b/open-sse/executors/trae.ts index 195f7c4c20..47b306fc93 100644 --- a/open-sse/executors/trae.ts +++ b/open-sse/executors/trae.ts @@ -32,7 +32,11 @@ function flattenQuery(messages: ChatMessage[]): string { if (typeof m.content === "string") content = m.content; else if (Array.isArray(m.content)) { content = m.content - .map((p) => (p && typeof p === "object" ? String((p as JsonRecord).text ?? "") : "")) + .map((p) => { + if (typeof p === "string") return p; + if (p && typeof p === "object") return String((p as JsonRecord).text ?? ""); + return ""; + }) .join(""); } if (m.role === "system") parts.push(`[System]\n${content}`); @@ -165,6 +169,9 @@ export class TraeExecutor extends BaseExecutor { ): Promise { const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`; const ctrl = new AbortController(); + // If the caller's signal is already aborted, abort upfront so we don't open + // a network request the consumer no longer wants. + if (signal?.aborted) ctrl.abort(); const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS); const onAbort = () => ctrl.abort(); if (signal) signal.addEventListener("abort", onAbort, { once: true }); @@ -193,7 +200,10 @@ export class TraeExecutor extends BaseExecutor { } catch { data = { _raw: payload }; } - if (onEvent(ev, data)) return; // consumer signalled completion + if (onEvent(ev, data)) { + await reader.cancel().catch(() => {}); + return; + } } else if (line === "") ev = null; } } diff --git a/scripts/diag-trae-auth.mjs b/scripts/ad-hoc/diag-trae-auth.mjs similarity index 88% rename from scripts/diag-trae-auth.mjs rename to scripts/ad-hoc/diag-trae-auth.mjs index fa93aac121..fcacb0b3fd 100644 --- a/scripts/diag-trae-auth.mjs +++ b/scripts/ad-hoc/diag-trae-auth.mjs @@ -2,8 +2,8 @@ // Authorization variants, so we can see which one (if any) the server accepts. // // Usage: -// node scripts/diag-trae-auth.mjs -// TRAE_TOKEN=eyJ... node scripts/diag-trae-auth.mjs +// node scripts/ad-hoc/diag-trae-auth.mjs +// TRAE_TOKEN=eyJ... node scripts/ad-hoc/diag-trae-auth.mjs // // Paste ONLY the token value (no "Cloud-IDE-JWT " prefix). The token is never // printed in full; only its length + last 6 chars are shown for sanity. @@ -31,7 +31,10 @@ const commonHeaders = { // Each variant = a set of auth-bearing headers to try. const variants = [ - { name: "Authorization: Cloud-IDE-JWT ", headers: { Authorization: `Cloud-IDE-JWT ${token}` } }, + { + name: "Authorization: Cloud-IDE-JWT ", + headers: { Authorization: `Cloud-IDE-JWT ${token}` }, + }, { name: "Authorization: Bearer ", headers: { Authorization: `Bearer ${token}` } }, { name: "Authorization: (raw)", headers: { Authorization: token } }, { name: "Cloud-IDE-JWT: (header)", headers: { "Cloud-IDE-JWT": token } }, @@ -44,7 +47,10 @@ async function probe(label, url, method, headers) { const res = await fetch(url, { method, headers: { ...commonHeaders, ...headers }, - body: method === "POST" ? JSON.stringify({ mode: "code", env: "remote", origin: "web" }) : undefined, + body: + method === "POST" + ? JSON.stringify({ mode: "code", env: "remote", origin: "web" }) + : undefined, }); const text = await res.text(); const snippet = text.replace(/\s+/g, " ").slice(0, 160); diff --git a/scripts/smoke-trae.mjs b/scripts/ad-hoc/smoke-trae.mjs similarity index 83% rename from scripts/smoke-trae.mjs rename to scripts/ad-hoc/smoke-trae.mjs index 2dd48d609e..fc6f68d6b8 100644 --- a/scripts/smoke-trae.mjs +++ b/scripts/ad-hoc/smoke-trae.mjs @@ -1,13 +1,13 @@ -// End-to-end smoke test: dergaет TraeExecutor против реального Trae с твоим -// JWT из ../trae_solo.env, выводит content + usage. -// Запуск: node --import tsx/esm scripts/smoke-trae.mjs +// End-to-end smoke test: drives TraeExecutor against the real Trae API with your +// JWT from trae_solo.env (kept outside the repo), printing content + usage. +// Run: node --import tsx/esm scripts/ad-hoc/smoke-trae.mjs import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const envPath = path.resolve(__dirname, "../../trae_solo.env"); +const envPath = path.resolve(__dirname, "../../../trae_solo.env"); if (!fs.existsSync(envPath)) { console.error(`Не найден ${envPath}. Положи туда TRAE_TOKEN= и TRAE_WEB_ID= и т.д.`); process.exit(1); @@ -23,7 +23,7 @@ const cfg = Object.fromEntries( }) ); -const { TraeExecutor } = await import("../open-sse/executors/trae.ts"); +const { TraeExecutor } = await import("../../open-sse/executors/trae.ts"); const ex = new TraeExecutor(); const credentials = { diff --git a/src/app/authorize/route.ts b/src/app/authorize/route.ts index 6c8ce70ddb..b2a0954af6 100644 --- a/src/app/authorize/route.ts +++ b/src/app/authorize/route.ts @@ -30,8 +30,12 @@ import { parseTraeCallbackQuery } from "./parseCallback"; * the echoed state before trusting the postMessage. */ function htmlClose(message: Record): NextResponse { - // Embedding values: only emit the small/sanitized status payload — never - // the raw token. The opener trusts origin === self anyway. + // Embedding values: only emit the small/sanitized status payload — never the + // raw token. We post to the loopback origin pair (localhost + 127.0.0.1) on + // this same port rather than "*": Trae forces the callback onto 127.0.0.1, + // but the dashboard opener is usually on localhost, so a single + // window.location.origin target would silently drop the message. Restricting + // to the two known loopback hosts keeps it secure (CWE-359) and working. const safe = JSON.stringify({ type: "trae-oauth-callback", ...message, @@ -41,7 +45,17 @@ function htmlClose(message: Record): NextResponse {

Trae authorization ${message.success ? "✓" : "failed"}

${message.success ? "You can close this window." : "Return to the dashboard."}

`, diff --git a/src/shared/components/TraeAuthModal.tsx b/src/shared/components/TraeAuthModal.tsx index 94b59b77f0..94d1508787 100644 --- a/src/shared/components/TraeAuthModal.tsx +++ b/src/shared/components/TraeAuthModal.tsx @@ -111,7 +111,22 @@ export default function TraeAuthModal({ loginTraceId?: string; } | null; if (!m || m.type !== "trae-oauth-callback") return; - if (traceIdRef.current && m.loginTraceId && m.loginTraceId !== traceIdRef.current) return; + // Accept this window's origin OR the sibling loopback host: the dashboard + // usually runs on localhost while Trae forces the callback onto 127.0.0.1, + // so they are different origins by design. Restrict to that known pair + // (never wildcard), then rely on the random loginTraceId for CSRF. + const here = window.location; + const altHost = + here.hostname === "127.0.0.1" + ? "localhost" + : here.hostname === "localhost" + ? "127.0.0.1" + : null; + const allowedOrigins = new Set([here.origin]); + if (altHost) + allowedOrigins.add(`${here.protocol}//${altHost}${here.port ? `:${here.port}` : ""}`); + if (!allowedOrigins.has(ev.origin)) return; + if (!traceIdRef.current || m.loginTraceId !== traceIdRef.current) return; setAuthorizing(false); if (m.success) { onSuccess?.();