From 7f2778b2bca25f45624ee422c50907011128b4f1 Mon Sep 17 00:00:00 2001 From: Antonio Date: Tue, 17 Mar 2026 18:31:58 -0300 Subject: [PATCH] fix(cron): expose callerSessionKey in AJV schemas so session isolation reaches handlers The per-caller ownership enforcement introduced for issue #35447 was silently bypassed: all four mutation/list schemas used additionalProperties:false but did not declare callerSessionKey, causing AJV to strip the field before the handler could read it. As a result resolveCronCallerOptions always received an empty caller and fell back to allow-all behaviour. Fix: - Add optional callerSessionKey (NonEmptyString) to CronListParamsSchema, CronUpdateParamsSchema, CronRemoveParamsSchema and CronRunParamsSchema. - Update the four handlers in server-methods/cron.ts to read p.callerSessionKey instead of the previous p.sessionKey (which was never populated through these schemas). - Add validator tests covering acceptance of the new field and rejection of empty strings across all four operations. Co-Authored-By: Claude Opus 4.6 --- src/gateway/protocol/cron-validators.test.ts | 53 ++++++++++++++++++++ src/gateway/protocol/schema/cron.ts | 7 ++- src/gateway/server-methods/cron.ts | 16 +++--- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/gateway/protocol/cron-validators.test.ts b/src/gateway/protocol/cron-validators.test.ts index 1de9db206b9..3d0b7afa326 100644 --- a/src/gateway/protocol/cron-validators.test.ts +++ b/src/gateway/protocol/cron-validators.test.ts @@ -79,6 +79,59 @@ describe("cron protocol validators", () => { expect(validateCronListParams({ offset: -1 })).toBe(false); }); + it("accepts callerSessionKey on list params", () => { + expect(validateCronListParams({ callerSessionKey: "telegram:direct:111" })).toBe(true); + expect(validateCronListParams({ callerSessionKey: "" })).toBe(false); + }); + + it("accepts callerSessionKey on update params", () => { + expect( + validateCronUpdateParams({ + id: "job-1", + patch: { enabled: false }, + callerSessionKey: "telegram:direct:111", + }), + ).toBe(true); + expect( + validateCronUpdateParams({ + jobId: "job-2", + patch: { enabled: true }, + callerSessionKey: "telegram:direct:222", + }), + ).toBe(true); + expect( + validateCronUpdateParams({ id: "job-1", patch: { enabled: false }, callerSessionKey: "" }), + ).toBe(false); + }); + + it("accepts callerSessionKey on remove params", () => { + expect(validateCronRemoveParams({ id: "job-1", callerSessionKey: "telegram:direct:111" })).toBe( + true, + ); + expect( + validateCronRemoveParams({ jobId: "job-2", callerSessionKey: "telegram:direct:222" }), + ).toBe(true); + expect(validateCronRemoveParams({ id: "job-1", callerSessionKey: "" })).toBe(false); + }); + + it("accepts callerSessionKey on run params", () => { + expect( + validateCronRunParams({ + id: "job-1", + mode: "force", + callerSessionKey: "telegram:direct:111", + }), + ).toBe(true); + expect( + validateCronRunParams({ + jobId: "job-2", + mode: "due", + callerSessionKey: "telegram:direct:222", + }), + ).toBe(true); + expect(validateCronRunParams({ id: "job-1", callerSessionKey: "" })).toBe(false); + }); + it("enforces runs limit minimum for id and jobId selectors", () => { expect(validateCronRunsParams({ id: "job-1", limit: 1 })).toBe(true); expect(validateCronRunsParams({ jobId: "job-2", limit: 1 })).toBe(true); diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index f61d3e42711..b81f3935164 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -275,6 +275,7 @@ export const CronListParamsSchema = Type.Object( enabled: Type.Optional(CronJobsEnabledFilterSchema), sortBy: Type.Optional(CronJobsSortBySchema), sortDir: Type.Optional(CronSortDirSchema), + callerSessionKey: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); @@ -312,12 +313,16 @@ export const CronJobPatchSchema = Type.Object( export const CronUpdateParamsSchema = cronIdOrJobIdParams({ patch: CronJobPatchSchema, + callerSessionKey: Type.Optional(NonEmptyString), }); -export const CronRemoveParamsSchema = cronIdOrJobIdParams({}); +export const CronRemoveParamsSchema = cronIdOrJobIdParams({ + callerSessionKey: Type.Optional(NonEmptyString), +}); export const CronRunParamsSchema = cronIdOrJobIdParams({ mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])), + callerSessionKey: Type.Optional(NonEmptyString), }); export const CronRunsParamsSchema = Type.Object( diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 2651a2a6b3f..46a0566c080 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -83,9 +83,9 @@ export const cronHandlers: GatewayRequestHandlers = { enabled?: "all" | "enabled" | "disabled"; sortBy?: "nextRunAtMs" | "updatedAtMs" | "name"; sortDir?: "asc" | "desc"; - sessionKey?: string; + callerSessionKey?: string; }; - const callerOpts = resolveCronCallerOptions(client, p.sessionKey); + const callerOpts = resolveCronCallerOptions(client, p.callerSessionKey); const page = await context.cron.listPage({ includeDisabled: p.includeDisabled, limit: p.limit, @@ -169,7 +169,7 @@ export const cronHandlers: GatewayRequestHandlers = { id?: string; jobId?: string; patch: Record; - sessionKey?: string; + callerSessionKey?: string; }; const jobId = p.id ?? p.jobId; if (!jobId) { @@ -192,7 +192,7 @@ export const cronHandlers: GatewayRequestHandlers = { return; } } - const callerOpts = resolveCronCallerOptions(client, p.sessionKey); + const callerOpts = resolveCronCallerOptions(client, p.callerSessionKey); try { const job = await context.cron.update(jobId, patch, callerOpts); context.logGateway.info("cron: job updated", { jobId }); @@ -217,7 +217,7 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const p = params as { id?: string; jobId?: string; sessionKey?: string }; + const p = params as { id?: string; jobId?: string; callerSessionKey?: string }; const jobId = p.id ?? p.jobId; if (!jobId) { respond( @@ -227,7 +227,7 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const callerOpts = resolveCronCallerOptions(client, p.sessionKey); + const callerOpts = resolveCronCallerOptions(client, p.callerSessionKey); try { const result = await context.cron.remove(jobId, callerOpts); if (result.removed) { @@ -258,7 +258,7 @@ export const cronHandlers: GatewayRequestHandlers = { id?: string; jobId?: string; mode?: "due" | "force"; - sessionKey?: string; + callerSessionKey?: string; }; const jobId = p.id ?? p.jobId; if (!jobId) { @@ -269,7 +269,7 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const callerOpts = resolveCronCallerOptions(client, p.sessionKey); + const callerOpts = resolveCronCallerOptions(client, p.callerSessionKey); try { const result = await context.cron.enqueueRun(jobId, p.mode ?? "force", callerOpts); respond(true, result, undefined);