From 51629247a51456325747dad493046031f90a1b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Fri, 29 May 2026 22:44:16 +0000 Subject: [PATCH] fix(middleware): prevent cross-org site binding in target create/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend verifySiteAccess to check that when req.userOrgId is already set by a prior middleware (e.g. verifyResourceAccess/verifyTargetAccess), the site from req.body.siteId belongs to the same organization. This prevents the cross-organization tunnel boundary bypass where an attacker with resource access in one org binds that resource's target to a site in another org. Add verifySiteAccess to both target route stacks: - PUT /resource/:resourceId/target (after verifyResourceAccess) - POST /target/:targetId (after verifyTargetAccess) The org-match check runs before req.userOrg is overwritten, so the resource's organization context is preserved for comparison. Signed-off-by: Marc Schäfer --- server/middlewares/verifySiteAccess.ts | 14 ++++++++++---- server/routers/external.ts | 5 ++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index e630cf0f1..c4d35a52f 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -71,6 +71,15 @@ export async function verifySiteAccess( ); } + if (req.userOrgId && site.orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this site" + ) + ); + } + if (!req.userOrg) { // Get user's role ID in the organization const userOrgRole = await db @@ -128,10 +137,7 @@ export async function verifySiteAccess( .where( and( eq(roleSites.siteId, site.siteId), - inArray( - roleSites.roleId, - req.userOrgRoleIds! - ) + inArray(roleSites.roleId, req.userOrgRoleIds!) ) ) .limit(1) diff --git a/server/routers/external.ts b/server/routers/external.ts index 440bb5f21..db0db594a 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -561,6 +561,7 @@ authenticated.delete( authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, + verifySiteAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), @@ -612,6 +613,7 @@ authenticated.get( authenticated.post( "/target/:targetId", verifyTargetAccess, + verifySiteAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), @@ -1234,7 +1236,8 @@ export const authRouter = Router(); unauthenticated.use("/auth", authRouter); authRouter.use( rateLimit({ - windowMs: config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000, + windowMs: + config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000, max: config.getRawConfig().rate_limits.auth.max_requests, keyGenerator: (req) => `authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,