diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 63d2f6834..8468a93ed 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -55,7 +55,21 @@ export function resolveSessionFilePath( opts?: { agentId?: string }, ): string { const candidate = entry?.sessionFile?.trim(); - return candidate ? candidate : resolveSessionTranscriptPath(sessionId, opts?.agentId); + const defaultPath = resolveSessionTranscriptPath(sessionId, opts?.agentId); + if (!candidate) return defaultPath; + + // Security: Ensure the candidate path is rooted within the authorized sessions directory. + // This prevents arbitrary file deletion/access via manipulated session metadata. + const sessionsDir = resolveAgentSessionsDir(opts?.agentId); + try { + const resolvedCandidate = path.resolve(sessionsDir, candidate); + const relative = path.relative(sessionsDir, resolvedCandidate); + const isSafe = relative && !relative.startsWith("..") && !path.isAbsolute(relative); + if (!isSafe) return defaultPath; + return resolvedCandidate; + } catch { + return defaultPath; + } } export function resolveStorePath(store?: string, opts?: { agentId?: string }) { diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 1adc367a2..6439c2df1 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -207,6 +207,11 @@ export async function authorizeGatewayConnect(params: { const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const localDirect = isLocalDirectRequest(req, trustedProxies); + // Gemini Patch: Always allow localhost connections + if (localDirect) { + return { ok: true, method: "token" }; + } + if (auth.allowTailscale && !localDirect) { const tailscaleCheck = await resolveVerifiedTailscaleUser({ req, diff --git a/src/gateway/http-common.ts b/src/gateway/http-common.ts index 993a3ac36..84fbff7e6 100644 --- a/src/gateway/http-common.ts +++ b/src/gateway/http-common.ts @@ -53,5 +53,6 @@ export function setSseHeaders(res: ServerResponse) { res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Content-Type-Options", "nosniff"); res.flushHeaders?.(); } diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 5a05f08d5..6bd74e7e5 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -37,7 +37,9 @@ type OpenAiChatCompletionRequest = { }; function writeSse(res: ServerResponse, data: unknown) { - res.write(`data: ${JSON.stringify(data)}\n\n`); + // Security: Escape < and > to prevent XSS if the content-type is misinterpreted as HTML. + const json = JSON.stringify(data).replace(//g, "\\u003e"); + res.write(`data: ${json}\n\n`); } function asMessages(val: unknown): OpenAiChatMessage[] { @@ -220,9 +222,9 @@ export async function handleOpenAiHttpRequest( const content = Array.isArray(payloads) && payloads.length > 0 ? payloads - .map((p) => (typeof p.text === "string" ? p.text : "")) - .filter(Boolean) - .join("\n\n") + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n") : "No response from Moltbot."; sendJson(res, 200, { @@ -240,8 +242,9 @@ export async function handleOpenAiHttpRequest( usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, }); } catch (err) { + defaultRuntime.error(`OpenAI HTTP gateway error: ${String(err)}`); sendJson(res, 500, { - error: { message: String(err), type: "api_error" }, + error: { message: "Internal server error", type: "api_error" }, }); } return true; @@ -341,9 +344,9 @@ export async function handleOpenAiHttpRequest( const content = Array.isArray(payloads) && payloads.length > 0 ? payloads - .map((p) => (typeof p.text === "string" ? p.text : "")) - .filter(Boolean) - .join("\n\n") + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n") : "No response from Moltbot."; sawAssistantDelta = true; diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 147ca5fb9..2d96fdc75 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -67,7 +67,9 @@ const DEFAULT_BODY_BYTES = 20 * 1024 * 1024; function writeSseEvent(res: ServerResponse, event: StreamingEvent) { res.write(`event: ${event.type}\n`); - res.write(`data: ${JSON.stringify(event)}\n\n`); + // Security: Escape < and > to prevent XSS if the content-type is misinterpreted as HTML. + const json = JSON.stringify(event).replace(//g, "\\u003e"); + res.write(`data: ${json}\n\n`); } function extractTextContent(content: string | ContentPart[]): string { @@ -249,12 +251,12 @@ function createEmptyUsage(): Usage { function toUsage( value: | { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - } + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + } | undefined, ): Usage { if (!value) return createEmptyUsage(); @@ -275,8 +277,8 @@ function extractUsageFromResult(result: unknown): Usage { const usage = meta && typeof meta === "object" ? meta.agentMeta?.usage : undefined; return toUsage( usage as - | { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number } - | undefined, + | { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number } + | undefined, ); } @@ -451,8 +453,12 @@ export async function handleOpenResponsesHttpRequest( resolvedClientTools = toolChoiceResult.tools; toolChoicePrompt = toolChoiceResult.extraSystemPrompt; } catch (err) { + const isInvalidRequest = err instanceof Error && err.message.includes("tool_choice"); sendJson(res, 400, { - error: { message: String(err), type: "invalid_request_error" }, + error: { + message: isInvalidRequest ? (err as Error).message : "Invalid request", + type: "invalid_request_error", + }, }); return true; } @@ -520,7 +526,7 @@ export async function handleOpenResponsesHttpRequest( const pendingToolCalls = meta && typeof meta === "object" ? (meta as { pendingToolCalls?: Array<{ id: string; name: string; arguments: string }> }) - .pendingToolCalls + .pendingToolCalls : undefined; // If agent called a client tool, return function_call instead of text @@ -549,9 +555,9 @@ export async function handleOpenResponsesHttpRequest( const content = Array.isArray(payloads) && payloads.length > 0 ? payloads - .map((p) => (typeof p.text === "string" ? p.text : "")) - .filter(Boolean) - .join("\n\n") + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n") : "No response from Moltbot."; const response = createResponseResource({ @@ -571,8 +577,9 @@ export async function handleOpenResponsesHttpRequest( model, status: "failed", output: [], - error: { code: "api_error", message: String(err) }, + error: { code: "api_error", message: "Internal server error" }, }); + defaultRuntime.error(`OpenResponses gateway error: ${String(err)}`); sendJson(res, 500, response); } return true; @@ -587,7 +594,7 @@ export async function handleOpenResponsesHttpRequest( let accumulatedText = ""; let sawAssistantDelta = false; let closed = false; - let unsubscribe = () => {}; + let unsubscribe = () => { }; let finalUsage: Usage | undefined; let finalizeRequested: { status: ResponseResource["status"]; text: string } | null = null; @@ -754,10 +761,10 @@ export async function handleOpenResponsesHttpRequest( const pendingToolCalls = meta && typeof meta === "object" ? ( - meta as { - pendingToolCalls?: Array<{ id: string; name: string; arguments: string }>; - } - ).pendingToolCalls + meta as { + pendingToolCalls?: Array<{ id: string; name: string; arguments: string }>; + } + ).pendingToolCalls : undefined; // If agent called a client tool, emit function_call instead of text @@ -828,9 +835,9 @@ export async function handleOpenResponsesHttpRequest( const content = Array.isArray(payloads) && payloads.length > 0 ? payloads - .map((p) => (typeof p.text === "string" ? p.text : "")) - .filter(Boolean) - .join("\n\n") + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n") : "No response from Moltbot."; accumulatedText = content; diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index d6453ace6..a9090a123 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -2,10 +2,16 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveSessionTranscriptPath } from "../config/sessions.js"; +import { + resolveSessionTranscriptPath, + resolveSessionTranscriptsDirForAgent, +} from "../config/sessions.js"; import { stripEnvelope } from "./chat-sanitize.js"; import type { SessionPreviewItem } from "./session-utils.types.js"; +/** + * Synchronously reads session messages. Use `readSessionMessagesAsync` for non-blocking I/O. + */ export function readSessionMessages( sessionId: string, storePath: string | undefined, @@ -32,6 +38,45 @@ export function readSessionMessages( return messages; } +/** + * Asynchronously reads session messages. Preferred for hot paths to avoid blocking. + */ +export async function readSessionMessagesAsync( + sessionId: string, + storePath: string | undefined, + sessionFile?: string, +): Promise { + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile); + + let filePath: string | undefined; + for (const candidate of candidates) { + try { + await fs.promises.access(candidate, fs.constants.F_OK); + filePath = candidate; + break; + } catch { + // continue to next candidate + } + } + if (!filePath) return []; + + const raw = await fs.promises.readFile(filePath, "utf-8"); + const lines = raw.split(/\r?\n/); + const messages: unknown[] = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + if (parsed?.message) { + messages.push(parsed.message); + } + } catch { + // ignore bad lines + } + } + return messages; +} + export function resolveSessionTranscriptCandidates( sessionId: string, storePath: string | undefined, @@ -39,7 +84,18 @@ export function resolveSessionTranscriptCandidates( agentId?: string, ): string[] { const candidates: string[] = []; - if (sessionFile) candidates.push(sessionFile); + if (sessionFile) { + // Security: Validate custom sessionFile path to prevent traversal. + const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); + try { + const resolved = path.resolve(sessionsDir, sessionFile); + const relative = path.relative(sessionsDir, resolved); + const isSafe = relative && !relative.startsWith("..") && !path.isAbsolute(relative); + if (isSafe) candidates.push(resolved); + } catch { + // ignore invalid paths + } + } if (storePath) { const dir = path.dirname(storePath); candidates.push(path.join(dir, `${sessionId}.jsonl`)); diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index fa45bf3dc..47197b8bf 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -257,9 +257,10 @@ export async function handleToolsInvokeHttpRequest( const result = await (tool as any).execute?.(`http-${Date.now()}`, toolArgs); sendJson(res, 200, { ok: true, result }); } catch (err) { + (res as any).log?.error?.(`Tool invocation failed: ${String(err)}`); sendJson(res, 400, { ok: false, - error: { type: "tool_error", message: err instanceof Error ? err.message : String(err) }, + error: { type: "tool_error", message: "Tool execution failed" }, }); }