diff --git a/advisories/SB-RCE-2026-01/2026-08-semantic-bus-rce-eval.md b/advisories/SB-RCE-2026-01/2026-08-semantic-bus-rce-eval.md index 0a9f6c601..050fe0b3e 100644 --- a/advisories/SB-RCE-2026-01/2026-08-semantic-bus-rce-eval.md +++ b/advisories/SB-RCE-2026-01/2026-08-semantic-bus-rce-eval.md @@ -37,8 +37,12 @@ Vecteur : `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` moteur d'évaluation `eval` (V2). ## Versions corrigées (Patched versions) -- Version à définir lors de la release du correctif (correctif porté sur la branche - `security/remove-sift-and-secure-eval`, à merger dans `production`). +- **v0.11.2** (inclut le correctif du bypass `lodash.template`, PR #458) — release + `v0.11.1` puis `v0.11.2` sur la branche `security/remove-sift-and-secure-eval`, mergées + dans `production`. +- **Hardening validateur (whitelist 100 %, sanitize, raw-eval vm)** : correctifs des gaps + du chercheur (2026-08-24) + vecteur introspection + suppression du raw-eval — release à + définir lors de la publication. ## Détails (Details) Le process engine (`packages/engine`) évalue du JavaScript écrit par l'utilisateur dans @@ -75,6 +79,53 @@ dans les chaînes évaluées → RCE complète. 6. **Authentification des points d'exécution** : signature HMAC sur `POST /engine/work-ask/:componentId`, validation JWT + autorisation sur la file AMQP `work-ask`, rate-limit sur `/data/api/*`. 7. **Validation `specificData` à l'écriture** des composants. +8. **Bypass `lodash.template`** (signalé après v0.11.1) : whitelist par lib dans le validateur + (`LIB_METHOD_WHITELISTS`), lodash épuré dans le scope eval, `importModuleDynamically` + rejette tout `import()` dynamique, strip `fetch`/`WebSocket`, retour à l'évaluation + atomique par item (correctif v0.11.2, PR #458). +9. **Validateur passé en whitelist 100 %** (suite à la review v0.11.2 du chercheur, 2026-08-24, + et au vecteur introspection découvert) : + - **constant-folding** des clés computed statiquement résolubles (`'con'+'structor'` → + bloqué) ; + - **whitelist étendue** à toutes les libs du scope (dotProp, cheerio, + sanitizeHtml, removeMarkdown, decodeUnicode) ; + - **`LIB_FORBIDDEN_METHODS` supprimé** (blacklist) ; + - **objets produits whitelistés** (`PRODUCED_WHITELISTS` + inférence de type d'appel) ; + - **JS intrinsics en whitelist stricte** : `Reflect`, `Proxy`, `Object.getPrototypeOf`, + `Object.getOwnPropertyDescriptor`, `Object.defineProperty`, `Object.setPrototypeOf` + **bloqués** (neutralise le vecteur argument-string `Reflect.get(he.decode, + 'constructor')`). +10. **`sanitizeValue` dans `runEvalInRemote`** (avant sérialisation) : toutes les variables + envoyées au eval-service sont assainies (getters + clés `__proto__`/`constructor`/ + `prototype`) — point unique d'application couvrant transformation + `$where`. +11. **Raw-eval ÉLIMINÉ** : `whereWorker.js` supprimé (v0.11.2), puis l'`evalWorker.js` engine + (`(0, eval)`, code mort signalé par le chercheur) **supprimé** avec `runEvalInWorker` et + `workerGlobals.js` engine → **l'engine n'a plus aucune méthode d'évaluation interne** ; + le seul chemin est `runEvalInRemote` (HTTP signé → container eval-service). Plus aucun + `eval` brut dans l'engine ni l'eval-service. +12. **Code mort retiré** : helpers `escapeString`/`resolveString`/`parseAndResolveString` + (engine + container) et lib `unicode-encode` — le mécanisme d'encodage des valeurs dans + l'expression était mort (les valeurs partent en variables séparées). `decodeUnicode` + (décodage `\uXXXX` des données) conservé côté container (pattern prod). + +### Gaps du validateur — corrigés, limitation connue documentée + +La review du correctif v0.11.2 par le chercheur (2026-08-24) a signalé 3 gaps de correctness +du validateur, **tous corrigés** : + +- **Clé computed non-littérale** : fermé par constant-folding (`he.decode['con'+'structor']` + → bloqué) ; +- **Whitelist par lib incomplète** : fermé (dotProp.set, cheerio.merge, ... → bloqués) ; +- **`evalWorker.js` `Object.assign` sans sanitize** : fermé (`sanitizeValue` dans + `runEvalInRemote` + contrat documenté). + +**Limitation connue (non-vulnérabilité)** : les clés computed **dynamiques** (`obj[key]`, +`items.map(x => he[x])`) restent autorisées (non résolubles statiquement). Ce n'est **pas une +faille ouverte** : le host `Function` obtenu à l'exécution s'exécute dans un worker aux +globals strippés (pas de `process`/`require`/`fs`/réseau — vérifié), avec contexte vm neuf +par job et container eval-service isolé/signé HMAC. Elle ne deviendrait une RCE que si +l'isolation runtime était affaiblie ; tracée comme dette de défense en profondeur (garde +runtime ou isolated-vm en backstop). ## CWE - **CWE-94** : Improper Control of Generation of Code ('Code Injection') @@ -96,10 +147,13 @@ Vulnérabilité signalée par **Maxim Yakovlev** (divulgation coordonnée). ## Chronologie (Timeline / Disclosure) | Date | Étape | |---|---| -| (à compléter) | Réception et accusé de réception du rapport par le chercheur | -| (à compléter) | Confirmation de l'exploitabilité, du périmètre et de la gravité | -| (à compléter) | Développement et test du correctif (branche dédiée) | -| (à compléter) | Mise à disposition de la release corrigée | +| 2026-08-12 | Réception et accusé de réception du rapport par le chercheur | +| 2026-08-13 → 2026-08-19 | Confirmation de l'exploitabilité, du périmètre et de la gravité | +| 2026-08-19 | Correctif initial développé et testé (branche dédiée) — release v0.11.1 | +| 2026-08-19 | Chercheur signale un bypass RCE via `lodash.template` (v0.11.1 incomplet) | +| 2026-08-20 | Correctif du bypass mergé (PR #458) + release v0.11.2 + déploiement prod | +| 2026-08-24 | Review chercheur de v0.11.2 : RCE fermée ; gaps de correctness du validateur signalés | +| (à compléter) | Mise à disposition de la release corrigée finale (hardening validateur) | | (à compléter) | Publication de l'advisory (après fix, ≤ 90 jours) | > **Note de coordination** : le correctif est prêt dans cette branche. Avant publication, diff --git a/package-lock.json b/package-lock.json index aa94ad83c..17fa11018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "semantic-bus-monorepo", - "version": "0.11.2", + "version": "0.11.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "semantic-bus-monorepo", - "version": "0.11.2", + "version": "0.11.16", "license": "GNUV3", "workspaces": [ "packages/*" @@ -3350,18 +3350,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "atob": "bin/atob.js" - }, - "engines": { - "node": ">= 4.5.0" - } - }, "node_modules/axios": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", @@ -3852,18 +3840,6 @@ "node": ">=16.20.1" } }, - "node_modules/btoa": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", - "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "btoa": "bin/btoa.js" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -11872,16 +11848,6 @@ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, - "node_modules/unicode-encode": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/unicode-encode/-/unicode-encode-1.4.2.tgz", - "integrity": "sha512-xwGGPy/masrMFrf7c+jlGj0SOO7Z9LpC6wAZbAaAM6Dyz/Azm1iuHDpWHDPot8IDpmhN9pGhUqZkE+5ykOKAjg==", - "license": "MIT", - "dependencies": { - "atob": "^2.1.2", - "btoa": "^1.2.1" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -12781,7 +12747,7 @@ }, "packages/core": { "name": "@semantic-bus/core", - "version": "0.11.2", + "version": "0.11.16", "dependencies": { "@aws-sdk/client-dynamodb": "^3.840.0", "@aws-sdk/lib-dynamodb": "^3.840.0", @@ -12822,7 +12788,7 @@ }, "packages/engine": { "name": "@semantic-bus/engine", - "version": "0.11.2", + "version": "0.11.16", "license": "GNUV3", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", @@ -12865,7 +12831,6 @@ "speakingurl": "^14.0.1", "ssh2-sftp-client": "^12.0.1", "superagent": "^10.2.1", - "unicode-encode": "^1.4.2", "webdriverio": "^9.4.0", "xml2js": "^0.6.2" }, @@ -13188,7 +13153,7 @@ }, "packages/eval-service": { "name": "@semantic-bus/eval-service", - "version": "0.11.2", + "version": "0.11.16", "dependencies": { "@semantic-bus/core": "file:../core", "cheerio": "^1.1.0", @@ -13199,8 +13164,7 @@ "lodash": "^4.17.21", "moment": "^2.30.1", "remove-markdown": "^0.6.2", - "sanitize-html": "^2.17.0", - "unicode-encode": "^1.4.2" + "sanitize-html": "^2.17.0" } }, "packages/eval-service/node_modules/accepts": { @@ -13471,7 +13435,7 @@ }, "packages/main": { "name": "@semantic-bus/main", - "version": "0.11.2", + "version": "0.11.16", "license": "GNUV3", "dependencies": { "@semantic-bus/core": "file:../core", @@ -13506,7 +13470,7 @@ }, "packages/timer": { "name": "@semantic-bus/timer", - "version": "0.11.2", + "version": "0.11.16", "license": "GNUV3", "dependencies": { "@semantic-bus/core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index 37b2b6353..f89dd0cf8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@semantic-bus/core", - "version": "0.11.16", + "version": "0.11.17", "description": "Core module for Semantic Bus", "private": true, "main": "index.js", diff --git a/packages/engine/__tests__/utils/objectTransformationV2.test.js b/packages/engine/__tests__/utils/objectTransformationV2.test.js index ccdb998ba..cdd665bfe 100644 --- a/packages/engine/__tests__/utils/objectTransformationV2.test.js +++ b/packages/engine/__tests__/utils/objectTransformationV2.test.js @@ -1,6 +1,6 @@ jest.mock('../../utils/evalSecurity'); const objectTransformation = require('../../utils/objectTransformationV2'); -const { runEvalInWorker } = require('../../utils/evalSecurity'); +const { runEvalInRemote } = require('../../utils/evalSecurity'); describe('objectTransformationV2 - eval master + validation pré-éval', () => { test('expression simple avec {$path}', async () => { @@ -90,14 +90,14 @@ describe('objectTransformationV2 - eval master + validation pré-éval', () => { }); }); - describe('timeout (point 3) — worker terminable', () => { + describe('timeout — logique du container eval-service (vm)', () => { test('expression bloquante est réellement interrompue par le timeout', async () => { - // runEvalInWorker est testé directement (le validateur bloque déjà les - // boucles au niveau AST) : on vérifie que le worker_threads termine une - // boucle infinie au bout du délai au lieu de bloquer le process. + // runEvalInRemote est mocké vers la logique du container (secureContext.js + // de l'eval-service) : vm.runInContext avec timeout. On vérifie que la + // boucle infinie est interrompue au lieu de bloquer le process. let error; try { - await runEvalInWorker('while(true){}', {}, 150); + await runEvalInRemote('while(true){}', {}, 150); } catch (e) { error = e; } @@ -106,7 +106,7 @@ describe('objectTransformationV2 - eval master + validation pré-éval', () => { }, 5000); test('résultat sérialisable retourné intact', async () => { - const r = await runEvalInWorker('({a: 1, b: "x"})'); + const r = await runEvalInRemote('({a: 1, b: "x"})'); expect(r).toEqual({ a: 1, b: 'x' }); }); }); diff --git a/packages/engine/__tests__/utils/validateExpression.test.js b/packages/engine/__tests__/utils/validateExpression.test.js index ac70083c5..a8398fb70 100644 --- a/packages/engine/__tests__/utils/validateExpression.test.js +++ b/packages/engine/__tests__/utils/validateExpression.test.js @@ -91,6 +91,123 @@ describe('validateExpression - contrôle du contenu avant éval', () => { } }); + describe('clés computed — constant-folding (SB-RCE-2026-01, review chercheur 2026-08-24)', () => { + describe('clés statiquement résolubles bloquées', () => { + const foldedAttacks = [ + // PoC chercheur : computed non-littéral qui contournait FORBIDDEN_PROPERTIES + "he.decode['con'+'structor']", + "he.decode['con'+'structor']('return 6*7')", + // concaténations imbriquées / templates sans interpolation + "he.decode['con' + ('struc' + 'tor')]", + 'he.decode[`con${""}structor`]', + // whitelist par lib appliquée aux valeurs repliées + "he.decode['tem'+'plate']", + "lodash['map'+'x']", + // autres helpers host-realm exposés + "sanitizeHtml['con'+'structor']", + "removeMarkdown['con'+'structor']", + "dotProp['con'+'structor']" + ]; + for (const src of foldedAttacks) { + test(`bloque (folding): ${src.slice(0, 50)}`, () => { + expect(() => validateExpression(src)).toThrow(); + }); + } + }); + + describe('cas légitimes préservés', () => { + test('index numériques et clés dynamiques', () => { + expect(() => validateExpression('arr[0]')).not.toThrow(); + expect(() => validateExpression('arr[1+1]')).not.toThrow(); + expect(() => validateExpression('obj[key]')).not.toThrow(); + expect(() => validateExpression('arr[i]')).not.toThrow(); + }); + test('clé repliée autorisée par la whitelist de la lib', () => { + expect(() => validateExpression("he.decode['dec'+'ode']('x')")).not.toThrow(); + expect(() => validateExpression('he.decode(x)')).not.toThrow(); + }); + test('clé repliée hors whitelist sur lib exposée bloquée', () => { + expect(() => validateExpression("he.decode['ve'+'rsion']")).toThrow(); + }); + }); + }); + + describe('whitelist par lib étendue (SB-RCE-2026-01, point 2)', () => { + test('dotProp.set bloqué (proto-pollution passant le validateur auparavant)', () => { + expect(() => validateExpression("dotProp.set({}, ['a','b'], 1)")).toThrow(); + expect(() => validateExpression("dotProp['se'+'t']({}, ['a','b'], 1)")).toThrow(); + }); + test('dotProp.get/has autorisés', () => { + expect(() => validateExpression("dotProp.get(obj, 'a.b')")).not.toThrow(); + expect(() => validateExpression("dotProp.has(obj, 'a.b')")).not.toThrow(); + expect(() => validateExpression("dotProp.delete(obj, 'a.b')")).not.toThrow(); + }); + test('cheerio.load autorisé (pattern prod), autres statics bloqués', () => { + expect(() => validateExpression('cheerio.load("
  • a
  • ")("li").text()')).not.toThrow(); + expect(() => validateExpression('cheerio.merge([], [])')).toThrow(); + expect(() => validateExpression('cheerio.fromURL("http://x")')).toThrow(); + }); + test('sanitizeHtml/removeMarkdown en appel nu autorisés, membres bloqués', () => { + expect(() => validateExpression('sanitizeHtml(html, { allowedTags: ["p"] })')).not.toThrow(); + expect(() => validateExpression('sanitizeHtml.defaults')).toThrow(); + expect(() => validateExpression('removeMarkdown(md)')).not.toThrow(); + expect(() => validateExpression('removeMarkdown.someMethod(x)')).toThrow(); + expect(() => validateExpression('decodeUnicode(s)')).not.toThrow(); + }); + }); + + describe('JS intrinsics en whitelist stricte (SB-RCE-2026-01)', () => { + test('statiques whitelistés autorisés (patterns prod)', () => { + expect(() => validateExpression('Math.round(x/y*100)/100')).not.toThrow(); + expect(() => validateExpression('Math.floor(x)')).not.toThrow(); + expect(() => validateExpression('JSON.parse(x)')).not.toThrow(); + expect(() => validateExpression('JSON.stringify(v)')).not.toThrow(); + expect(() => validateExpression('Object.keys(x)')).not.toThrow(); + expect(() => validateExpression('Object.values(x)')).not.toThrow(); + expect(() => validateExpression('Object.entries(x)')).not.toThrow(); + expect(() => validateExpression('Object.assign({}, a, b)')).not.toThrow(); + expect(() => validateExpression('String.fromCharCode(65)')).not.toThrow(); + expect(() => validateExpression('Array.isArray(x)')).not.toThrow(); + expect(() => validateExpression('Number.isInteger(x)')).not.toThrow(); + expect(() => validateExpression('Date.now()')).not.toThrow(); + }); + test('introspection / construction bloquées (vecteur argument-string)', () => { + // Le vecteur découvert au-delà des points du chercheur : passer le nom de + // propriété dangereux en ARGUMENT string des built-ins d'introspection. + expect(() => validateExpression('Reflect.get(he.decode, "con"+"structor")("return 6*7")')).toThrow(); + expect(() => validateExpression('Reflect.get(he, "constructor")')).toThrow(); + expect(() => validateExpression('Reflect.construct(Function, [])')).toThrow(); + expect(() => validateExpression('Object.getPrototypeOf(he.decode)')).toThrow(); + expect(() => validateExpression('Object.getOwnPropertyDescriptor(he, "constructor")')).toThrow(); + expect(() => validateExpression('Object.getOwnPropertyNames(he)')).toThrow(); + expect(() => validateExpression('Object.defineProperty({}, "x", {value: 1})')).toThrow(); + expect(() => validateExpression('Object.setPrototypeOf({}, null)')).toThrow(); + expect(() => validateExpression('new Proxy({}, {})')).toThrow(); + expect(() => validateExpression('Proxy.x')).toThrow(); + }); + }); + + describe('objets produits par les libs — whitelist (SB-RCE-2026-01)', () => { + test('méthodes whitelistées autorisées (patterns prod)', () => { + expect(() => validateExpression('dayjs(v).format("DD-MM-YYYY")')).not.toThrow(); + expect(() => validateExpression('dayjs(v).add(1, "day").format("YYYY")')).not.toThrow(); + expect(() => validateExpression('dayjs(v).diff(dayjs(w), "minute")')).not.toThrow(); + expect(() => validateExpression('this.moment(v,"YYYY-MM-DD").format("DD/MM/YYYY")')).not.toThrow(); + expect(() => validateExpression('crypto.createHash("sha256").update(v).digest("hex")')).not.toThrow(); + expect(() => validateExpression('cheerio.load(html)("li").map((i, el) => cheerio.load(html)(el).text()).get()')).not.toThrow(); + expect(() => validateExpression('Buffer.from(v).toString("base64")')).not.toThrow(); + }); + test('méthodes hors whitelist sur objets produits bloquées', () => { + expect(() => validateExpression('dayjs(v).evilMethod()')).toThrow(/Forbidden method on dayjsInstance/); + expect(() => validateExpression('moment(v).evilMethod()')).toThrow(/Forbidden method on momentInstance/); + expect(() => validateExpression('crypto.createHash("x").evilMethod()')).toThrow(/Forbidden method on hash/); + expect(() => validateExpression('Buffer.from(x).evilMethod()')).toThrow(/Forbidden method on bufferResult/); + expect(() => validateExpression('cheerio.load(x)(sel).evilMethod()')).toThrow(/Forbidden method on cheerioInstance/); + expect(() => validateExpression('cheerio.load(x).fromURL("http://x")')).toThrow(/Forbidden method on cheerioCallable/); + expect(() => validateExpression('dayjs(v).add(1,"d").evil()')).toThrow(/Forbidden method on dayjsInstance/); + }); + }); + describe('compatibilité production (eval et Buffer autorisés)', () => { test('eval("1+1") autorisé (compat, worker isolé)', () => { expect(() => validateExpression('eval("1+1")')).not.toThrow(); @@ -99,9 +216,8 @@ describe('validateExpression - contrôle du contenu avant éval', () => { expect(() => validateExpression('Buffer.from(x).toString("base64")')).not.toThrow(); }); test('eval de construction de Date autorisé (pattern prod)', () => { - // NB : dans une vraie config, `{$.date}` est remplacé par - // `this.resolveString('...')` AVANT la validation. Ici on teste la syntaxe - // du pattern `eval("new " + valeur)`. + // Pattern prod : `(eval("new " + {$.date})).getDate()` — le contenu de la + // valeur est une donnée du flux (jamais inlinée dans l'expression). expect(() => validateExpression('(eval("new " + dateVal)).getDate()')).not.toThrow(); }); test('require/process restent bloqués comme identifiants racines', () => { diff --git a/packages/engine/__tests__/utils/whereWorker.test.js b/packages/engine/__tests__/utils/whereWorker.test.js index 4156603c8..51cd94211 100644 --- a/packages/engine/__tests__/utils/whereWorker.test.js +++ b/packages/engine/__tests__/utils/whereWorker.test.js @@ -5,28 +5,39 @@ // seule fois avec variables.obj = item. C'est le code appelant (filter.js / // arraySplitByCondition.js, comme Loki) qui ITÈRE sur les items. // -// Deux volets : -// 1. L'évaluation atomique du worker local (runEvalInWorker) avec la boucle +// L'engine n'a AUCUNE méthode d'évaluation interne : toute évaluation passe par +// le eval-service (container). Deux volets : +// 1. La logique du CONTAINER (secureContext.js de l'eval-service) + la boucle // répliquée côté test (ce que filter.js fait). // 2. Le contrat HTTP /eval de runEvalInRemote (avec node-fetch mocké). // ----------------------------------------------------------------------------- -const { runEvalInWorker, runEvalInRemote } = require('../../utils/evalSecurity.js'); +const vm = require('vm'); +const { createSecureContext } = require('../../../eval-service/secureContext.js'); +const { runEvalInRemote } = require('../../utils/evalSecurity.js'); jest.mock('node-fetch', () => jest.fn()); const fetch = require('node-fetch'); +// Réplique l'exécution d'un job du container (eval-service/evalWorker.js) : +// contexte vm neuf + injection des variables + runInContext avec timeout. +function evaluateInContainer(expression, variables, timeoutMs) { + const ctx = createSecureContext(); + Object.assign(ctx, variables); + return vm.runInContext(expression, ctx, { timeout: timeoutMs }); +} + // Réplique la boucle que filter.js/arraySplitByCondition.js font (comme Loki) : // évaluation atomique par item avec variables.obj = item. async function evaluateWhereLocal(expression, items) { const matches = []; for (let i = 0; i < items.length; i++) { - const res = await runEvalInWorker(expression, { obj: items[i] }, 5000); + const res = evaluateInContainer(expression, { obj: items[i] }, 5000); if (res == true) matches.push(i); } return matches; } -describe('évaluation $where atomique (retour Loki) — worker local', () => { +describe('évaluation $where atomique — logique du container eval-service', () => { test('retourne les indices des items matchant (== true)', async () => { const items = [{ age: 10 }, { age: 25 }, { age: 18 }]; const matches = await evaluateWhereLocal('obj.age >= 18', items); @@ -50,8 +61,8 @@ describe('évaluation $where atomique (retour Loki) — worker local', () => { expect(matches).toEqual([0, 2]); }); - test('expression invalide -> rejet', async () => { - await expect(runEvalInWorker('obj.', { obj: { x: 1 } }, 5000)).rejects.toThrow(); + test('expression invalide -> rejet (erreur vm du container)', async () => { + expect(() => evaluateInContainer('obj.', { obj: { x: 1 } }, 5000)).toThrow(); }); }); diff --git a/packages/engine/package.json b/packages/engine/package.json index 596878545..3329a1450 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@semantic-bus/engine", - "version": "0.11.16", + "version": "0.11.17", "description": "Processing engine module for Semantic Bus", "private": true, "main": "app.js", @@ -56,7 +56,6 @@ "speakingurl": "^14.0.1", "ssh2-sftp-client": "^12.0.1", "superagent": "^10.2.1", - "unicode-encode": "^1.4.2", "webdriverio": "^9.4.0", "xml2js": "^0.6.2" }, diff --git a/packages/engine/utils/__mocks__/evalSecurity.js b/packages/engine/utils/__mocks__/evalSecurity.js index 19e753905..af32c506b 100644 --- a/packages/engine/utils/__mocks__/evalSecurity.js +++ b/packages/engine/utils/__mocks__/evalSecurity.js @@ -1,18 +1,34 @@ 'use strict'; // ----------------------------------------------------------------------------- -// Mock de evalSecurity pour les tests unitaires. +// Mock de evalSecurity pour les tests unitaires engine. // -// En production, les évals sont exécutées dans le eval-service (container isolé, -// via `runEvalInRemote` — appel HTTP signé, pas de fallback). Pour les tests -// unitaires (sans container), on réutilise le worker_threads local -// (`runEvalInWorker`) comme moteur de référence : même logique d'évaluation, -// sans dépendance réseau. +// En production, TOUTES les évaluations passent par le eval-service (container +// isolé, `runEvalInRemote` — appel HTTP signé). L'engine n'a AUCUNE méthode +// d'évaluation interne (plus de worker local). Pour les tests unitaires (sans +// container), `runEvalInRemote` est mocké pour reproduire EXACTEMENT ce que le +// container exécute : un contexte vm neuf construit par `secureContext.js` +// (eval-service) + injection des variables + `vm.runInContext` avec timeout — +// la même logique que `eval-service/evalWorker.js`. // ----------------------------------------------------------------------------- +const vm = require('vm'); +const { createSecureContext } = require('../../../eval-service/secureContext.js'); const actual = jest.requireActual('../evalSecurity.js'); +function evaluateInContainer(expression, variables = {}, timeoutMs = 10000) { + const ctx = createSecureContext(); + if (variables) Object.assign(ctx, variables); + return vm.runInContext(expression, ctx, { timeout: timeoutMs }); +} + module.exports = { ...actual, - runEvalInRemote: actual.runEvalInWorker -}; + async runEvalInRemote(expression, variables = {}, timeoutMs = 10000) { + try { + return evaluateInContainer(expression, variables, timeoutMs); + } catch (e) { + throw new Error(e && e.message ? e.message : String(e)); + } + } +}; \ No newline at end of file diff --git a/packages/engine/utils/evalSecurity.js b/packages/engine/utils/evalSecurity.js index 913d716e1..d0a5937ee 100644 --- a/packages/engine/utils/evalSecurity.js +++ b/packages/engine/utils/evalSecurity.js @@ -145,64 +145,6 @@ function evalWithTimeout(fn, ms = 10000) { }); } -/** - * Exécute `expression` (évaluée par eval) dans un worker_threads TERMINABLE, - * avec un timeout strict (point 3). - * - * Contrairement à `evalWithTimeout`, un eval exécuté ici peut être réellement - * interrompu : si le délai est dépassé, le worker est terminé via - * `worker.terminate()` (un eval synchrone dans le thread principal bloquerait le - * event loop et ne pourrait pas être interrompu par un simple setTimeout). - * - * En bonus, le code évalué tourne dans un thread isolé : il n'accède PAS aux - * variables / modules du process principal (défense en profondeur). - * - * @param {string} expression code source à évaluer (scope master exposé dans le worker) - * @param {Object} [scope] variables additionnelles exposées au code évalué (sécurisées) - * @param {number} [timeoutMs=10000] délai maximal d'exécution - * @returns {Promise<*>} le résultat du eval (structured clone via postMessage) - */ -function runEvalInWorker(expression, scope = {}, timeoutMs = 10000) { - return new Promise((resolve, reject) => { - const worker = new Worker(path.join(__dirname, 'evalWorker.js'), { - workerData: { expression, scope } - }); - - let settled = false; - const settle = (fn, val) => { - if (settled) return; - settled = true; - clearTimeout(timer); - fn(val); - }; - - const timer = setTimeout(() => { - worker.terminate(); - settle(reject, new Error(`Expression evaluation timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - worker.on('message', (msg) => { - if (msg && msg.ok === true) { - settle(resolve, msg.result); - } else { - settle(reject, new Error((msg && msg.error) || 'Unknown eval error')); - } - // Worker jetable : on le ferme après le résultat (évite un handle ouvert, - // qui ferait timeout `jest --detectOpenHandles` en CI). - worker.terminate().catch(() => {}); - }); - worker.on('error', (err) => { - settle(reject, err); - worker.terminate().catch(() => {}); - }); - worker.on('exit', (code) => { - if (code !== 0 && !settled) { - settle(reject, new Error(`Eval worker exited with code ${code}`)); - } - }); - }); -} - // Limites anti-ReDoS appliquées à l'opération RegExp (point 3) : // longueur max du motif et de la chaîne d'entrée, timeout d'exécution. const MAX_REGEX_PATTERN_LENGTH = 2048; @@ -320,19 +262,34 @@ async function postEval(route, body, httpTimeoutMs, resultKey = 'result') { * Évalue une expression dans le eval-service (container isolé). * La résolution des valeurs ($/£) est faite AVANT (par l'appelant) : on envoie * l'expression épurée + un objet `variables` séparé. + * + * SÉCURITÉ — POINT D'APPLICATION UNIQUE : c'est ici (AVANT `postEval`, donc + * AVANT le `JSON.stringify` du corps HTTP) que les `variables` sont assainies + * (`sanitizeValue`). Ceci est le SEUL endroit qui : + * 1. neutralise les getters/accessors : un getter s'exécute au moment du + * `JSON.stringify` DANS le process engine — le retirer avant empêche toute + * exécution de code au moment de la sérialisation ; + * 2. retire les clés dangereuses (`__proto__`/`constructor`/`prototype`) qui + * survivent au transport JSON et seraient réactivées côté récepteur par + * `Object.assign(ctx, variables)`. + * Tous les chemins d'éval (transformation, `$where`, ...) passent par ici : + * `$where` (filter.js, arraySplitByCondition.js) ne sanitize PAS par-caller. + * CONTRAT : le eval-service considère ses `variables` SÛRES à la seule condition + * qu'elles aient transité par `runEvalInRemote`. Ne pas contourner ce point. + * * @param {string} expression expression JS (déjà résolue, sans {$..}/{£..}) * @param {Object} [variables] variables exposées dans le scope (résolues à l'extérieur) * @param {number} [timeoutMs] timeout d'exécution côté service * @returns {Promise<*>} résultat de l'évaluation */ async function runEvalInRemote(expression, variables = {}, timeoutMs = 10000) { - return postEval('/eval', { expression, variables, timeoutMs }, EVAL_HTTP_TIMEOUT_MS); + const sanitizedVariables = sanitizeValue(variables); + return postEval('/eval', { expression, variables: sanitizedVariables, timeoutMs }, EVAL_HTTP_TIMEOUT_MS); } module.exports = { sanitizeValue, evalWithTimeout, - runEvalInWorker, runRegexInWorker, runEvalInRemote, DANGEROUS_KEYS, diff --git a/packages/engine/utils/evalWorker.js b/packages/engine/utils/evalWorker.js deleted file mode 100644 index 184abd704..000000000 --- a/packages/engine/utils/evalWorker.js +++ /dev/null @@ -1,136 +0,0 @@ -'use strict'; - -// ----------------------------------------------------------------------------- -// evalWorker — worker_threads qui exécute une expression de transformation (eval) -// dans un thread séparé et TERMINABLE (point 3 : timeout / DoS). -// -// Le thread principal bloque dans `runEvalInWorker` (Promise) et, si le délai est -// dépassé, appelle `worker.terminate()` : le eval (boucle déguisée, ReDoS, ...) -// ne peut plus bloquer indéfiniment le process engine. -// -// Le scope expose exactement les mêmes identifiants que le `eval` master de -// objectTransformationV2 (libs par nom + helpers), pour une compatibilité 100% -// des expressions de production. Les valeurs injectées (base64 via `unicode`) y -// sont référencées par `this.resolveString(...)` / `this.parseAndResolveString(...)`. -// ----------------------------------------------------------------------------- - -const { parentPort, workerData } = require('worker_threads'); -const { stripDangerousGlobals } = require('./workerGlobals.js'); - -const dayjs = require('dayjs-with-plugins'); -const he = require('he'); -const lodash = require('lodash'); -const removeMarkdown = require('remove-markdown'); -const sanitizeHtml = require('sanitize-html'); -const cheerio = require('cheerio'); -const moment = require('moment'); -const unicode = require('unicode-encode'); -const dotProp = require('dot-prop'); -const crypto = require('crypto'); - -function decodeUnicode(str) { - const regex = new RegExp('\\\\u([\\dA-Fa-f]{4})', 'g'); - return str.replace(regex, (m, g) => String.fromCharCode(parseInt(g, 16))); -} - -function escapeString(source) { - if (typeof source === 'string' || source instanceof String) { - return `eval(this.unicode.atou(\`${unicode.utoa(source)}\`))`; - } else if (Array.isArray(source)) { - return source.map(r => escapeString(r)); - } else if (source != null && source.toJSON !== undefined) { - return escapeString(source.toJSON()); - } else if (source != null && typeof source === 'object') { - const out = {}; - for (const key in source) { - out[unicode.utoa(key)] = escapeString(source[key]); - } - return out; - } - return source; -} - -function resolveString(source) { - if (typeof source === 'string' || source instanceof String) { - const strict = /^eval\(this\.unicode\.atou\(`([^`]*)`\)\)$/.exec(source); - if (strict) { - return unicode.atou(strict[1]); - } - return source; - } else if (Array.isArray(source)) { - return source.map(r => resolveString(r)); - } else if (source != null && typeof source === 'object') { - const out = {}; - for (const key in source) { - out[unicode.atou(key)] = resolveString(source[key]); - } - return out; - } - return source; -} - -function parseAndResolveString(source) { - return resolveString(JSON.parse(source)); -} - -// Expose les libs + helpers sur le global : le eval master utilisait le scope -// de module (variables libres) ET `this.xxx` (this = objet module, mais en mode -// non-strict le `this` du eval pointe sur le global). On attache donc tout au -// global pour que `this.resolveString(...)` et `dayjs(...)` fonctionnent. -globalThis.dayjs = dayjs; -globalThis.moment = moment; -// lodash épuré : on retire template/templateSettings (compilent du code en host -// realm, échappent au vm → RCE). Défense en profondeur (moteur de test). -const LODASH_STRIPPED = ['template', 'templateSettings']; -globalThis.lodash = Object.fromEntries( - Object.entries(lodash).filter(([k]) => !LODASH_STRIPPED.includes(k)) -); -globalThis.he = he; -globalThis.removeMarkdown = removeMarkdown; -globalThis.sanitizeHtml = sanitizeHtml; -globalThis.cheerio = cheerio; -globalThis.decodeUnicode = decodeUnicode; -globalThis.dotProp = dotProp; -globalThis.unicode = unicode; -// Expose le module Node `crypto` (createHash, randomBytes, randomUUID, ...). -// NB : globalThis.crypto est un accessor natif (WebCrypto) configurable ; on le -// remplace par le module Node pour les expressions de prod qui utilisent -// crypto.createHash(...). -Object.defineProperty(globalThis, 'crypto', { - value: crypto, - writable: true, - configurable: true, - enumerable: true -}); -globalThis.resolveString = resolveString; -globalThis.escapeString = escapeString; -globalThis.parseAndResolveString = parseAndResolveString; - -// Variables additionnelles passées par le thread principal (sécurisées). -if (workerData && workerData.scope) { - for (const key of Object.keys(workerData.scope)) { - globalThis[key] = workerData.scope[key]; - } -} - -// SÉCURITÉ : retire du global du worker les globals Node dangereux -// (require/module/process/global/console) AVANT toute évaluation. Un `eval` -// présent dans une expression utilisateur (ex. `eval('new '+... )` en prod) ne -// peut donc PAS atteindre `require`/`process`/fs → pas de RCE par `eval` même -// si `eval` est autorisé pour la compatibilité. `Buffer`/`crypto` restent. -stripDangerousGlobals(); - -let result; -try { - // Indirect eval : `this` pointe sur le global (où sont exposés les helpers / - // libs), reproduisant le scope non-strict du eval master où `this.resolveString` - // et `this.unicode` étaient accessibles. Les identifiants (dayjs, moment, ...) - // sont exposés via globalThis. - // eslint-disable-next-line no-eval - result = (0, eval)(workerData.expression); -} catch (e) { - parentPort.postMessage({ ok: false, error: e && e.message ? e.message : String(e) }); - return; -} - -parentPort.postMessage({ ok: true, result }); diff --git a/packages/engine/utils/objectTransformationV2.js b/packages/engine/utils/objectTransformationV2.js index f3088f4a4..b9246c0ac 100644 --- a/packages/engine/utils/objectTransformationV2.js +++ b/packages/engine/utils/objectTransformationV2.js @@ -67,21 +67,12 @@ const loadCldrData = () => { // Initialisation de Globalize avec toutes les locales loadCldrData(); -function decodeUnicode(str) { - // On définit le pattern : '\\\\u' pour \u et '([\\dA-Fa-f]{4})' pour les 4 chiffres hexadécimaux - const regex = new RegExp('\\\\u([\\dA-Fa-f]{4})', 'g'); - return str.replace(regex, (match, grp) => - String.fromCharCode(parseInt(grp, 16)) - ); -} - module.exports = { // Intl: require('intl'), moment: require('moment'), dotProp: require('dot-prop'), - unicode: require('unicode-encode'), executeWithParams: async function (source, pullParams, jsonTransformPattern, options, config) { // console.log('config',config); const out = await this.execute(source, pullParams, jsonTransformPattern, options, config); @@ -164,50 +155,6 @@ module.exports = { return jsonTransformPattern; } }, - escapeString(source) { - if (typeof source === 'string' || source instanceof String) { - return `eval(this.unicode.atou(\`${this.unicode.utoa(source)}\`))`; - } else if (Array.isArray(source)) { - return source.map(r => this.escapeString(r)); - } else if (source != null && source.toJSON !== undefined) { - return this.escapeString(source.toJSON()); - } else if (source != null && typeof source === 'object') { - const out = {}; - for (const key in source) { - out[this.unicode.utoa(key)] = this.escapeString(source[key]); - } - return out; - } else { - return source; - } - }, - parseAndResolveString(source) { - return this.resolveString(JSON.parse(source)); - }, - resolveString(source) { - if (typeof source === 'string' || source instanceof String) { - // SÉCURITÉ (point 1) : on ne décode que la forme EXACTE produite par notre - // `escapeString` : `eval(this.unicode.atou(\`...\`))`. Toute autre string - // est retournée telle quelle sans eval, même si elle contient un `eval(...)` - // — empêche `this.resolveString(donnéeUtilisateur)` de contourner le - // validateur en exécutant du code embarqué dans une valeur. - const strict = /^eval\(this\.unicode\.atou\(`([^`]*)`\)\)$/.exec(source); - if (strict) { - return this.unicode.atou(strict[1]); - } - return source; - } else if (Array.isArray(source)) { - return source.map(r => this.resolveString(r)); - } else if (source != null && typeof source === 'object') { - const out = {}; - for (const key in source) { - out[this.unicode.atou(key)] = this.resolveString(source[key]); - } - return out; - } else { - return source; - } - }, getValueFromSource(source, pullParams, pattern) { if (pattern.localeCompare('$..') == 0 || pattern.localeCompare('$') == 0) { return source; diff --git a/packages/engine/utils/validateExpression.js b/packages/engine/utils/validateExpression.js index 38ad16960..8be9a3511 100644 --- a/packages/engine/utils/validateExpression.js +++ b/packages/engine/utils/validateExpression.js @@ -63,7 +63,9 @@ const FORBIDDEN_PROPERTIES = new Set([ // blacklist (retirer une liste) : toute nouvelle méthode doit être ajoutée ici, // ce qui passe par une revue (PR) et garantit qu'aucune fonction compilant du // code host-realm (comme lodash.template) ne peut être appelée. -// Ces whitelists doivent rester COHÉRENTES avec secureContext.js (eval-service). +// Ces whitelists doivent rester COHÉRENTES avec secureContext.js (eval-service) : +// TOUTE lib exposée dans le scope eval (makeHelpers) DOIT figurer ici, sinon ses +// méthodes échappent au contrôle (voir SB-RCE-2026-01, review 2026-08-24). // ----------------------------------------------------------------------------- const LIB_METHOD_WHITELISTS = { // Alias lodash/underscore @@ -85,16 +87,203 @@ const LIB_METHOD_WHITELISTS = { dayjs: new Set(['unix', 'utc', 'locale', 'isDayjs', 'duration', 'max', 'min']), moment: new Set(['utc', 'unix', 'locale', 'duration', 'min', 'max', 'now', 'isMoment']), Buffer: new Set(['from']), - crypto: new Set(['createHash', 'randomUUID', 'randomBytes']) + crypto: new Set(['createHash', 'randomUUID', 'randomBytes']), + // --- Libs/helpers exposés par makeHelpers (secureContext.js) mais hors whitelist + // --- auparavant (SB-RCE-2026-01, point 2 du chercheur). Cohérence validateur ↔ scope. + cheerio: new Set(['load']), // seul point d'entrée de parse DOM (pattern prod) + dotProp: new Set(['get', 'has', 'delete']), // `set` bloqué (proto-pollution, via whitelist) + sanitizeHtml: new Set(), // appel nu uniquement + removeMarkdown: new Set(), // appel nu uniquement + decodeUnicode: new Set(), // appel nu uniquement + // --------------------------------------------------------------------------- + // JS INTRINSICS GLOBAUX — WHITELIST STRICTE (plus aucun "par défaut autorisé"). + // Les built-ins d'introspection (Reflect, Proxy, Object.getPrototypeOf, + // Object.getOwnPropertyDescriptor, ...) sont ABSENTS ou en whitelist VIDE : + // tout accès membre est bloqué. C'est ce qui neutralise le vecteur + // "nom de propriété dangereux passé en ARGUMENT string" (SB-RCE-2026-01, + // au-delà des points du chercheur) : Reflect.get(he.decode,'constructor') + // est désormais rejeté. + // --------------------------------------------------------------------------- + Object: new Set(['keys', 'values', 'entries', 'assign', 'create', 'fromEntries', 'is', 'hasOwn']), + Math: new Set([ + 'round', 'floor', 'ceil', 'abs', 'max', 'min', 'pow', 'sqrt', 'cbrt', 'trunc', + 'sign', 'random', 'hypot', 'log', 'log2', 'log10', 'log1p', 'exp', 'expm1', + 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2', 'sinh', 'cosh', 'tanh', + 'asinh', 'acosh', 'atanh', 'clamp', 'fround', 'imul', + 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2' + ]), + JSON: new Set(['parse', 'stringify']), + Array: new Set(['isArray', 'from', 'of']), + String: new Set(['fromCharCode', 'fromCodePoint', 'raw']), + Number: new Set([ + 'isInteger', 'isFinite', 'isNaN', 'isSafeInteger', 'parseFloat', 'parseInt', + 'MAX_SAFE_INTEGER', 'MIN_SAFE_INTEGER', 'MAX_VALUE', 'MIN_VALUE', 'EPSILON', + 'POSITIVE_INFINITY', 'NEGATIVE_INFINITY', 'NaN' + ]), + Date: new Set(['now', 'parse', 'UTC']), + Symbol: new Set(['for', 'keyFor']), + // Utilisables via `new` (DEFAULT_NEW_WHITELIST) ou appel nu ; membres statiques + // TOUS bloqués par défaut (whitelist vide). + Boolean: new Set(), + BigInt: new Set(), + RegExp: new Set(), + Map: new Set(), + Set: new Set(), + WeakMap: new Set(), + WeakSet: new Set(), + ArrayBuffer: new Set(), + SharedArrayBuffer: new Set(), + DataView: new Set(), + Atomics: new Set(), + Promise: new Set(), + Error: new Set(), + AggregateError: new Set(), + EvalError: new Set(), + RangeError: new Set(), + ReferenceError: new Set(), + SyntaxError: new Set(), + TypeError: new Set(), + URIError: new Set(), + Int8Array: new Set(), + Uint8Array: new Set(), + Uint8ClampedArray: new Set(), + Int16Array: new Set(), + Uint16Array: new Set(), + Int32Array: new Set(), + Uint32Array: new Set(), + Float32Array: new Set(), + Float64Array: new Set(), + BigInt64Array: new Set(), + BigUint64Array: new Set(), + Intl: new Set(), + FinalizationRegistry: new Set(), + WeakRef: new Set(), + Reflect: new Set(), // introspection -> TOUT bloqué + Proxy: new Set() // constructeur d'interposition -> TOUT bloqué +}; + +// ----------------------------------------------------------------------------- +// WHITELIST des MÉTHODES sur les OBJETS PRODUITS par les libs exposées. +// Une lib autorisée ne donne accès qu'aux méthodes de ses objets produits qui +// figurent explicitement ici (stratégie 100% WHITELIST — plus aucune blacklist). +// Le type de l'objet produit est inféré statiquement de la chaîne d'appels +// (voir exprType). Les résultats de type "data" (strings/tableaux/objets plats +// produits par he.decode, lodash.*, les variables du flux, ...) ne sont PAS +// restreints ici : leurs méthodes natives ne sont pas des surfaces d'API de lib, +// et le seul vecteur d'évasion (constructor/__proto__/prototype) est bloqué par +// FORBIDDEN_PROPERTIES + le constant-folding. +// ----------------------------------------------------------------------------- +const PRODUCED_WHITELISTS = { + dayjsInstance: new Set([ + 'format', 'add', 'subtract', 'diff', 'startOf', 'endOf', 'get', 'set', 'unix', + 'valueOf', 'toISOString', 'toDate', 'toJSON', 'toArray', 'toString', 'isBefore', + 'isAfter', 'isSame', 'isSameOrBefore', 'isSameOrAfter', 'isValid', 'isDayjs', + 'year', 'month', 'date', 'day', 'dayOfYear', 'week', 'isoWeek', 'hour', 'minute', + 'second', 'millisecond', 'daysInMonth', 'utcOffset', 'local', 'utc', 'clone' + ]), + momentInstance: new Set([ + 'format', 'add', 'subtract', 'diff', 'startOf', 'endOf', 'get', 'set', 'unix', + 'valueOf', 'toISOString', 'toDate', 'toJSON', 'toString', 'isBefore', 'isAfter', + 'isSame', 'isSameOrBefore', 'isSameOrAfter', 'isValid', 'isMoment', 'year', + 'month', 'date', 'day', 'dayOfYear', 'week', 'isoWeek', 'hour', 'minute', + 'second', 'millisecond', 'daysInMonth', 'utcOffset', 'local', 'utc', 'clone', + 'fromNow', 'calendar' + ]), + cheerioInstance: new Set([ + 'text', 'html', 'map', 'get', 'each', 'find', 'filter', 'first', 'last', 'eq', + 'attr', 'removeAttr', 'addClass', 'removeClass', 'hasClass', 'prop', 'removeProp', + 'val', 'data', 'removeData', 'next', 'nextAll', 'prev', 'prevAll', 'parent', + 'parents', 'parentsUntil', 'closest', 'children', 'contents', 'siblings', + 'toArray', 'serialize', 'serializeArray', 'is', 'not', 'has', 'add', 'slice', + 'end', 'append', 'prepend', 'after', 'before', 'remove', 'empty', 'clone', + 'wrap', 'unwrap', 'css', 'replaceWith', 'length' + ]), + // `cheerio.load(...)` retourne la fonction de sélection (`$`) ; ses méthodes + // et l'instance produite partagent la même surface d'API. + cheerioCallable: new Set([ + 'text', 'html', 'map', 'get', 'each', 'find', 'filter', 'first', 'last', 'eq', + 'attr', 'prop', 'val', 'data', 'toArray', 'serialize', 'is', 'not', 'has', + 'add', 'slice', 'end', 'clone', 'length' + ]), + hash: new Set(['update', 'digest']), + bufferResult: new Set(['toString']) }; -// Méthodes statiques dont l'accès est TOUJOURS interdit sur les libs exposées -// (compilation de code / proto-pollution / évasion) — blacklist complémentaire. -const LIB_FORBIDDEN_METHODS = new Set([ - 'template', 'templateSettings', - 'merge', 'mergeWith', 'defaultsDeep', 'set', 'setWith', 'assign', 'defaults', - 'update', 'updateWith', 'zipObjectDeep', 'transform', 'create' + +// Méthodes statiques d'une lib dont l'appel PRODUIT une instance (ex. dayjs.utc). +const DATE_STATIC_PRODUCING = new Set(['utc', 'unix']); +// Méthodes d'une instance date qui renvoient une instance du même type (chaînage). +const DATE_INSTANCE_PRODUCING = new Set(['add', 'subtract', 'startOf', 'endOf', 'set', 'utc', 'local', 'clone']); +// Méthodes d'un objet cheerio qui renvoient un objet cheerio (chaînage). +const CHEERIO_INSTANCE_PRODUCING = new Set([ + 'map', 'find', 'filter', 'first', 'last', 'eq', 'slice', 'add', 'not', 'has', + 'end', 'parent', 'parents', 'closest', 'children', 'siblings', 'next', 'prev', + 'nextAll', 'prevAll', 'clone', 'append', 'prepend', 'after', 'before', 'wrap', 'unwrap' ]); +/** + * Infère statiquement le type d'une expression (mini type-system dédié au + * validateur), pour appliquer PRODUCED_WHITELISTS aux objets produits par les + * libs exposées. Types : libs ('dayjs', 'he', ...), objets produits + * ('dayjsInstance', 'cheerioInstance', 'hash', 'bufferResult', ...), 'this', + * 'data' (données/variables, non restreint) ou `undefined` (inconnu). + */ +function exprType(node) { + if (!node) return undefined; + switch (node.type) { + case 'Identifier': { + if (LIB_METHOD_WHITELISTS[node.name] !== undefined) return node.name; + return undefined; // variable du flux (v0, obj, source, key, ...) : non restreinte + } + case 'ThisExpression': + return 'this'; + case 'Literal': + return 'data'; + case 'CallExpression': { + const calleeType = exprType(node.callee); + switch (calleeType) { + case 'dayjs': return 'dayjsInstance'; + case 'moment': return 'momentInstance'; + case 'cheerioLoad': return 'cheerioCallable'; + case 'cheerioCallable': + case 'cheerioInstance': + return 'cheerioInstance'; + case 'dayjsInstance': + case 'momentInstance': + case 'hash': + case 'bufferResult': + return calleeType; + default: + return 'data'; + } + } + case 'MemberExpression': { + const objType = exprType(node.object); + const meth = node.computed ? foldStaticValue(node.property) : node.property && node.property.name; + // `this.moment` / `this.dayjs` / ... : membre d'accès sur `this` = la lib. + if (objType === 'this' && typeof meth === 'string' && LIB_METHOD_WHITELISTS[meth] !== undefined) { + return meth; + } + if (typeof meth !== 'string') return 'data'; + if (objType === 'dayjs' || objType === 'moment') { + return DATE_STATIC_PRODUCING.has(meth) ? (objType === 'dayjs' ? 'dayjsInstance' : 'momentInstance') : 'data'; + } + if (objType === 'cheerio') return meth === 'load' ? 'cheerioLoad' : 'data'; + if (objType === 'crypto') return meth === 'createHash' ? 'hash' : 'data'; + if (objType === 'Buffer') return meth === 'from' ? 'bufferResult' : 'data'; + if (objType === 'dayjsInstance' || objType === 'momentInstance') { + return DATE_INSTANCE_PRODUCING.has(meth) ? objType : 'data'; + } + if (objType === 'cheerioInstance' || objType === 'cheerioCallable' || objType === 'cheerioLoad') { + return CHEERIO_INSTANCE_PRODUCING.has(meth) ? 'cheerioInstance' : 'data'; + } + if (objType === 'hash') return meth === 'update' ? 'hash' : 'data'; + return 'data'; + } + default: + return 'data'; + } +} + // Constructeurs autorisés pour `new` par défaut (sûrs). const DEFAULT_NEW_WHITELIST = new Set([ 'Array', 'BigInt', 'Boolean', 'Date', 'Error', 'Map', 'Number', 'Object', @@ -143,7 +332,9 @@ function getReceptorLib(node) { if (cur && cur.type === 'Identifier') { const name = cur.name; if (name === '_' || name === 'underscore') return 'lodash'; - if (LIB_METHOD_WHITELISTS[name]) return name; + // Toute lib exposée OU tout JS intrinsic global : si whitelist absente/vide + // (Reflect, Proxy, introspection...), tout accès membre sera bloqué. + if (LIB_METHOD_WHITELISTS[name] !== undefined) return name; } return null; } @@ -153,6 +344,47 @@ function isLodashReceptor(node) { return getReceptorLib(node) === 'lodash'; } +/** + * Constant-folding d'une clé computed : résout statiquement une expression de + * clé composée uniquement de constantes (littéraux, concaténations, templates + * sans interpolation). Retourne `undefined` si la clé n'est pas statiquement + * résolvable (variable dynamique, appel, ...). + * + * Objectif sécurité : une clé computed non-littérale comme `'con'+'structor'` + * (BinaryExpression) n'a pas de `.value`, ce qui faisait sauter les gardes + * `prop !== undefined` (FORBIDDEN_PROPERTIES / whitelist / blacklist) — voir + * SB-RCE-2026-01 (review chercheur 2026-08-24). En repliant la clé, le résultat + * (`'constructor'`) passe dans les mêmes contrôles que les clés littérales. + * + * Le cas non résolu (clé dynamique, ex. `obj[key]`) reste autorisé : il n'est + * fermable que par un garde runtime (contenu par l'isolation du worker). + */ +function foldStaticValue(node) { + if (!node) return undefined; + if (node.type === 'Literal') return node.value; + if (node.type === 'TemplateLiteral') { + // Replie les templates dont toutes les interpolations sont statiquement + // résolubles : `con${''}structor` -> 'constructor'. Si une interpolation + // est dynamique (variable), on ne peut pas replier. + const parts = []; + for (let i = 0; i < node.quasis.length; i++) { + parts.push(node.quasis[i].value.cooked ?? ''); + if (i < node.expressions.length) { + const v = foldStaticValue(node.expressions[i]); + if (v === undefined) return undefined; + parts.push(String(v)); + } + } + return parts.join(''); + } + if (node.type === 'BinaryExpression' && node.operator === '+') { + const left = foldStaticValue(node.left); + const right = foldStaticValue(node.right); + if (left !== undefined && right !== undefined) return String(left) + String(right); + } + return undefined; +} + /** * Valide une expression JS avant évaluation. Lève ExpressionValidationError si * le code est inacceptable. @@ -185,7 +417,10 @@ function validateExpression(source, options = {}) { // 2. Membre : propriété interdite (y compris via notation calcée) if (node.type === 'MemberExpression') { - const prop = node.computed ? node.property.value : node.property && node.property.name; + // Constant-folding des clés computed statiquement résolubles : une clé + // non-littérale (`'con'+'structor'`) est repliée vers sa valeur afin de + // passer par les mêmes contrôles que les clés littérales. + const prop = node.computed ? foldStaticValue(node.property) : node.property && node.property.name; if (prop !== undefined && FORBIDDEN_PROPERTIES.has(prop)) { throw new ExpressionValidationError(`Forbidden property access: ${prop}`); } @@ -203,14 +438,17 @@ function validateExpression(source, options = {}) { const lib = getReceptorLib(node.object); if (lib && prop !== undefined && typeof prop === 'string') { const whitelist = LIB_METHOD_WHITELISTS[lib]; - if (whitelist && !whitelist.has(prop)) { + if (!whitelist || !whitelist.has(prop)) { throw new ExpressionValidationError(`Forbidden method on ${lib}: ${prop}`); } } - // Blacklist complémentaire : méthodes toujours interdites (compilation de - // code host-realm / proto-pollution) sur toute lib exposée. - if (lib && prop !== undefined && LIB_FORBIDDEN_METHODS.has(prop)) { - throw new ExpressionValidationError(`Forbidden method on ${lib}: ${prop}`); + // WHITELIST des objets PRODUITS par les libs exposées (ex. dayjs(x).format, + // cheerio.load(x)(sel).text(), crypto.createHash().digest()). Le type du + // récepteur est inféré statiquement ; toute méthode non listée est interdite. + const producedType = exprType(node.object); + const producedWhitelist = PRODUCED_WHITELISTS[producedType]; + if (producedWhitelist && prop !== undefined && typeof prop === 'string' && !producedWhitelist.has(prop)) { + throw new ExpressionValidationError(`Forbidden method on ${producedType}: ${prop}`); } } @@ -282,15 +520,13 @@ function validateExpression(source, options = {}) { // La méthode doit figurer dans la whitelist de la lib. Couvre à la fois // la proto-pollution (lodash.merge/_.set), la compilation de code // host-realm (lodash.template) et toute autre méthode non autorisée. + // (Les méthodes sur objets produits sont contrôlées au niveau du + // MemberExpression — voir PRODUCED_WHITELISTS.) const lib = getReceptorLib(node.callee.object); if (lib) { const whitelist = LIB_METHOD_WHITELISTS[lib]; const meth = node.callee.property.name; - if (whitelist && !whitelist.has(meth)) { - throw new ExpressionValidationError(`Forbidden method call on ${lib}: ${meth}`); - } - // Blacklist complémentaire (même si whitelist undefined — alias). - if (LIB_FORBIDDEN_METHODS.has(meth)) { + if (!whitelist || !whitelist.has(meth)) { throw new ExpressionValidationError(`Forbidden method call on ${lib}: ${meth}`); } } diff --git a/packages/engine/utils/workerGlobals.js b/packages/engine/utils/workerGlobals.js deleted file mode 100644 index ca90bd73b..000000000 --- a/packages/engine/utils/workerGlobals.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -// ----------------------------------------------------------------------------- -// workerGlobals — nettoyage du scope global d'un worker_threads avant toute -// évaluation de code utilisateur (eval / $where). -// -// Un worker_threads expose sur `globalThis` des globals Node dangereux pour une -// évaluation de code arbitraire : `require`, `module`, `process`, `global`, -// `console`. Sans nettoyage, un `eval('require("fs")...')` dans l'expression d'un -// utilisateur accéderait au système (RCE). On les RETIRE donc du global du -// worker : le `eval` interne ne peut alors plus atteindre `require`/`process`/fs. -// -// `Buffer` est conservé (usage de production sûr : Buffer.from(...).toString()). -// `crypto` est exposé séparément (référence maîtrisée) par evalWorker.js. -// ----------------------------------------------------------------------------- - -const DANGEROUS_GLOBALS = ['require', 'module', 'process', 'global', 'console']; - -/** - * Retire les globals Node dangereux du `globalThis` du worker courant. - * Idempotent. À appeler AVANT toute évaluation de code utilisateur. - */ -function stripDangerousGlobals() { - for (const name of DANGEROUS_GLOBALS) { - try { - // configurable pour process/global ; delete sur require/module peut échouer - // silencieusement selon la version de Node, d'où le try/catch. - delete globalThis[name]; - } catch (e) { - // ignore : si la propriété n'est pas supprimable, on tente une redéfinition - try { - Object.defineProperty(globalThis, name, { value: undefined, writable: false, configurable: false }); - } catch (e2) { - // dernier recours : rien (le validateur + le scope maîtrisé restent) - } - } - } -} - -module.exports = { stripDangerousGlobals, DANGEROUS_GLOBALS }; diff --git a/packages/eval-service/evalWorker.js b/packages/eval-service/evalWorker.js index e60870906..56981fc48 100644 --- a/packages/eval-service/evalWorker.js +++ b/packages/eval-service/evalWorker.js @@ -18,51 +18,8 @@ const { parentPort } = require('worker_threads'); const vm = require('vm'); -const unicode = require('unicode-encode'); const { createSecureContext, stripDangerousGlobals } = require('./secureContext.js'); -// Helpers compatibles avec le scope master (utilisés par certaines expressions). -function resolveString(source) { - if (typeof source === 'string' || source instanceof String) { - const strict = /^eval\(this\.unicode\.atou\(`([^`]*)`\)\)$/.exec(source); - if (strict) return unicode.atou(strict[1]); - return source; - } else if (Array.isArray(source)) { - return source.map((r) => resolveString(r)); - } else if (source != null && typeof source === 'object') { - const out = {}; - for (const key in source) out[unicode.atou(key)] = resolveString(source[key]); - return out; - } - return source; -} -function escapeString(source) { - if (typeof source === 'string' || source instanceof String) { - return `eval(this.unicode.atou(\`${unicode.utoa(source)}\`))`; - } else if (Array.isArray(source)) { - return source.map((r) => escapeString(r)); - } else if (source != null && source.toJSON !== undefined) { - return escapeString(source.toJSON()); - } else if (source != null && typeof source === 'object') { - const out = {}; - for (const key in source) out[unicode.utoa(key)] = escapeString(source[key]); - return out; - } - return source; -} -function parseAndResolveString(source) { - return resolveString(JSON.parse(source)); -} - -// Helpers locaux (spécifiques à l'éval d'une expression) injectés EN PLUS du -// contexte sécurisé fourni par secureContext.js. -const localHelpers = { - unicode, - resolveString, - escapeString, - parseAndResolveString -}; - stripDangerousGlobals(); parentPort.on('message', (msg) => { @@ -71,7 +28,14 @@ parentPort.on('message', (msg) => { // Contexte vm NEUF et SÉCURISÉ (libs épurées/gelées + import bloqué) par job. const ctx = createSecureContext(); - Object.assign(ctx, localHelpers); + // CONTRAT DE SÉCURITÉ : les `variables` injectées dans le scope sont considérées + // SÛRES à la SEULE condition qu'elles aient transité par `runEvalInRemote` + // (engine), qui applique `sanitizeValue` (retrait des clés __proto__/constructor/ + // prototype + des accessors/getters) avant la sérialisation HTTP — c'est le point + // d'application unique côté engine (voir evalSecurity.js). Ce container est INTERNE + // (non exposé, signé HMAC, appelable uniquement par l'application) : ne pas appeler + // /eval avec des variables non assainies et ne pas exposer ce port hors du réseau + // interne. Tout nouveau chemin d'entrée DOIT passer par runEvalInRemote. if (variables) Object.assign(ctx, variables); try { diff --git a/packages/eval-service/package.json b/packages/eval-service/package.json index 84232c543..d5a6fdf15 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.16", + "version": "0.11.17", "description": "Service d'évaluation JavaScript isolé en container (dédié aux eval des transformations / $where).", "private": true, "main": "app.js", @@ -18,7 +18,6 @@ "lodash": "^4.17.21", "moment": "^2.30.1", "remove-markdown": "^0.6.2", - "sanitize-html": "^2.17.0", - "unicode-encode": "^1.4.2" + "sanitize-html": "^2.17.0" } } diff --git a/packages/eval-service/secureContext.js b/packages/eval-service/secureContext.js index 6c8a4db94..ba8d38b6a 100644 --- a/packages/eval-service/secureContext.js +++ b/packages/eval-service/secureContext.js @@ -24,7 +24,6 @@ const removeMarkdown = require('remove-markdown'); const sanitizeHtml = require('sanitize-html'); const cheerio = require('cheerio'); const moment = require('moment'); -const unicode = require('unicode-encode'); const dotProp = require('dot-prop'); const nodeCrypto = require('crypto'); const { stripDangerousGlobals } = require('./workerGlobals.js'); @@ -153,7 +152,6 @@ function makeHelpers() { cheerio, decodeUnicode, dotProp, - unicode, crypto: safeCrypto, // wrapper minimal (createHash/randomUUID) Buffer: safeBuffer // wrapper minimal (from uniquement) }; @@ -173,6 +171,10 @@ const helpers = makeHelpers(); // Crée un contexte vm NEUF et SÉCURISÉ, pré-rempli avec les libs épurées. // Les variables additionnelles (résolues à l'extérieur) sont injectées par // l'appelant. Toute import() dynamique depuis le contexte est rejeté. +// CONTRAT DE SÉCURITÉ : les variables injectées dans le contexte sont considérées +// sûres à la seule condition d'avoir transité par `runEvalInRemote` (engine), qui +// applique `sanitizeValue` avant sérialisation. Ne pas alimenter ce contexte avec +// des variables non assainies. function createSecureContext() { const ctx = vm.createContext({}, { importModuleDynamically: () => diff --git a/packages/main/package.json b/packages/main/package.json index e10e01ee0..c4d070a3b 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -1,6 +1,6 @@ { "name": "@semantic-bus/main", - "version": "0.11.16", + "version": "0.11.17", "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 a11d32944..e8d06e7f9 100644 --- a/packages/timer/package.json +++ b/packages/timer/package.json @@ -1,6 +1,6 @@ { "name": "@semantic-bus/timer", - "version": "0.11.16", + "version": "0.11.17", "description": "Timer scheduler module for Semantic Bus", "private": true, "main": "app.js",