Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
16 changes: 15 additions & 1 deletion advisories/SB-IDOR-2026-01/2026-08-idor-workspace-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) :
Expand Down Expand Up @@ -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)
Expand All @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
14 changes: 14 additions & 0 deletions packages/core/__tests__/lib/workspace_component_security.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) }) })
Expand Down
7 changes: 6 additions & 1 deletion packages/core/lib/workspace_component_lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/eval-service/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
72 changes: 71 additions & 1 deletion packages/main/__tests__/server/workspaceSecurity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ 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(),
get_workspace_simple: jest.fn(),
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({})),
Expand Down Expand Up @@ -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: [] });
});
});
2 changes: 1 addition & 1 deletion packages/main/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
76 changes: 76 additions & 0 deletions packages/main/scripts/backfillOrphanComponents.js
Original file line number Diff line number Diff line change
@@ -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é ---');
}
};
54 changes: 27 additions & 27 deletions packages/main/server/workspaceWebService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/timer/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading