Files
Ruslan KonviserandClaude Opus 5 b45fc7ee85 fix(security): MJML file-include LFI + integration-setting takeover (GHSA-48h9, GHSA-4rwq) (#10239)
* fix(security): compile MJML without file includes, lock down integration settings

GHSA-48h9-vwf5-h8m7 (critical): an email or accounting template containing
mj-include with a path was compiled with the file loader enabled, so any caller
who could preview or save a template could read files from the API container,
including .env and /proc/self/environ. All nine mjml2html call sites now go
through compileMjml(), which passes ignoreIncludes: true (verified against
mjml-parser-xml 4.18.0, which returns before any read) and coerces the source to
a string, so a JSON body can no longer arrive as a pre-parsed Handlebars AST. An
ESLint rule keeps raw mjml imports out of the package, and the preview routes
now validate their bodies.

GHSA-4rwq-65wh-45h4 (high): PUT /integration-setting/:id could rewrite
server-managed settings such as a GitHub installation_id, binding another
tenant's installation. Updates are now tenant-scoped, restricted to an allow-list
of user-editable keys, and pin settingsName and integrationId; the inherited
POST /integration-tenant route that reached the same sink is gated; and
installation_id is canonicalised so the uniqueness check compares like with like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): add the new vocabulary and use US spellings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): harden template source coercion, address review findings

Review follow-ups on the GHSA-48h9 / GHSA-4rwq fix. No security boundary
is relaxed; the coercion helper gets strictly stronger.

- toTemplateSource(): map every non-primitive (a JSON object or array, a
  Handlebars AST, a function) to '' instead of stringifying it. `String(value)`
  produced a useless '[object Object]' and, for an object whose `toString` and
  `valueOf` are not callable ({"toString":1,"valueOf":2} is valid JSON), threw a
  TypeError out of the request handler instead of rendering an empty template.
  The preview DTOs already reject a non-string `data`, so this is the second
  layer, and it is the layer the stored-`hbs` render path relies on. Primitives
  still stringify. Spec updated to assert the stronger outcome (the AST now
  renders to '', not to '[object Object]') and extended with the throwing shape.
  Also clears SonarCloud "'source ?? ''' will use Object's default
  stringification format".

- IntegrationTenantController.create(): replace the nested ternary that encoded
  "not an array means refuse" as a sentinel `[null]` element with an explicit
  guard. Same three outcomes (absent settings allowed, array checked
  element-wise, anything else forbidden), one fewer indirection. Clears
  SonarCloud "Extract this nested ternary operation".

- GITHUB_INSTALLATION_ID_PATTERN: `\d` for `[0-9]`. Identical in JS (`\d` is
  ASCII-only, with or without the `u` flag); clears a SonarCloud nitpick.

- integration-setting.utils.ts: record WHY the allowlist lookup stays on
  `Object.prototype.hasOwnProperty.call`. SonarCloud asks for `Object.hasOwn`,
  but that is ES2022 and packages/core/tsconfig.lib.json targets es2021 with no
  `lib` override, so it fails to compile (TS2550, verified with tsc).

Tests: packages/core email-template + accounting-template + integration-setting
+ integration-tenant = 8 suites, 123 tests passed; integration-github = 2 suites,
48 tests passed. ESLint over the changed core files: 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:06:26 +02:00

58 lines
2.0 KiB
JavaScript

// cspell:ignore esquery
const baseConfig = require('../../eslint.config.js');
// The MJML compiler reads files from disk for `<mj-include>` unless told not to, and email/accounting
// templates are tenant-editable (GHSA-48h9-vwf5-h8m7). Every compile must go through
// `src/lib/email-template/compile-mjml.ts`, which disables includes; importing the compiler anywhere
// else in this package is a lint error. Covers `import` and `import x = require()`
// (`no-restricted-imports`) as well as `require()` and dynamic `import()` (`no-restricted-syntax`).
const MJML_MODULES = ['mjml', 'mjml-core', 'mjml-parser-xml'];
const MJML_MESSAGE =
'Compile MJML through compileMjml() in src/lib/email-template/compile-mjml.ts; it disables <mj-include> file reads (GHSA-48h9-vwf5-h8m7).';
// esquery regex literal; no '/' allowed inside. Matches `mjml`, `mjml-core`, `mjml-parser-xml` and their subpaths.
const MJML_MODULE_REGEX = '/^mjml(-core|-parser-xml)?(?![A-Za-z0-9_.-])/';
const restrictMjmlImports = {
name: 'gauzy/core/restrict-mjml-imports',
files: ['**/*.ts', '**/*.js', '**/*.cts', '**/*.mts', '**/*.cjs', '**/*.mjs'],
ignores: ['**/src/lib/email-template/compile-mjml.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: MJML_MODULES.map((name) => ({ name, message: MJML_MESSAGE })),
patterns: [{ group: MJML_MODULES.map((name) => `${name}/*`), message: MJML_MESSAGE }]
}
],
'no-restricted-syntax': [
'error',
{
selector: `CallExpression[callee.name='require'][arguments.0.value=${MJML_MODULE_REGEX}]`,
message: MJML_MESSAGE
},
{
selector: `ImportExpression[source.value=${MJML_MODULE_REGEX}]`,
message: MJML_MESSAGE
}
]
}
};
module.exports = [
...baseConfig,
restrictMjmlImports,
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}']
}
]
},
languageOptions: {
parser: require('jsonc-eslint-parser')
}
}
];