fix(gateway): require owner auth for managed image media (#79204)

This commit is contained in:
Peter Steinberger
2026-05-08 06:34:11 +01:00
committed by GitHub
parent 24f9f4455b
commit a360aa3c8c
3 changed files with 45 additions and 68 deletions

View File

@@ -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.

View File

@@ -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,
});

View File

@@ -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");