fix(security): prevent cross-channel reply routing in shared sessions

This commit is contained in:
Brandon Wise
2026-02-23 10:18:34 -05:00
committed by Peter Steinberger
parent c7ae4ed04d
commit 455fbc6b6d
3 changed files with 136 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ import {
isDeliverableMessageChannel,
isGatewayMessageChannel,
normalizeMessageChannel,
type DeliverableMessageChannel,
type GatewayMessageChannel,
} from "../../utils/message-channel.js";
import type { OutboundTargetResolution } from "./targets.js";
@@ -32,6 +33,20 @@ export function resolveAgentDeliveryPlan(params: {
explicitThreadId?: string | number;
accountId?: string;
wantsDelivery: boolean;
/**
* The channel that originated the current agent turn. When provided,
* overrides session-level `lastChannel` to prevent cross-channel reply
* routing in shared sessions (dmScope="main").
*
* @see https://github.com/openclaw/openclaw/issues/24152
*/
turnSourceChannel?: string;
/** Turn-source `to` — paired with `turnSourceChannel`. */
turnSourceTo?: string;
/** Turn-source `accountId` — paired with `turnSourceChannel`. */
turnSourceAccountId?: string;
/** Turn-source `threadId` — paired with `turnSourceChannel`. */
turnSourceThreadId?: string | number;
}): AgentDeliveryPlan {
const requestedRaw =
typeof params.requestedChannel === "string" ? params.requestedChannel.trim() : "";
@@ -43,11 +58,24 @@ export function resolveAgentDeliveryPlan(params: {
? params.explicitTo.trim()
: undefined;
// Resolve turn-source channel for cross-channel safety.
const normalizedTurnSource = params.turnSourceChannel
? normalizeMessageChannel(params.turnSourceChannel)
: undefined;
const turnSourceChannel =
normalizedTurnSource && isDeliverableMessageChannel(normalizedTurnSource)
? normalizedTurnSource
: undefined;
const baseDelivery = resolveSessionDeliveryTarget({
entry: params.sessionEntry,
requestedChannel: requestedChannel === INTERNAL_MESSAGE_CHANNEL ? "last" : requestedChannel,
explicitTo,
explicitThreadId: params.explicitThreadId,
turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
});
const resolvedChannel = (() => {

View File

@@ -357,3 +357,79 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.threadId).toBe(1008013);
});
});
describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", () => {
it("uses turnSourceChannel over session lastChannel when provided", () => {
// Simulate: WhatsApp message originated the turn, but a Slack message
// arrived concurrently and updated lastChannel to "slack"
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-shared",
updatedAt: 1,
lastChannel: "slack", // <- concurrently overwritten
lastTo: "U0AEMECNCBV", // <- Slack user (wrong target)
},
requestedChannel: "last",
turnSourceChannel: "whatsapp", // <- originated from WhatsApp
turnSourceTo: "+66972796305", // <- WhatsApp user (correct target)
});
expect(resolved.channel).toBe("whatsapp");
expect(resolved.to).toBe("+66972796305");
});
it("falls back to session lastChannel when turnSourceChannel is not set", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-normal",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "8587265585",
},
requestedChannel: "last",
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("8587265585");
});
it("respects explicit requestedChannel over turnSourceChannel", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-explicit",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U12345",
},
requestedChannel: "telegram",
explicitTo: "8587265585",
turnSourceChannel: "whatsapp",
turnSourceTo: "+66972796305",
});
// Explicit requestedChannel "telegram" is not "last", so it takes priority
expect(resolved.channel).toBe("telegram");
});
it("preserves turnSourceAccountId and turnSourceThreadId", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-meta",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
lastAccountId: "wrong-account",
},
requestedChannel: "last",
turnSourceChannel: "telegram",
turnSourceTo: "8587265585",
turnSourceAccountId: "bot-123",
turnSourceThreadId: 42,
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("8587265585");
expect(resolved.accountId).toBe("bot-123");
expect(resolved.threadId).toBe(42);
});
});

View File

@@ -62,13 +62,41 @@ export function resolveSessionDeliveryTarget(params: {
fallbackChannel?: DeliverableMessageChannel;
allowMismatchedLastTo?: boolean;
mode?: ChannelOutboundTargetMode;
/**
* When set, this overrides the session-level `lastChannel` for "last"
* resolution. This prevents cross-channel reply routing when multiple
* channels share the same session (dmScope = "main") and an inbound
* message from a different channel updates `lastChannel` while an agent
* turn is still in flight.
*
* Callers should set this to the channel that originated the current
* agent turn so the reply always routes back to the correct channel.
*
* @see https://github.com/openclaw/openclaw/issues/24152
*/
turnSourceChannel?: DeliverableMessageChannel;
/** Turn-source `to` — paired with `turnSourceChannel`. */
turnSourceTo?: string;
/** Turn-source `accountId` — paired with `turnSourceChannel`. */
turnSourceAccountId?: string;
/** Turn-source `threadId` — paired with `turnSourceChannel`. */
turnSourceThreadId?: string | number;
}): SessionDeliveryTarget {
const context = deliveryContextFromSession(params.entry);
const lastChannel =
const sessionLastChannel =
context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined;
const lastTo = context?.to;
const lastAccountId = context?.accountId;
const lastThreadId = context?.threadId;
// When a turn-source channel is provided, use it instead of the session's
// mutable lastChannel. This prevents a concurrent inbound from a different
// channel from hijacking the reply target (cross-channel privacy leak).
const lastChannel = params.turnSourceChannel ?? sessionLastChannel;
const lastTo = params.turnSourceChannel ? (params.turnSourceTo ?? context?.to) : context?.to;
const lastAccountId = params.turnSourceChannel
? (params.turnSourceAccountId ?? context?.accountId)
: context?.accountId;
const lastThreadId = params.turnSourceChannel
? (params.turnSourceThreadId ?? context?.threadId)
: context?.threadId;
const rawRequested = params.requestedChannel ?? "last";
const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested);