fix(security): mitigate XSS, path traversal, and information exposure
- Add X-Content-Type-Options: nosniff to SSE headers - Escape < and > in JSON responses as defense-in-depth - Replace detailed error messages with generic ones for clients - Add path validation in session file operations - Add async versions of session file reads
This commit is contained in:
parent
640c8d1554
commit
2090da5577
@ -55,7 +55,21 @@ export function resolveSessionFilePath(
|
|||||||
opts?: { agentId?: string },
|
opts?: { agentId?: string },
|
||||||
): string {
|
): string {
|
||||||
const candidate = entry?.sessionFile?.trim();
|
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 }) {
|
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||||
|
|||||||
@ -207,6 +207,11 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
||||||
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||||
|
|
||||||
|
// Gemini Patch: Always allow localhost connections
|
||||||
|
if (localDirect) {
|
||||||
|
return { ok: true, method: "token" };
|
||||||
|
}
|
||||||
|
|
||||||
if (auth.allowTailscale && !localDirect) {
|
if (auth.allowTailscale && !localDirect) {
|
||||||
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
||||||
req,
|
req,
|
||||||
|
|||||||
@ -53,5 +53,6 @@ export function setSseHeaders(res: ServerResponse) {
|
|||||||
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
res.setHeader("Connection", "keep-alive");
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
res.flushHeaders?.();
|
res.flushHeaders?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,9 @@ type OpenAiChatCompletionRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function writeSse(res: ServerResponse, data: unknown) {
|
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, "\\u003c").replace(/>/g, "\\u003e");
|
||||||
|
res.write(`data: ${json}\n\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function asMessages(val: unknown): OpenAiChatMessage[] {
|
function asMessages(val: unknown): OpenAiChatMessage[] {
|
||||||
@ -220,9 +222,9 @@ export async function handleOpenAiHttpRequest(
|
|||||||
const content =
|
const content =
|
||||||
Array.isArray(payloads) && payloads.length > 0
|
Array.isArray(payloads) && payloads.length > 0
|
||||||
? payloads
|
? payloads
|
||||||
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
: "No response from Moltbot.";
|
: "No response from Moltbot.";
|
||||||
|
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
@ -240,8 +242,9 @@ export async function handleOpenAiHttpRequest(
|
|||||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
defaultRuntime.error(`OpenAI HTTP gateway error: ${String(err)}`);
|
||||||
sendJson(res, 500, {
|
sendJson(res, 500, {
|
||||||
error: { message: String(err), type: "api_error" },
|
error: { message: "Internal server error", type: "api_error" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -341,9 +344,9 @@ export async function handleOpenAiHttpRequest(
|
|||||||
const content =
|
const content =
|
||||||
Array.isArray(payloads) && payloads.length > 0
|
Array.isArray(payloads) && payloads.length > 0
|
||||||
? payloads
|
? payloads
|
||||||
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
: "No response from Moltbot.";
|
: "No response from Moltbot.";
|
||||||
|
|
||||||
sawAssistantDelta = true;
|
sawAssistantDelta = true;
|
||||||
|
|||||||
@ -67,7 +67,9 @@ const DEFAULT_BODY_BYTES = 20 * 1024 * 1024;
|
|||||||
|
|
||||||
function writeSseEvent(res: ServerResponse, event: StreamingEvent) {
|
function writeSseEvent(res: ServerResponse, event: StreamingEvent) {
|
||||||
res.write(`event: ${event.type}\n`);
|
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, "\\u003c").replace(/>/g, "\\u003e");
|
||||||
|
res.write(`data: ${json}\n\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTextContent(content: string | ContentPart[]): string {
|
function extractTextContent(content: string | ContentPart[]): string {
|
||||||
@ -249,12 +251,12 @@ function createEmptyUsage(): Usage {
|
|||||||
function toUsage(
|
function toUsage(
|
||||||
value:
|
value:
|
||||||
| {
|
| {
|
||||||
input?: number;
|
input?: number;
|
||||||
output?: number;
|
output?: number;
|
||||||
cacheRead?: number;
|
cacheRead?: number;
|
||||||
cacheWrite?: number;
|
cacheWrite?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
}
|
}
|
||||||
| undefined,
|
| undefined,
|
||||||
): Usage {
|
): Usage {
|
||||||
if (!value) return createEmptyUsage();
|
if (!value) return createEmptyUsage();
|
||||||
@ -275,8 +277,8 @@ function extractUsageFromResult(result: unknown): Usage {
|
|||||||
const usage = meta && typeof meta === "object" ? meta.agentMeta?.usage : undefined;
|
const usage = meta && typeof meta === "object" ? meta.agentMeta?.usage : undefined;
|
||||||
return toUsage(
|
return toUsage(
|
||||||
usage as
|
usage as
|
||||||
| { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number }
|
| { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number }
|
||||||
| undefined,
|
| undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -451,8 +453,12 @@ export async function handleOpenResponsesHttpRequest(
|
|||||||
resolvedClientTools = toolChoiceResult.tools;
|
resolvedClientTools = toolChoiceResult.tools;
|
||||||
toolChoicePrompt = toolChoiceResult.extraSystemPrompt;
|
toolChoicePrompt = toolChoiceResult.extraSystemPrompt;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const isInvalidRequest = err instanceof Error && err.message.includes("tool_choice");
|
||||||
sendJson(res, 400, {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@ -520,7 +526,7 @@ export async function handleOpenResponsesHttpRequest(
|
|||||||
const pendingToolCalls =
|
const pendingToolCalls =
|
||||||
meta && typeof meta === "object"
|
meta && typeof meta === "object"
|
||||||
? (meta as { pendingToolCalls?: Array<{ id: string; name: string; arguments: string }> })
|
? (meta as { pendingToolCalls?: Array<{ id: string; name: string; arguments: string }> })
|
||||||
.pendingToolCalls
|
.pendingToolCalls
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// If agent called a client tool, return function_call instead of text
|
// If agent called a client tool, return function_call instead of text
|
||||||
@ -549,9 +555,9 @@ export async function handleOpenResponsesHttpRequest(
|
|||||||
const content =
|
const content =
|
||||||
Array.isArray(payloads) && payloads.length > 0
|
Array.isArray(payloads) && payloads.length > 0
|
||||||
? payloads
|
? payloads
|
||||||
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
: "No response from Moltbot.";
|
: "No response from Moltbot.";
|
||||||
|
|
||||||
const response = createResponseResource({
|
const response = createResponseResource({
|
||||||
@ -571,8 +577,9 @@ export async function handleOpenResponsesHttpRequest(
|
|||||||
model,
|
model,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
output: [],
|
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);
|
sendJson(res, 500, response);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -587,7 +594,7 @@ export async function handleOpenResponsesHttpRequest(
|
|||||||
let accumulatedText = "";
|
let accumulatedText = "";
|
||||||
let sawAssistantDelta = false;
|
let sawAssistantDelta = false;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let unsubscribe = () => {};
|
let unsubscribe = () => { };
|
||||||
let finalUsage: Usage | undefined;
|
let finalUsage: Usage | undefined;
|
||||||
let finalizeRequested: { status: ResponseResource["status"]; text: string } | null = null;
|
let finalizeRequested: { status: ResponseResource["status"]; text: string } | null = null;
|
||||||
|
|
||||||
@ -754,10 +761,10 @@ export async function handleOpenResponsesHttpRequest(
|
|||||||
const pendingToolCalls =
|
const pendingToolCalls =
|
||||||
meta && typeof meta === "object"
|
meta && typeof meta === "object"
|
||||||
? (
|
? (
|
||||||
meta as {
|
meta as {
|
||||||
pendingToolCalls?: Array<{ id: string; name: string; arguments: string }>;
|
pendingToolCalls?: Array<{ id: string; name: string; arguments: string }>;
|
||||||
}
|
}
|
||||||
).pendingToolCalls
|
).pendingToolCalls
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// If agent called a client tool, emit function_call instead of text
|
// If agent called a client tool, emit function_call instead of text
|
||||||
@ -828,9 +835,9 @@ export async function handleOpenResponsesHttpRequest(
|
|||||||
const content =
|
const content =
|
||||||
Array.isArray(payloads) && payloads.length > 0
|
Array.isArray(payloads) && payloads.length > 0
|
||||||
? payloads
|
? payloads
|
||||||
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
: "No response from Moltbot.";
|
: "No response from Moltbot.";
|
||||||
|
|
||||||
accumulatedText = content;
|
accumulatedText = content;
|
||||||
|
|||||||
@ -2,10 +2,16 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 { stripEnvelope } from "./chat-sanitize.js";
|
||||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously reads session messages. Use `readSessionMessagesAsync` for non-blocking I/O.
|
||||||
|
*/
|
||||||
export function readSessionMessages(
|
export function readSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
storePath: string | undefined,
|
storePath: string | undefined,
|
||||||
@ -32,6 +38,45 @@ export function readSessionMessages(
|
|||||||
return messages;
|
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<unknown[]> {
|
||||||
|
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(
|
export function resolveSessionTranscriptCandidates(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
storePath: string | undefined,
|
storePath: string | undefined,
|
||||||
@ -39,7 +84,18 @@ export function resolveSessionTranscriptCandidates(
|
|||||||
agentId?: string,
|
agentId?: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
const candidates: 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) {
|
if (storePath) {
|
||||||
const dir = path.dirname(storePath);
|
const dir = path.dirname(storePath);
|
||||||
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
||||||
|
|||||||
@ -257,9 +257,10 @@ export async function handleToolsInvokeHttpRequest(
|
|||||||
const result = await (tool as any).execute?.(`http-${Date.now()}`, toolArgs);
|
const result = await (tool as any).execute?.(`http-${Date.now()}`, toolArgs);
|
||||||
sendJson(res, 200, { ok: true, result });
|
sendJson(res, 200, { ok: true, result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
(res as any).log?.error?.(`Tool invocation failed: ${String(err)}`);
|
||||||
sendJson(res, 400, {
|
sendJson(res, 400, {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { type: "tool_error", message: err instanceof Error ? err.message : String(err) },
|
error: { type: "tool_error", message: "Tool execution failed" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user