mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
fix(gateway): require owner auth for managed image media (#79204)
This commit is contained in:
committed by
GitHub
parent
24f9f4455b
commit
a360aa3c8c
@@ -184,6 +184,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/install: refuse state-mutating OpenClaw CLI runs as root by default, keep an explicit `OPENCLAW_ALLOW_ROOT=1` escape hatch for intentional root/container use, and update DigitalOcean setup guidance to run OpenClaw as a non-root user. Fixes #67478. Thanks @Jerry-Xin and @natechicago.
|
||||
- Auto-reply/media: resolve `scp` from `PATH` when staging sandbox media so nonstandard OpenSSH installs can copy remote attachments.
|
||||
- Agents/PI: route PI-native OpenAI-compatible default streams through OpenClaw boundary-aware transports so local-compatible model runs keep API-key injection and transport policy.
|
||||
- Gateway/media: require authenticated owner or admin context for managed outgoing image bytes instead of trusting requester-session headers.
|
||||
- Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested.
|
||||
- Codex app-server: close stdio stdin before force-killing the managed app-server, matching Codex single-client shutdown behavior and avoiding unsettled CLI exits after successful runs.
|
||||
- CLI/Codex: dispose registered agent harnesses during short-lived CLI shutdown so successful Codex-backed `agent --local` runs do not leave app-server child processes alive.
|
||||
|
||||
@@ -9,13 +9,14 @@ import { setMediaStoreNetworkDepsForTest } from "../media/store.js";
|
||||
|
||||
const authorizeGatewayHttpRequestOrReplyMock = vi.fn();
|
||||
const resolveOpenAiCompatibleHttpOperatorScopesMock = vi.fn();
|
||||
const getLatestSubagentRunByChildSessionKeyMock = vi.fn();
|
||||
const resolveOpenAiCompatibleHttpSenderIsOwnerMock = vi.fn();
|
||||
const loadSessionEntryMock = vi.fn();
|
||||
const readSessionMessagesMock = vi.fn();
|
||||
|
||||
vi.mock("./http-utils.js", () => ({
|
||||
authorizeGatewayHttpRequestOrReply: authorizeGatewayHttpRequestOrReplyMock,
|
||||
resolveOpenAiCompatibleHttpOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopesMock,
|
||||
resolveOpenAiCompatibleHttpSenderIsOwner: resolveOpenAiCompatibleHttpSenderIsOwnerMock,
|
||||
}));
|
||||
|
||||
vi.mock("./session-utils.js", () => ({
|
||||
@@ -23,10 +24,6 @@ vi.mock("./session-utils.js", () => ({
|
||||
readSessionMessagesAsync: readSessionMessagesMock,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/subagent-registry.js", () => ({
|
||||
getLatestSubagentRunByChildSessionKey: getLatestSubagentRunByChildSessionKeyMock,
|
||||
}));
|
||||
|
||||
const {
|
||||
DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS,
|
||||
attachManagedOutgoingImagesToMessage,
|
||||
@@ -128,7 +125,6 @@ async function requestManagedImage(params: {
|
||||
authResponse?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
transcriptMessages?: Record<string, unknown>[];
|
||||
subagentRun?: Record<string, unknown> | null;
|
||||
sessionEntry?: { sessionId: string; sessionFile?: string };
|
||||
}) {
|
||||
authorizeGatewayHttpRequestOrReplyMock.mockImplementation(async ({ res }) => {
|
||||
@@ -140,7 +136,15 @@ async function requestManagedImage(params: {
|
||||
return { ok: true, ...params.authResponse };
|
||||
});
|
||||
resolveOpenAiCompatibleHttpOperatorScopesMock.mockReturnValue(params.scopes ?? ["operator.read"]);
|
||||
getLatestSubagentRunByChildSessionKeyMock.mockReturnValue(params.subagentRun ?? null);
|
||||
resolveOpenAiCompatibleHttpSenderIsOwnerMock.mockImplementation((_req, requestAuth) => {
|
||||
if (requestAuth.authMethod === "token" || requestAuth.authMethod === "password") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
requestAuth.trustDeclaredOperatorScopes === true &&
|
||||
(params.scopes ?? ["operator.read"]).includes("operator.admin")
|
||||
);
|
||||
});
|
||||
loadSessionEntryMock.mockReturnValue({
|
||||
storePath: path.join(params.stateDir, "gateway-sessions.json"),
|
||||
entry: params.sessionEntry ?? { sessionId: "sess-1", sessionFile: "session.jsonl" },
|
||||
@@ -237,7 +241,7 @@ describe("handleManagedOutgoingImageHttpRequest", () => {
|
||||
const { result } = await requestManagedImage({
|
||||
stateDir,
|
||||
pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`,
|
||||
headers: { "x-openclaw-requester-session-key": sessionKey },
|
||||
authResponse: { authMethod: "token" },
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
@@ -259,25 +263,40 @@ describe("handleManagedOutgoingImageHttpRequest", () => {
|
||||
expect(result.body.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects requests from unrelated sessions", async () => {
|
||||
it("rejects non-owner trusted-proxy requests with self-declared session ownership", async () => {
|
||||
const { attachmentId, sessionKey } = await createFixture(stateDir);
|
||||
|
||||
const { result } = await requestManagedImage({
|
||||
stateDir,
|
||||
pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`,
|
||||
headers: { "x-openclaw-requester-session-key": "agent:main:other" },
|
||||
authResponse: { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
|
||||
headers: { "x-openclaw-requester-session-key": sessionKey },
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("allows device-token access without requester session ownership", async () => {
|
||||
it("rejects device-token access with self-declared session ownership", async () => {
|
||||
const { attachmentId, sessionKey } = await createFixture(stateDir);
|
||||
|
||||
const { result } = await requestManagedImage({
|
||||
stateDir,
|
||||
pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`,
|
||||
authResponse: { authMethod: "device-token" },
|
||||
headers: { "x-openclaw-requester-session-key": sessionKey },
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("serves owner trusted-proxy requests with admin scope", async () => {
|
||||
const { attachmentId, sessionKey } = await createFixture(stateDir);
|
||||
|
||||
const { result } = await requestManagedImage({
|
||||
stateDir,
|
||||
pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`,
|
||||
authResponse: { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
@@ -332,14 +351,14 @@ describe("handleManagedOutgoingImageHttpRequest", () => {
|
||||
const first = await requestManagedImage({
|
||||
stateDir,
|
||||
pathName,
|
||||
headers: { "x-openclaw-requester-session-key": sessionKey },
|
||||
authResponse: { authMethod: "token" },
|
||||
sessionEntry: { sessionId: "sess-main", sessionFile },
|
||||
transcriptMessages,
|
||||
});
|
||||
const second = await requestManagedImage({
|
||||
stateDir,
|
||||
pathName,
|
||||
headers: { "x-openclaw-requester-session-key": sessionKey },
|
||||
authResponse: { authMethod: "token" },
|
||||
sessionEntry: { sessionId: "sess-main", sessionFile },
|
||||
transcriptMessages,
|
||||
});
|
||||
@@ -354,7 +373,7 @@ describe("handleManagedOutgoingImageHttpRequest", () => {
|
||||
const third = await requestManagedImage({
|
||||
stateDir,
|
||||
pathName,
|
||||
headers: { "x-openclaw-requester-session-key": sessionKey },
|
||||
authResponse: { authMethod: "token" },
|
||||
sessionEntry: { sessionId: "sess-main", sessionFile },
|
||||
transcriptMessages,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { readLocalFileSafely } from "../infra/fs-safe.js";
|
||||
import { tryReadJson, writeJson } from "../infra/json-files.js";
|
||||
@@ -23,6 +22,7 @@ import { sendJson, sendMethodNotAllowed } from "./http-common.js";
|
||||
import {
|
||||
authorizeGatewayHttpRequestOrReply,
|
||||
resolveOpenAiCompatibleHttpOperatorScopes,
|
||||
resolveOpenAiCompatibleHttpSenderIsOwner,
|
||||
} from "./http-utils.js";
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
import { loadSessionEntry, readSessionMessagesAsync } from "./session-utils.js";
|
||||
@@ -277,31 +277,6 @@ function buildOutgoingVariantUrl(sessionKey: string, attachmentId: string, varia
|
||||
return `${OUTGOING_IMAGE_ROUTE_PREFIX}/${encodeURIComponent(sessionKey)}/${attachmentId}/${variant}`;
|
||||
}
|
||||
|
||||
function resolveRequesterSessionKey(req: IncomingMessage) {
|
||||
const raw = req.headers["x-openclaw-requester-session-key"];
|
||||
if (Array.isArray(raw)) {
|
||||
return raw[0]?.trim() || null;
|
||||
}
|
||||
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null;
|
||||
}
|
||||
|
||||
async function requesterOwnsManagedImageSession(params: {
|
||||
requesterSessionKey: string;
|
||||
targetSessionKey: string;
|
||||
}) {
|
||||
if (params.requesterSessionKey === params.targetSessionKey) {
|
||||
return true;
|
||||
}
|
||||
const subagentRun = getLatestSubagentRunByChildSessionKey(params.targetSessionKey);
|
||||
if (!subagentRun) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
subagentRun.requesterSessionKey === params.requesterSessionKey ||
|
||||
subagentRun.controllerSessionKey === params.requesterSessionKey
|
||||
);
|
||||
}
|
||||
|
||||
function deriveAltText(source: string, index: number) {
|
||||
const fallback = `Generated image ${index + 1}`;
|
||||
try {
|
||||
@@ -1009,9 +984,6 @@ export async function handleManagedOutgoingImageHttpRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
const privilegedAccess =
|
||||
requestAuth.trustDeclaredOperatorScopes || requestAuth.authMethod === "device-token";
|
||||
|
||||
const requestedScopes = resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod("chat.history", requestedScopes);
|
||||
if (!scopeAuth.allowed) {
|
||||
@@ -1046,32 +1018,17 @@ export async function handleManagedOutgoingImageHttpRequest(
|
||||
sendStatus(res, 404, "not found");
|
||||
return true;
|
||||
}
|
||||
if (!privilegedAccess) {
|
||||
const requesterSessionKey = resolveRequesterSessionKey(req);
|
||||
if (!requesterSessionKey) {
|
||||
sendJson(res, 403, {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "forbidden",
|
||||
message: "requester session ownership required",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const ownsSession = await requesterOwnsManagedImageSession({
|
||||
requesterSessionKey,
|
||||
targetSessionKey: record.sessionKey,
|
||||
// Requester-session headers are client-declared, so media bytes require
|
||||
// authenticated owner/admin context rather than trusting a URL-scoped header.
|
||||
if (!resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth)) {
|
||||
sendJson(res, 403, {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "forbidden",
|
||||
message: "owner access required",
|
||||
},
|
||||
});
|
||||
if (!ownsSession) {
|
||||
sendJson(res, 403, {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "forbidden",
|
||||
message: "requester session does not own attachment session",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!(await recordMatchesTranscriptMessage(record))) {
|
||||
sendStatus(res, 404, "not found");
|
||||
|
||||
Reference in New Issue
Block a user