mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-02 22:07:19 +02:00
* refactor: self-healing tenant isolation update guard Replace the strict throw-on-any-tenantId guard with a strip-or-throw approach: - $set/$setOnInsert: strip when value matches current tenant or no context is active; throw only on cross-tenant mutations - $unset/$rename: always strip (unsetting/renaming tenantId is never valid) - Top-level tenantId: same logic as $set This eliminates the entire class of "tenantId in update payload" bugs at the plugin level while preserving the cross-tenant security invariant. * test: update mutation guard tests for self-healing behavior - Convert same-tenant $set/$setOnInsert tests to expect silent stripping instead of throws - Convert $unset test to expect silent stripping - Add cross-tenant throw tests for $set, $setOnInsert, top-level - Add same-tenant stripping tests for $set, $setOnInsert, top-level - Add $rename stripping test - Add no-context stripping test - Update error message assertions to match new cross-tenant message * revert: remove call-site tenantId stripping patches Revert the per-call-site tenantId stripping from #12498 and the excludedKeys patch from #12501. These are no longer needed since the self-healing guard handles tenantId in update payloads at the plugin level. Reverted patches: - conversation.ts: delete update.tenantId in saveConvo(), tenantId destructuring in bulkSaveConvos() - message.ts: delete update.tenantId in saveMessage() and recordMessage(), tenantId destructuring in bulkSaveMessages() and updateMessage() - config.ts: tenantId in excludedKeys Set - config.spec.ts: tenantId in excludedKeys test assertion * fix: strip tenantId from update documents in tenantSafeBulkWrite Mongoose middleware does not fire for bulkWrite, so the plugin-level guard never sees update payloads in bulk operations. Extend injectTenantId() to strip tenantId from update documents for updateOne/updateMany operations, preventing cross-tenant overwrites. * refactor: rename guard, add empty-op cleanup and strict-mode warning - Rename assertNoTenantIdMutation to sanitizeTenantIdMutation - Remove empty operator objects after stripping to avoid MongoDB errors - Log warning in strict mode when stripping tenantId without context - Fix $setOnInsert test to use upsert:true with non-matching filter * test: fix bulk-save tests and add negative excludedKeys assertion - Wrap bulkSaveConvos/bulkSaveMessages tests in tenantStorage.run() to exercise the actual multi-tenant stripping path - Assert tenantId equals the real tenant, not undefined - Add negative assertion: excludedKeys must NOT contain tenantId * fix: type-safe tenantId stripping in tenantSafeBulkWrite - Fix TS2345 error: replace conditional type inference with UpdateQuery<Record<string, unknown>> for stripTenantIdFromUpdate - Handle empty updates after stripping (e.g., $set: { tenantId } as sole field) by filtering null ops from the bulk array - Add 4 tests for bulk update tenantId stripping: plain-object update, $set stripping, $unset stripping, and sole-field-in-$set edge case * fix: resolve TS2345 in stripTenantIdFromUpdate parameter type Use Record<string, unknown> instead of UpdateQuery<> to avoid type incompatibility with Mongoose's AnyObject-based UpdateQuery resolution in CI. * fix: strip tenantId from bulk updates unconditionally Separate sanitization from injection in tenantSafeBulkWrite: tenantId is now stripped from all update documents before any tenant-context checks, closing the gap where no-context and system-context paths passed caller-supplied tenantId through to MongoDB unmodified. * refactor: address review findings in tenant isolation - Fix early-return gap in stripTenantIdFromUpdate that skipped operator-level tenantId when top-level was also present - Lazy-allocate copy in stripTenantIdFromUpdate (no allocation when no tenantId is present) - Document behavioral asymmetry: plugin throws on cross-tenant, bulkWrite strips silently (intentional, documented in JSDoc) - Remove double JSDoc on injectTenantId - Remove redundant cast in stripTenantIdFromUpdate - Use shared frozen EMPTY_BULK_RESULT constant - Remove Record<string, unknown> annotation in recordMessage - Isolate bulkSave* tests: pre-create docs then update with cross-tenant payload, read via runAsSystem to prove stripping is independent of filter injection * fix: no-op empty updates after tenantId sanitization When tenantId is the sole field in an update (e.g., { $set: { tenantId } }), sanitization leaves an empty update object that would fail with "Update document requires atomic operators." The updateGuard now detects this and short-circuits the query by adding an unmatchable filter condition and disabling upsert, matching the bulk-write handling that filters out null ops. * refactor: remove dead logger.warn branches, add mixed-case test - Remove unreachable logger.warn calls in sanitizeTenantIdMutation: queryMiddleware throws before updateGuard in strict+no-context, and isStrict() is false in non-strict+no-context - Add test for combined top-level + operator-level tenantId stripping to lock in the early-return fix * feat: ESLint rule to ban raw bulkWrite and collection.* in data-schemas Add no-restricted-syntax rules to the data-schemas ESLint config that flag direct Model.bulkWrite() and Model.collection.* calls. These bypass Mongoose middleware and the tenant isolation plugin — all bulk writes must use tenantSafeBulkWrite() instead. Test files are excluded since they intentionally use raw driver calls for fixture setup. Also migrate the one remaining raw bulkWrite in seedSystemGrants() to use tenantSafeBulkWrite() for consistency. * test: add findByIdAndUpdate coverage to mutation guard tests * fix: keep tenantSafeBulkWrite in seedSystemGrants, fix ESLint config - Revert to tenantSafeBulkWrite in seedSystemGrants (always runs under runAsSystem, so the wrapper passes through correctly) - Split data-schemas ESLint config: shared TS rules for all files, no-restricted-syntax only for production non-wrapper files - Fix unused destructure vars to use _tenantId pattern
391 lines
10 KiB
JavaScript
391 lines
10 KiB
JavaScript
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin';
|
|
import { fixupConfigRules, fixupPluginRules } from '@eslint/compat';
|
|
import reactHooks from 'eslint-plugin-react-hooks';
|
|
import tsParser from '@typescript-eslint/parser';
|
|
import importPlugin from 'eslint-plugin-import';
|
|
import prettier from 'eslint-plugin-prettier';
|
|
import { FlatCompat } from '@eslint/eslintrc';
|
|
import jsxA11Y from 'eslint-plugin-jsx-a11y';
|
|
import i18next from 'eslint-plugin-i18next';
|
|
import react from 'eslint-plugin-react';
|
|
import jest from 'eslint-plugin-jest';
|
|
import globals from 'globals';
|
|
import js from '@eslint/js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const compat = new FlatCompat({
|
|
baseDirectory: __dirname,
|
|
recommendedConfig: js.configs.recommended,
|
|
allConfig: js.configs.all,
|
|
});
|
|
|
|
export default [
|
|
{
|
|
ignores: [
|
|
'client/vite.config.ts',
|
|
'client/dist/**/*',
|
|
'client/public/**/*',
|
|
'client/coverage/**/*',
|
|
'e2e/playwright-report/**/*',
|
|
'packages/api/types/**/*',
|
|
'packages/api/dist/**/*',
|
|
'packages/api/test_bundle/**/*',
|
|
'api/demo/**/*',
|
|
'packages/client/dist/**/*',
|
|
'packages/data-provider/types/**/*',
|
|
'packages/data-provider/dist/**/*',
|
|
'packages/data-provider/test_bundle/**/*',
|
|
'packages/data-schemas/dist/**/*',
|
|
'packages/data-schemas/misc/**/*',
|
|
'data-node/**/*',
|
|
'meili_data/**/*',
|
|
'**/node_modules/**/*',
|
|
'.devcontainer/**/*',
|
|
],
|
|
},
|
|
...fixupConfigRules(
|
|
compat.extends(
|
|
'eslint:recommended',
|
|
'plugin:react/recommended',
|
|
'plugin:react-hooks/recommended',
|
|
'plugin:jest/recommended',
|
|
'prettier',
|
|
'plugin:jsx-a11y/recommended',
|
|
),
|
|
),
|
|
{
|
|
plugins: {
|
|
react: fixupPluginRules(react),
|
|
'react-hooks': fixupPluginRules(reactHooks),
|
|
'@typescript-eslint': typescriptEslintEslintPlugin,
|
|
import: importPlugin,
|
|
'jsx-a11y': fixupPluginRules(jsxA11Y),
|
|
'import/parsers': tsParser,
|
|
i18next,
|
|
prettier: fixupPluginRules(prettier),
|
|
},
|
|
|
|
languageOptions: {
|
|
globals: {
|
|
...globals.browser,
|
|
...globals.node,
|
|
...globals.commonjs,
|
|
},
|
|
parser: tsParser,
|
|
ecmaVersion: 'latest',
|
|
sourceType: 'module',
|
|
parserOptions: {
|
|
ecmaFeatures: {
|
|
jsx: true,
|
|
},
|
|
},
|
|
},
|
|
|
|
settings: {
|
|
react: {
|
|
createClass: 'createReactClass',
|
|
pragma: 'React',
|
|
fragment: 'Fragment',
|
|
version: 'detect',
|
|
},
|
|
'import/parsers': {
|
|
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
|
},
|
|
'import/resolver': {
|
|
typescript: {
|
|
project: ['./client/tsconfig.json'],
|
|
},
|
|
node: {
|
|
project: ['./client/tsconfig.json'],
|
|
},
|
|
},
|
|
},
|
|
|
|
rules: {
|
|
'prettier/prettier': 'error',
|
|
'react/react-in-jsx-scope': 'off',
|
|
|
|
'@typescript-eslint/ban-ts-comment': [
|
|
'error',
|
|
{
|
|
'ts-ignore': false,
|
|
},
|
|
],
|
|
// Disable a11y features to be enabled later on.
|
|
'jsx-a11y/no-static-element-interactions': 'off',
|
|
'jsx-a11y/click-events-have-key-events': 'off',
|
|
'jsx-a11y/alt-text': 'off',
|
|
'jsx-a11y/img-redundant-alt': 'off',
|
|
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
|
// common rules
|
|
'no-nested-ternary': 'warn',
|
|
'no-constant-binary-expression': 'warn',
|
|
'no-unused-vars': [
|
|
'warn',
|
|
{
|
|
argsIgnorePattern: '^_',
|
|
varsIgnorePattern: '^_',
|
|
caughtErrorsIgnorePattern: '^_',
|
|
},
|
|
],
|
|
'no-console': 'off',
|
|
'import/no-cycle': 'error',
|
|
'import/no-self-import': 'error',
|
|
'import/extensions': 'off',
|
|
'no-promise-executor-return': 'off',
|
|
'no-param-reassign': 'off',
|
|
'no-continue': 'off',
|
|
'no-restricted-syntax': 'off',
|
|
'react/prop-types': 'off',
|
|
'react/display-name': 'off',
|
|
},
|
|
},
|
|
{
|
|
files: ['api/**/*.js', 'config/**/*.js'],
|
|
rules: {
|
|
// API
|
|
'no-async-promise-executor': 'off',
|
|
},
|
|
},
|
|
{
|
|
files: [
|
|
'client/src/**/*.tsx',
|
|
'client/src/**/*.ts',
|
|
'client/src/**/*.jsx',
|
|
'client/src/**/*.js',
|
|
],
|
|
rules: {
|
|
// Client a11y
|
|
// TODO: maybe later to error.
|
|
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
|
'jsx-a11y/label-has-associated-control': 'off',
|
|
'jsx-a11y/no-static-element-interactions': 'off',
|
|
'jsx-a11y/click-events-have-key-events': 'off',
|
|
'jsx-a11y/interactive-supports-focus': 'off',
|
|
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
|
'jsx-a11y/img-redundant-alt': 'off',
|
|
},
|
|
},
|
|
{
|
|
files: ['**/rollup.config.js', '**/.eslintrc.js', '**/jest.config.js'],
|
|
languageOptions: {
|
|
globals: {
|
|
...globals.node,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
files: [
|
|
'**/*.test.js',
|
|
'**/*.test.jsx',
|
|
'**/*.test.ts',
|
|
'**/*.test.tsx',
|
|
'**/*.spec.js',
|
|
'**/*.spec.jsx',
|
|
'**/*.spec.ts',
|
|
'**/*.spec.tsx',
|
|
'**/setupTests.js',
|
|
],
|
|
languageOptions: {
|
|
globals: {
|
|
...globals.jest,
|
|
...globals.node,
|
|
},
|
|
},
|
|
rules: {
|
|
// TEST
|
|
'react/display-name': 'off',
|
|
'react/prop-types': 'off',
|
|
'jest/no-commented-out-tests': 'off',
|
|
'react/no-unescaped-entities': 'off',
|
|
'jest/no-conditional-expect': 'off',
|
|
'jest/no-disabled-tests': 'off',
|
|
'@typescript-eslint/no-unused-vars': 'off',
|
|
},
|
|
},
|
|
...compat
|
|
.extends(
|
|
'plugin:@typescript-eslint/eslint-recommended',
|
|
'plugin:@typescript-eslint/recommended',
|
|
)
|
|
.map((config) => ({
|
|
...config,
|
|
files: ['**/*.ts', '**/*.tsx'],
|
|
})),
|
|
{
|
|
files: ['**/*.ts', '**/*.tsx'],
|
|
ignores: ['packages/**/*'],
|
|
plugins: {
|
|
'@typescript-eslint': typescriptEslintEslintPlugin,
|
|
jest: fixupPluginRules(jest),
|
|
},
|
|
languageOptions: {
|
|
parser: tsParser,
|
|
ecmaVersion: 5,
|
|
sourceType: 'script',
|
|
parserOptions: {
|
|
project: './client/tsconfig.json',
|
|
},
|
|
},
|
|
rules: {
|
|
// i18n
|
|
'i18next/no-literal-string': [
|
|
'error',
|
|
{
|
|
mode: 'jsx-text-only',
|
|
'should-validate-template': true,
|
|
},
|
|
],
|
|
//
|
|
'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }],
|
|
'@typescript-eslint/no-unused-expressions': 'off',
|
|
'@typescript-eslint/no-unused-vars': [
|
|
'warn',
|
|
{
|
|
argsIgnorePattern: '^_',
|
|
varsIgnorePattern: '^_',
|
|
caughtErrorsIgnorePattern: '^_',
|
|
},
|
|
],
|
|
'@typescript-eslint/no-explicit-any': 'off',
|
|
'@typescript-eslint/no-unnecessary-condition': 'off',
|
|
'@typescript-eslint/strict-boolean-expressions': 'off',
|
|
'@typescript-eslint/ban-ts-comment': 'off',
|
|
// React
|
|
'react/no-unknown-property': 'warn',
|
|
'react-hooks/rules-of-hooks': 'error',
|
|
'react-hooks/exhaustive-deps': 'warn',
|
|
// General
|
|
'no-constant-binary-expression': 'off',
|
|
'import/no-cycle': 'off',
|
|
},
|
|
},
|
|
{
|
|
// **Data-provider specific configuration block**
|
|
files: ['./packages/data-provider/**/*.ts'],
|
|
languageOptions: {
|
|
parser: tsParser,
|
|
ecmaVersion: 'latest',
|
|
sourceType: 'module',
|
|
parserOptions: {
|
|
project: './packages/data-provider/tsconfig.json',
|
|
},
|
|
},
|
|
rules: {
|
|
'@typescript-eslint/no-unused-vars': [
|
|
'warn',
|
|
{
|
|
argsIgnorePattern: '^_',
|
|
varsIgnorePattern: '^_',
|
|
caughtErrorsIgnorePattern: '^_',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
files: ['./api/demo/**/*.ts'],
|
|
},
|
|
{
|
|
files: ['./packages/api/**/*.ts'],
|
|
rules: {
|
|
'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }],
|
|
'@typescript-eslint/no-unused-vars': [
|
|
'warn',
|
|
{
|
|
argsIgnorePattern: '^_',
|
|
varsIgnorePattern: '^_',
|
|
caughtErrorsIgnorePattern: '^_',
|
|
destructuredArrayIgnorePattern: '^_',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
files: ['./config/translations/**/*.ts'],
|
|
languageOptions: {
|
|
parser: tsParser,
|
|
ecmaVersion: 5,
|
|
sourceType: 'script',
|
|
parserOptions: {
|
|
project: './config/translations/tsconfig.json',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
files: ['./packages/data-provider/specs/**/*.ts'],
|
|
languageOptions: {
|
|
ecmaVersion: 5,
|
|
sourceType: 'script',
|
|
parserOptions: {
|
|
project: './packages/data-provider/tsconfig.spec.json',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
files: ['./api/demo/specs/**/*.ts'],
|
|
languageOptions: {
|
|
ecmaVersion: 5,
|
|
sourceType: 'script',
|
|
parserOptions: {
|
|
project: './packages/data-provider/tsconfig.spec.json',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
files: ['./packages/api/specs/**/*.ts'],
|
|
languageOptions: {
|
|
ecmaVersion: 5,
|
|
sourceType: 'script',
|
|
parserOptions: {
|
|
project: './packages/api/tsconfig.spec.json',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// **Data-schemas — shared rules for all TS files**
|
|
files: ['./packages/data-schemas/**/*.ts'],
|
|
languageOptions: {
|
|
parser: tsParser,
|
|
ecmaVersion: 'latest',
|
|
sourceType: 'module',
|
|
parserOptions: {
|
|
project: './packages/data-schemas/tsconfig.json',
|
|
},
|
|
},
|
|
rules: {
|
|
'@typescript-eslint/no-unused-vars': [
|
|
'warn',
|
|
{
|
|
argsIgnorePattern: '^_',
|
|
varsIgnorePattern: '^_',
|
|
caughtErrorsIgnorePattern: '^_',
|
|
destructuredArrayIgnorePattern: '^_',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
// **Data-schemas — ban raw bulkWrite/collection.* in production code**
|
|
// Tests and the tenantSafeBulkWrite wrapper itself are excluded.
|
|
files: ['./packages/data-schemas/**/*.ts'],
|
|
ignores: ['**/*.spec.ts', '**/*.test.ts', '**/utils/tenantBulkWrite.ts'],
|
|
rules: {
|
|
'no-restricted-syntax': [
|
|
'error',
|
|
{
|
|
selector: "CallExpression[callee.property.name='bulkWrite']",
|
|
message:
|
|
'Use tenantSafeBulkWrite() instead of Model.bulkWrite() — Mongoose middleware does not fire for bulkWrite, so the tenant isolation plugin cannot intercept it.',
|
|
},
|
|
{
|
|
selector: "MemberExpression[property.name='collection'][parent.type='MemberExpression']",
|
|
message:
|
|
'Avoid Model.collection.* — raw driver calls bypass all Mongoose middleware including tenant isolation. Use Mongoose model methods or tenantSafeBulkWrite() instead.',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
];
|