From de89d6fc615a394653d9e65e791e0d17bff8183c Mon Sep 17 00:00:00 2001 From: Jaynel Patiarba Date: Wed, 8 Apr 2026 03:33:13 +0800 Subject: [PATCH 1/2] fix(auth): add auth + org ownership check to public events endpoint The POST /org/:organizationId/events/:type endpoint had no authentication gate, allowing unauthenticated cross-tenant event injection including persistent cron events. Adds the same auth + org ownership pattern used by the adjacent watch endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/app.ts | 32 ++-- .../mesh/src/api/routes/public-events.test.ts | 152 ++++++++++++++++++ 2 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 apps/mesh/src/api/routes/public-events.test.ts diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index d879b28894..b0c91868ed 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -1342,18 +1342,28 @@ export async function createApp(options: CreateAppOptions = {}) { // Public Events endpoint app.post("/org/:organizationId/events/:type", async (c) => { + const meshContext = c.var.meshContext; + + // Require authentication (user session or API key) + const userId = meshContext.auth.user?.id ?? meshContext.auth.apiKey?.userId; + if (!userId) { + return c.json({ error: "Unauthorized" }, 401); + } + const orgId = c.req.param("organizationId"); - await c.var.meshContext.eventBus.publish( - orgId, - WellKnownOrgMCPId.SELF(orgId), - { - data: await c.req.json(), - type: `public:${c.req.param("type")}`, - subject: c.req.query("subject"), - deliverAt: c.req.query("deliverAt"), - cron: c.req.query("cron"), - }, - ); + + // Verify the authenticated user belongs to the target organization + if (orgId !== meshContext.organization?.id) { + return c.json({ error: "Forbidden access to organization" }, 403); + } + + await meshContext.eventBus.publish(orgId, WellKnownOrgMCPId.SELF(orgId), { + data: await c.req.json(), + type: `public:${c.req.param("type")}`, + subject: c.req.query("subject"), + deliverAt: c.req.query("deliverAt"), + cron: c.req.query("cron"), + }); return c.json({ success: true }); }); diff --git a/apps/mesh/src/api/routes/public-events.test.ts b/apps/mesh/src/api/routes/public-events.test.ts new file mode 100644 index 0000000000..28e48c5ba3 --- /dev/null +++ b/apps/mesh/src/api/routes/public-events.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, mock } from "bun:test"; +import { Hono } from "hono"; +import type { MeshContext } from "../../core/mesh-context"; +import { WellKnownOrgMCPId } from "@decocms/mesh-sdk"; + +/** + * Tests for POST /org/:organizationId/events/:type + * + * The handler is inline in app.ts, so we replicate it here to verify the + * auth + org-ownership guards in isolation. + */ + +type Env = { Variables: { meshContext: MeshContext } }; + +function createApp(ctx: unknown) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("meshContext", ctx as MeshContext); + await next(); + }); + app.post("/org/:organizationId/events/:type", async (c) => { + const meshContext = c.var.meshContext; + + const userId = meshContext.auth.user?.id ?? meshContext.auth.apiKey?.userId; + if (!userId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const orgId = c.req.param("organizationId"); + + if (orgId !== meshContext.organization?.id) { + return c.json({ error: "Forbidden access to organization" }, 403); + } + + await meshContext.eventBus.publish(orgId, WellKnownOrgMCPId.SELF(orgId), { + data: await c.req.json(), + type: `public:${c.req.param("type")}`, + subject: c.req.query("subject"), + deliverAt: c.req.query("deliverAt"), + cron: c.req.query("cron"), + }); + return c.json({ success: true }); + }); + return app; +} + +function postEvent( + app: Hono, + orgId: string, + type: string, + body: unknown, + query?: string, +) { + const qs = query ? `?${query}` : ""; + return app.request(`/org/${orgId}/events/${type}${qs}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /org/:organizationId/events/:type", () => { + it("rejects unauthenticated requests with 401", async () => { + const app = createApp({ + auth: { user: undefined, apiKey: undefined }, + organization: undefined, + eventBus: { publish: mock() }, + }); + + const res = await postEvent(app, "org_victim", "data", { + payload: "attack", + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Unauthorized"); + }); + + it("rejects cross-tenant access with 403", async () => { + const publishMock = mock(); + const app = createApp({ + auth: { user: { id: "user_1" } }, + organization: { id: "org_attacker" }, + eventBus: { publish: publishMock }, + }); + + const res = await postEvent(app, "org_victim", "data", { + payload: "attack", + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Forbidden access to organization"); + expect(publishMock).not.toHaveBeenCalled(); + }); + + it("rejects org-less API key with 403", async () => { + const publishMock = mock(); + const app = createApp({ + auth: { user: undefined, apiKey: { userId: "user_1" } }, + organization: undefined, + eventBus: { publish: publishMock }, + }); + + const res = await postEvent(app, "org_victim", "data", { + payload: "attack", + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Forbidden access to organization"); + expect(publishMock).not.toHaveBeenCalled(); + }); + + it("rejects cron injection from different org with 403", async () => { + const publishMock = mock(); + const app = createApp({ + auth: { user: { id: "user_1" } }, + organization: { id: "org_attacker" }, + eventBus: { publish: publishMock }, + }); + + const res = await postEvent( + app, + "org_victim", + "trigger", + { recurring: true }, + "cron=*/5+*+*+*+*", + ); + + expect(res.status).toBe(403); + expect(publishMock).not.toHaveBeenCalled(); + }); + + it("allows authenticated user to publish to own org", async () => { + const publishMock = mock(); + const app = createApp({ + auth: { user: { id: "user_1" } }, + organization: { id: "org_1" }, + eventBus: { publish: publishMock }, + }); + + const res = await postEvent(app, "org_1", "data", { + payload: "legitimate", + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { success: boolean }; + expect(body.success).toBe(true); + expect(publishMock).toHaveBeenCalledTimes(1); + }); +}); From 6d8ff9d69c97915497e7e8a306c150cd863e410b Mon Sep 17 00:00:00 2001 From: Jaynel Patiarba Date: Wed, 8 Apr 2026 03:42:47 +0800 Subject: [PATCH 2/2] ci: retrigger tests