From f56d3a9814b58e59b99eba65c37efe7f653aa231 Mon Sep 17 00:00:00 2001 From: simon louvet Date: Tue, 25 Aug 2026 18:58:01 +0200 Subject: [PATCH 1/2] fix(security): fail-closed sur composants sans workspaceId (orphelins) + backfill (SB-IDOR-2026-01) --- Makefile | 12 ++- .../2026-08-idor-workspace-import.md | 16 +++- .../lib/workspace_component_security.test.js | 14 ++++ packages/core/lib/workspace_component_lib.js | 7 +- .../server/workspaceSecurity.test.js | 72 +++++++++++++++++- .../main/scripts/backfillOrphanComponents.js | 76 +++++++++++++++++++ packages/main/server/workspaceWebService.js | 54 ++++++------- 7 files changed, 220 insertions(+), 31 deletions(-) create mode 100644 packages/main/scripts/backfillOrphanComponents.js diff --git a/Makefile b/Makefile index 5ff207a7f..841ea3559 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ .DEFAULT_GOAL := help .PHONY: docker-build docker-up build start log stop restart \ rabbit-up rabbit-reset \ - eval-up test-eval + eval-up test-eval \ + backfill-orphans DOCKER_COMPOSE=docker compose -f docker-compose.yaml DOCKER_COMPOSE_TEST=docker compose -f docker-compose.test.yaml @@ -56,6 +57,15 @@ test-eval: done EVAL_SERVICE_URL=http://localhost:8083 ENGINE_HMAC_SECRET=${ENGINE_HMAC_SECRET:-secret} npx jest packages/eval-service/__tests__/eval-service.integration.test.js +# Backfill des composants sans workspaceId (orphelins) — SB-IDOR-2026-01. +# À exécuter AVANT ou EN MÊME TEMPS que le déploiement du code fail-closed +# (assertComponentInWorkspace), sinon les composants orphelins deviennent +# non éditables/destructibles par personne (même leur propriétaire). +# Lance le script dans le container main (config.local.json monté → base locale). +backfill-orphans: + @docker compose ps --status running main >/dev/null 2>&1 || { echo "❌ Container main non démarré — démarrer d'abord (make up / docker-up)"; exit 1; } + @docker compose exec -T main node -e "require('/data/packages/main/scripts/backfillOrphanComponents').work()" + # Start start: docker-restart diff --git a/advisories/SB-IDOR-2026-01/2026-08-idor-workspace-import.md b/advisories/SB-IDOR-2026-01/2026-08-idor-workspace-import.md index b5b3bc55e..6b6505b72 100644 --- a/advisories/SB-IDOR-2026-01/2026-08-idor-workspace-import.md +++ b/advisories/SB-IDOR-2026-01/2026-08-idor-workspace-import.md @@ -36,6 +36,8 @@ CVSS à calculer (probablement ~8.x : réseau + authentifié faible + impact él ## Versions corrigées (Patched versions) - **v0.11.3** (correctif IDOR + gestion admin en base) — PR sur master, release à venir. +- **v0.11.10 → v0.11.14** (confused deputy sur les routes sœurs, bigdataflow legacy, admin maintenance, IDOR fichiers/upload/componentId). +- **à venir (après v0.11.14)** : fail-open sur composants sans `workspaceId` (orphelins) — fail-closed + estampillage sur le chemin de création + backfill. ## Détails (Details) - `POST /workspaces/:id/import` (`packages/main/server/workspaceWebService.js:244`) : @@ -78,6 +80,15 @@ CVSS à calculer (probablement ~8.x : réseau + authentifié faible + impact él - ⚠️ **Note opérationnelle** : sur une instance existante (utilisateurs déjà présents, sans `adminUsers` configuré), aucun utilisateur n'est admin par défaut — configurer `adminUsers` ou promouvoir via `PUT /users/:id/admin`. +- ✅ **Fail-open sur composants sans `workspaceId` (orphelins, contribution 3)** : + `assertComponentInWorkspace` (`packages/core/lib/workspace_component_lib.js`) passait + `if (component.workspaceId && ... !== workspaceId)` → fail-open : un utilisateur + owner/editor pouvait écraser/supprimer un composant orphelin (le PUT estampait son propre + `workspaceId`). Corrigé **fail-closed** (`!component.workspaceId || mismatch` → 403), + associé à (1) l'estampillage de `workspaceId` sur le chemin embedded de + `POST /workspaces/` (corrige aussi le bug latent de rattachement des composants) et + (2) un **backfill** des orphelins existants (`packages/main/scripts/backfillOrphanComponents.js`) + — les 3 à déployer ensemble, sinon les orphelins deviennent non éditables/destructibles. ## CWE - **CWE-639** : Authorization Bypass Through User-Controlled Key (IDOR) @@ -99,6 +110,9 @@ Signalée par **Maxim Yakovlev** (`batam111`) — divulgation coordonnée. - `packages/main/server/workspaceWebService.js:244` (route import — wrapperSecurity ajouté) - `packages/core/lib/user_lib.js:230-242` (défaut admin moindre privilège) - `packages/main/server/services/security.js` (wrapperSecurity) +- `packages/core/lib/workspace_component_lib.js` (assertComponentInWorkspace fail-closed — contribution 3) +- `packages/main/server/workspaceWebService.js` (`POST /workspaces/` embedded — workspaceId estampé — contribution 3) +- `packages/main/scripts/backfillOrphanComponents.js` (backfill des orphelins — contribution 3) - Tests : `packages/main/__tests__/server/workspaceSecurity.test.js`, - `packages/core/__tests__/lib/user_lib.admin.test.js` + `packages/core/__tests__/lib/workspace_component_security.test.js` - À compléter : lien PR de correctif + release. diff --git a/packages/core/__tests__/lib/workspace_component_security.test.js b/packages/core/__tests__/lib/workspace_component_security.test.js index 436b4e129..9cd3e0cfe 100644 --- a/packages/core/__tests__/lib/workspace_component_security.test.js +++ b/packages/core/__tests__/lib/workspace_component_security.test.js @@ -42,6 +42,20 @@ describe('workspace_component_lib.assertComponentInWorkspace', () => { await expect(workspaceComponentLib.assertComponentInWorkspace('comp', 'ws1')).rejects.toMatchObject({ status: 403 }); }); + test('refuse un composant sans workspaceId (orphelin, fail closed)', async () => { + findOneMock.mockReturnValue({ + select: () => ({ lean: () => ({ exec: () => Promise.resolve({ _id: 'comp' }) }) }) + }); + await expect(workspaceComponentLib.assertComponentInWorkspace('comp', 'ws1')).rejects.toMatchObject({ status: 403 }); + }); + + test('refuse un composant au workspaceId null (orphelin, fail closed)', async () => { + findOneMock.mockReturnValue({ + select: () => ({ lean: () => ({ exec: () => Promise.resolve({ _id: 'comp', workspaceId: null }) }) }) + }); + await expect(workspaceComponentLib.assertComponentInWorkspace('comp', 'ws1')).rejects.toMatchObject({ status: 403 }); + }); + test('refuse un composant inexistant', async () => { findOneMock.mockReturnValue({ select: () => ({ lean: () => ({ exec: () => Promise.resolve(null) }) }) diff --git a/packages/core/lib/workspace_component_lib.js b/packages/core/lib/workspace_component_lib.js index 5efc3f426..62b56e1f8 100644 --- a/packages/core/lib/workspace_component_lib.js +++ b/packages/core/lib/workspace_component_lib.js @@ -166,7 +166,12 @@ async function _assertComponentInWorkspace(componentId, workspaceId) { err.status = 404; throw err; } - if (component.workspaceId && component.workspaceId.toString() !== workspaceId.toString()) { + // FAIL CLOSED : un composant sans workspaceId (orphelin) n'est ni éditable ni + // destructible — y compris par son propriétaire légitime, jusqu'au backfill qui + // lui pose son workspaceId. Évite le fail-open cross-tenant (un utilisateur qui + // possède un workspace pouvait écraser/supprimer un composant orphelin, et le + // PUT lui estampait son propre workspaceId). + if (!component.workspaceId || component.workspaceId.toString() !== workspaceId.toString()) { const err = new global.Error('component_not_in_workspace'); err.status = 403; throw err; diff --git a/packages/main/__tests__/server/workspaceSecurity.test.js b/packages/main/__tests__/server/workspaceSecurity.test.js index 7b8d5ad29..80a9ed962 100644 --- a/packages/main/__tests__/server/workspaceSecurity.test.js +++ b/packages/main/__tests__/server/workspaceSecurity.test.js @@ -8,6 +8,7 @@ jest.mock('../../server/services/security', () => ({ })); jest.mock('@semantic-bus/core/lib/workspace_lib', () => ({ + create: jest.fn(() => Promise.resolve({ _id: 'WS', components: [] })), update: jest.fn(() => Promise.resolve({ components: [] })), getWorkspace: jest.fn(), addConnection: jest.fn(), @@ -15,7 +16,9 @@ jest.mock('@semantic-bus/core/lib/workspace_lib', () => ({ updateSimple: jest.fn() })); jest.mock('@semantic-bus/core', () => ({ user: {} })); -jest.mock('@semantic-bus/core/lib/auth_lib', () => ({})); +jest.mock('@semantic-bus/core/lib/auth_lib', () => ({ + get_decoded_jwt: jest.fn(() => ({ iss: 'USER_ID' })) +})); jest.mock('@semantic-bus/core/lib/workspace_component_lib', () => ({ create: jest.fn(() => Promise.resolve([])), update: jest.fn(() => Promise.resolve({})), @@ -149,3 +152,70 @@ describe('workspaceWebService - confused deputy sur les routes sœurs (SB-IDOR-2 expect(next).toHaveBeenCalledWith(expect.any(Error)); }); }); + +describe('workspaceWebService - POST /workspaces/ composants embarqués (SB-IDOR-2026-01)', () => { + const securityService = require('../../server/services/security'); + const workspaceLib = require('@semantic-bus/core/lib/workspace_lib'); + const workspaceComponentLib = require('@semantic-bus/core/lib/workspace_component_lib'); + + let routes; + function makeRouter() { + const store = { post: {}, put: {}, delete: {}, get: {} }; + return { + post: (path, ...mw) => { store.post[path] = mw; }, + put: (path, ...mw) => { store.put[path] = mw; }, + delete: (path, ...mw) => { store.delete[path] = mw; }, + get: (path, ...mw) => { store.get[path] = mw; }, + store + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + routes = makeRouter(); + registerRoutes(routes); + }); + + test('POST /workspaces/ avec composants embarqués estampe workspaceId sur chaque composant', async () => { + const mw = routes.store.post['/workspaces/']; + workspaceLib.create.mockResolvedValueOnce({ _id: 'WS_NEW', components: [] }); + workspaceLib.update.mockResolvedValueOnce({ _id: 'WS_NEW', components: ['COMP_1'] }); + workspaceComponentLib.create.mockResolvedValueOnce([{ _id: 'COMP_1', workspaceId: 'WS_NEW' }]); + + const req = { + query: {}, + headers: { authorization: 'JTW xxxx' }, + body: { workspace: { name: 'n', limitHistoric: 1, components: [{ _id: 'c1', name: 'x' }] } } + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + await mw[0](req, res, next); + + // le workspace est créé sans composant embarqué (components vide) + expect(workspaceLib.create).toHaveBeenCalledWith( + 'USER_ID', + expect.objectContaining({ components: [] }) + ); + // les composants sont créés avec workspaceId estampé, jamais body._id + expect(workspaceComponentLib.create).toHaveBeenCalledWith([ + expect.objectContaining({ _id: undefined, workspaceId: 'WS_NEW' }) + ]); + expect(workspaceLib.update).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'WS_NEW', components: ['COMP_1'] }) + ); + expect(res.send).toHaveBeenCalledWith({ _id: 'WS_NEW', components: ['COMP_1'] }); + expect(next).not.toHaveBeenCalled(); + }); + + test('POST /workspaces/ sans composant embarqué ne crée pas de composant', async () => { + const mw = routes.store.post['/workspaces/']; + workspaceLib.create.mockResolvedValueOnce({ _id: 'WS_NEW', components: [] }); + const req = { query: {}, headers: { authorization: 'JTW xxxx' }, body: { workspace: { name: 'n', limitHistoric: 1 } } }; + const res = { send: jest.fn() }; + const next = jest.fn(); + await mw[0](req, res, next); + expect(workspaceComponentLib.create).not.toHaveBeenCalled(); + expect(res.send).toHaveBeenCalledWith({ _id: 'WS_NEW', components: [] }); + }); +}); diff --git a/packages/main/scripts/backfillOrphanComponents.js b/packages/main/scripts/backfillOrphanComponents.js new file mode 100644 index 000000000..2ae93fdf9 --- /dev/null +++ b/packages/main/scripts/backfillOrphanComponents.js @@ -0,0 +1,76 @@ +'use strict'; + +// Backfill des composants orphelins (sans workspaceId) — SB-IDOR-2026-01. +// +// Contexte : le fix de assertComponentInWorkspace est fail-closed — un composant +// sans workspaceId n'est plus éditable/destructible par personne (même son +// propriétaire légitime) tant qu'il n'a pas de workspaceId. Ce script doit donc +// tourner AVANT ou EN MÊME TEMPS que le déploiement du code corrigé, pour que les +// flux PUT/DELETE légitimes continuent de fonctionner. +// +// Principe : pour chaque composant sans workspaceId, on cherche le workspace qui le +// référence dans sa liste `workspace.components` et on estampe workspaceId = workspace._id. +// Les composants non retrouvés (vraiment orphelins) sont listés à la fin pour contrôle +// manuel — ils resteront non-éditables/destructibles tant qu'ils n'ont pas de workspaceId. +// +// Usage : +// node -e "require('./packages/main/scripts/backfillOrphanComponents').work()" +// (à exécuter depuis la racine du repo, config.json chargé via getConfiguration) + +module.exports = { + workspaceComponent_model: require('@semantic-bus/core/models').workspaceComponent, + workspace_model: require('@semantic-bus/core/models').workspace, + + work: async function() { + console.log('--- Backfill composants orphelins (sans workspaceId) ---'); + + const orphans = await this.workspaceComponent_model.getInstance().model + .find({ $or: [{ workspaceId: null }, { workspaceId: { $exists: false } }] }) + .lean() + .exec(); + + console.log(`Composants sans workspaceId trouvés : ${orphans.length}`); + if (orphans.length === 0) { + console.log('Rien à faire.'); + return; + } + + const orphanIds = orphans.map(o => o._id); + const workspaces = await this.workspace_model.getInstance().model + .find({ components: { $in: orphanIds } }) + .select('_id components') + .lean() + .exec(); + + // id composant -> workspace._id + const componentToWorkspace = {}; + for (const ws of workspaces) { + for (const compId of ws.components) { + componentToWorkspace[compId.toString()] = ws._id; + } + } + + let updated = 0; + const unresolved = []; + for (const comp of orphans) { + const workspaceId = componentToWorkspace[comp._id.toString()]; + if (!workspaceId) { + unresolved.push(comp._id); + continue; + } + await this.workspaceComponent_model.getInstance().model.updateOne( + { _id: comp._id }, + { $set: { workspaceId: workspaceId.toString() } } + ).exec(); + updated++; + } + + console.log(`Composants backfillés (workspaceId estampé) : ${updated}`); + if (unresolved.length > 0) { + console.log(`\n⚠️ ${unresolved.length} composant(s) NON retrouvé(s) dans la liste components d'aucun workspace :`); + console.log(unresolved.map(id => id.toString()).join('\n')); + console.log('\nIls resteront non-éditables/destructibles tant qu\'un workspaceId ne leur est pas attribué manuellement.'); + } + console.log('--- Backfill terminé ---'); + } +}; diff --git a/packages/main/server/workspaceWebService.js b/packages/main/server/workspaceWebService.js index 1f5873ed5..fffcd1a2b 100644 --- a/packages/main/server/workspaceWebService.js +++ b/packages/main/server/workspaceWebService.js @@ -294,35 +294,35 @@ module.exports = function (router) { // -------------------------------------------------------------------------------- - router.post('/workspaces/', function (req, res, next) { - let workspaceBody = req.body.workspace - let userIdBody = UserIdFromToken(req) - - if (workspaceBody.components) { - // dans le cas ou il n'y a pas de save à la création : save du WS et des comp - if (workspaceBody.components.length > 0) { - workspace_component_lib.create(workspaceBody.components).then(function (workspaceComponent) { - workspaceBody.components = [] - workspaceBody.components.push(workspaceComponent._id) - workspace_lib.create(userIdBody, workspaceBody).then(function (workspace) { - res.send(workspace) - }) - }).catch(e => { - next(e) - }) + router.post('/workspaces/', async function (req, res, next) { + try { + let workspaceBody = req.body.workspace + let userIdBody = UserIdFromToken(req) + + const hasComponents = workspaceBody && workspaceBody.components && workspaceBody.components.length > 0 + + // SÉCURITÉ : le workspace est créé d'abord (son _id est connu), puis les + // composants embarqués sont créés avec workspaceId estampé — plus de composant + // orphelin (sans workspaceId) via ce chemin. Les composants créés sont + // rattachés au workspace (components = ids) dans la foulée. + if (hasComponents) { + const workspaceBodyInit = { ...workspaceBody, components: [] } + const workspace = await workspace_lib.create(userIdBody, workspaceBodyInit) + const componentsToCreate = workspaceBody.components.map(c => ({ + ...c, + _id: undefined, + workspaceId: workspace._id + })) + const workspaceComponents = await workspace_component_lib.create(componentsToCreate) + workspace.components = workspaceComponents.map(c => c._id) + const workspaceUpdated = await workspace_lib.update(workspace) + res.send(workspaceUpdated) } else { - workspace_lib.create(userIdBody, workspaceBody).then(function (workspace) { - res.send(workspace) - }).catch(e => { - next(e) - }) - } - } else { - workspace_lib.create(userIdBody, workspaceBody).then(function (workspace) { + const workspace = await workspace_lib.create(userIdBody, workspaceBody) res.send(workspace) - }).catch(e => { - next(e) - }) + } + } catch (e) { + next(e) } }) // <= create_workspace From 5ffbd67861274a13dc675606dafd4d6a1141899b Mon Sep 17 00:00:00 2001 From: simon louvet Date: Tue, 25 Aug 2026 18:58:09 +0200 Subject: [PATCH 2/2] release: bump to v0.11.16 (fail-closed composants orphelins) --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- packages/core/package.json | 2 +- packages/engine/package.json | 2 +- packages/eval-service/package.json | 2 +- packages/main/package.json | 2 +- packages/timer/package.json | 2 +- 7 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1008024c2..3ba292f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.11.16] - 2026-08-25 + +### Security + +- **Fail-closed sur les composants sans `workspaceId` (orphelins)** : + `assertComponentInWorkspace` refuse désormais (403) un composant sans `workspaceId` + (`!component.workspaceId || mismatch`), en plus du mismatch. Empêche un utilisateur + owner/editor d'un workspace d'écraser/supprimer un composant orphelin d'un autre + workflow (le PUT lui estampait même son propre `workspaceId`). +- **`POST /workspaces/` (composants embarqués)** : le workspace est créé d'abord, chaque + composant embarqué est créé avec `workspaceId` estampé puis rattaché au workspace — + plus d'orphelins créés par ce chemin (corrige aussi un bug latent de rattachement). +- **Backfill des orphelins** : `packages/main/scripts/backfillOrphanComponents.js` estampe + `workspaceId` sur les composants orphelins existants (retrouvés via `workspace.components`). + À exécuter au déploiement, en même temps que le code fail-closed (voir Makefile). + ## [0.11.15] - 2026-08-24 ### Fixed diff --git a/package.json b/package.json index 59eaa0518..d1a50dd3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "semantic-bus-monorepo", - "version": "0.11.15", + "version": "0.11.16", "description": "ETL style data middleware transformation embedded in an ESB for all kind of data", "private": true, "workspaces": [ diff --git a/packages/core/package.json b/packages/core/package.json index 5c8737d3d..37b2b6353 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@semantic-bus/core", - "version": "0.11.15", + "version": "0.11.16", "description": "Core module for Semantic Bus", "private": true, "main": "index.js", diff --git a/packages/engine/package.json b/packages/engine/package.json index a26cf72c3..596878545 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@semantic-bus/engine", - "version": "0.11.15", + "version": "0.11.16", "description": "Processing engine module for Semantic Bus", "private": true, "main": "app.js", diff --git a/packages/eval-service/package.json b/packages/eval-service/package.json index baddcc518..84232c543 100644 --- a/packages/eval-service/package.json +++ b/packages/eval-service/package.json @@ -1,6 +1,6 @@ { "name": "@semantic-bus/eval-service", - "version": "0.11.15", + "version": "0.11.16", "description": "Service d'évaluation JavaScript isolé en container (dédié aux eval des transformations / $where).", "private": true, "main": "app.js", diff --git a/packages/main/package.json b/packages/main/package.json index 0babffdff..e10e01ee0 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -1,6 +1,6 @@ { "name": "@semantic-bus/main", - "version": "0.11.15", + "version": "0.11.16", "description": "Main application module for Semantic Bus", "private": true, "main": "app.js", diff --git a/packages/timer/package.json b/packages/timer/package.json index 608b5e133..a11d32944 100644 --- a/packages/timer/package.json +++ b/packages/timer/package.json @@ -1,6 +1,6 @@ { "name": "@semantic-bus/timer", - "version": "0.11.15", + "version": "0.11.16", "description": "Timer scheduler module for Semantic Bus", "private": true, "main": "app.js",