fix: fail-closed shared-session reply routing (#24571) (thanks @brandonwise)

This commit is contained in:
Peter Steinberger
2026-02-25 01:41:04 +00:00
parent e28803503d
commit 885452f5c1
8 changed files with 180 additions and 14 deletions

View File

@@ -96,4 +96,41 @@ describe("agent delivery helpers", () => {
expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled();
expect(resolved.resolvedTo).toBe("+1555");
});
it("prefers turn-source delivery context over session last route", () => {
const plan = resolveAgentDeliveryPlan({
sessionEntry: {
sessionId: "s4",
updatedAt: 4,
deliveryContext: { channel: "slack", to: "U_WRONG", accountId: "wrong" },
},
requestedChannel: "last",
turnSourceChannel: "whatsapp",
turnSourceTo: "+17775550123",
turnSourceAccountId: "work",
accountId: undefined,
wantsDelivery: true,
});
expect(plan.resolvedChannel).toBe("whatsapp");
expect(plan.resolvedTo).toBe("+17775550123");
expect(plan.resolvedAccountId).toBe("work");
});
it("does not reuse mutable session to when only turnSourceChannel is provided", () => {
const plan = resolveAgentDeliveryPlan({
sessionEntry: {
sessionId: "s5",
updatedAt: 5,
deliveryContext: { channel: "slack", to: "U_WRONG" },
},
requestedChannel: "last",
turnSourceChannel: "whatsapp",
accountId: undefined,
wantsDelivery: true,
});
expect(plan.resolvedChannel).toBe("whatsapp");
expect(plan.resolvedTo).toBeUndefined();
});
});

View File

@@ -65,6 +65,15 @@ export function resolveAgentDeliveryPlan(params: {
normalizedTurnSource && isDeliverableMessageChannel(normalizedTurnSource)
? normalizedTurnSource
: undefined;
const turnSourceTo =
typeof params.turnSourceTo === "string" && params.turnSourceTo.trim()
? params.turnSourceTo.trim()
: undefined;
const turnSourceAccountId = normalizeAccountId(params.turnSourceAccountId);
const turnSourceThreadId =
params.turnSourceThreadId != null && params.turnSourceThreadId !== ""
? params.turnSourceThreadId
: undefined;
const baseDelivery = resolveSessionDeliveryTarget({
entry: params.sessionEntry,
@@ -72,9 +81,9 @@ export function resolveAgentDeliveryPlan(params: {
explicitTo,
explicitThreadId: params.explicitThreadId,
turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
turnSourceTo,
turnSourceAccountId,
turnSourceThreadId,
});
const resolvedChannel = (() => {

View File

@@ -470,4 +470,62 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)",
expect(resolved.accountId).toBe("bot-123");
expect(resolved.threadId).toBe(42);
});
it("does not fall back to session target metadata when turnSourceChannel is set", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-no-fallback",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
lastAccountId: "wrong-account",
lastThreadId: "1739142736.000100",
},
requestedChannel: "last",
turnSourceChannel: "whatsapp",
});
expect(resolved.channel).toBe("whatsapp");
expect(resolved.to).toBeUndefined();
expect(resolved.accountId).toBeUndefined();
expect(resolved.threadId).toBeUndefined();
expect(resolved.lastTo).toBeUndefined();
expect(resolved.lastAccountId).toBeUndefined();
expect(resolved.lastThreadId).toBeUndefined();
});
it("uses explicitTo even when turnSourceTo is omitted", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-explicit-to",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
},
requestedChannel: "last",
explicitTo: "+15551234567",
turnSourceChannel: "whatsapp",
});
expect(resolved.channel).toBe("whatsapp");
expect(resolved.to).toBe("+15551234567");
});
it("still allows mismatched lastTo only from turn-scoped metadata", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-mismatch-turn",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
},
requestedChannel: "telegram",
allowMismatchedLastTo: true,
turnSourceChannel: "whatsapp",
turnSourceTo: "+15550000000",
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("+15550000000");
});
});

View File

@@ -89,17 +89,13 @@ export function resolveSessionDeliveryTarget(params: {
const sessionLastChannel =
context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined;
// 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;
// When a turn-source channel is provided, use only turn-scoped metadata.
// Falling back to mutable session fields would re-introduce routing races.
const hasTurnSourceChannel = params.turnSourceChannel != null;
const lastChannel = hasTurnSourceChannel ? params.turnSourceChannel : sessionLastChannel;
const lastTo = hasTurnSourceChannel ? params.turnSourceTo : context?.to;
const lastAccountId = hasTurnSourceChannel ? params.turnSourceAccountId : context?.accountId;
const lastThreadId = hasTurnSourceChannel ? params.turnSourceThreadId : context?.threadId;
const rawRequested = params.requestedChannel ?? "last";
const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested);