From 80c8c81fb8a852b493885a3bed7637097def55d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:54:24 -0500 Subject: [PATCH] Check that a schedule's zone belongs to the caller's workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a schedule validates every reference it carries against the caller's workspace — content, widget, layout, playlist all go through checkRefInWorkspace. zone_id was the one polymorphic reference left out of that list, so a schedule could be pointed at a zone belonging to another workspace's layout. It needed its own check rather than a sixth entry in the table: layout_zones has no workspace_id column of its own. A zone belongs to a layout, and the layout carries the workspace, so the ownership question has to be answered through that join. A zone on a platform-template layout (workspace_id IS NULL) is allowed, matching how the other references treat templates. 882 server tests green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- server/routes/schedules.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server/routes/schedules.js b/server/routes/schedules.js index 99e96d5..5c4cb55 100644 --- a/server/routes/schedules.js +++ b/server/routes/schedules.js @@ -94,6 +94,19 @@ function workspaceAccess(req, workspaceId) { // / layout / playlist refs (where workspace_id IS NULL is the platform-template // path and is always allowed) and for devices / device_groups (where // workspace_id is required - those tables never carry template rows). +// layout_zones has no workspace_id of its own — a zone belongs to a layout, and the layout carries +// the workspace. zone_id was the one polymorphic reference missing from the ownership checks, so a +// schedule could be pointed at a zone in someone else's workspace. +function checkZoneInWorkspace(zoneId, workspaceId) { + const row = db.prepare( + 'SELECT l.workspace_id FROM layout_zones z JOIN layouts l ON l.id = z.layout_id WHERE z.id = ?' + ).get(zoneId); + if (!row) return { status: 404, error: 'zone not found' }; + if (row.workspace_id === workspaceId) return null; + if (row.workspace_id == null) return null; // platform-template layout + return { status: 403, error: 'zone is not in this workspace' }; +} + function checkRefInWorkspace(table, id, workspaceId, opts = { allowNullWorkspace: false }) { const row = db.prepare(`SELECT workspace_id FROM ${table} WHERE id = ?`).get(id); if (!row) return { status: 404, error: `${table.replace(/_/g, ' ').slice(0, -1)} not found` }; @@ -243,6 +256,10 @@ router.post('/', (req, res) => { const err = checkRefInWorkspace(table, id, targetWorkspaceId, { allowNullWorkspace: allowNull }); if (err) return res.status(err.status).json({ error: err.error }); } + if (zone_id) { + const zErr = checkZoneInWorkspace(zone_id, targetWorkspaceId); + if (zErr) return res.status(zErr.status).json({ error: zErr.error }); + } const id = uuidv4(); db.prepare(`