@lenne.tech/nest-server 11.15.3 → 11.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.15.3",
3
+ "version": "11.16.0",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -178,7 +178,7 @@
178
178
  "packageManager": "pnpm@10.29.2",
179
179
  "pnpm": {
180
180
  "overrides": {
181
- "lodash": "4.17.23"
181
+ "qs": "6.14.2"
182
182
  },
183
183
  "onlyBuiltDependencies": [
184
184
  "bcrypt",
@@ -1,7 +1,7 @@
1
- import { Field, FieldOptions } from '@nestjs/graphql';
1
+ import { Field, FieldOptions, HideField } from '@nestjs/graphql';
2
2
  import { TypeMetadataStorage } from '@nestjs/graphql/dist/schema-builder/storages/type-metadata.storage';
3
3
  import { Prop, PropOptions } from '@nestjs/mongoose';
4
- import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
4
+ import { ApiHideProperty, ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
5
5
  import { EnumAllowedTypes } from '@nestjs/swagger/dist/interfaces/schema-object-metadata.interface';
6
6
  import { Type } from 'class-transformer';
7
7
  import {
@@ -34,9 +34,89 @@ export const nestedTypeRegistry = new Map<string, any>();
34
34
  */
35
35
  export const enumNameRegistry = new Map<any, string>();
36
36
 
37
+ // Metadata keys for input whitelist tracking
38
+ export const UNIFIED_FIELD_KEYS = Symbol('UNIFIED_FIELD_KEYS');
39
+ export const EXCLUDED_FIELD_KEYS = Symbol('EXCLUDED_FIELD_KEYS');
40
+ export const FORCE_INCLUDED_FIELD_KEYS = Symbol('FORCE_INCLUDED_FIELD_KEYS');
41
+
42
+ /**
43
+ * Get whitelisted @UnifiedField keys for a class, respecting inheritance.
44
+ *
45
+ * Priority model (3 levels):
46
+ * 1. Explicit settings (exclude: true / exclude: false) — resolved by nearest class (child wins)
47
+ * 2. Implicit includes (@UnifiedField without exclude) — overridden by any explicit exclude: true
48
+ *
49
+ * Rules:
50
+ * - exclude: true → explicit exclusion, wins over implicit @UnifiedField from anywhere in chain
51
+ * - exclude: false → explicit re-enable, can override parent's exclude: true
52
+ * - @UnifiedField (no exclude) → implicit inclusion, CANNOT override parent's exclude: true
53
+ * - Among explicit settings, child class wins (first encountered while walking up the chain)
54
+ */
55
+ export function getUnifiedFieldKeys(metatype: Function): Set<string> {
56
+ const cacheKey = Symbol.for('UNIFIED_FIELD_KEYS_RESOLVED');
57
+ const cached = Reflect.getOwnMetadata(cacheKey, metatype);
58
+ if (cached) return cached;
59
+
60
+ const implicitIncludes = new Set<string>();
61
+ const explicitExcludes = new Set<string>();
62
+ const explicitIncludes = new Set<string>();
63
+ const explicitlyResolved = new Set<string>();
64
+
65
+ // Walk from child (most specific) to parent
66
+ let current = metatype;
67
+ while (current && current !== Object && current !== Function && current.prototype) {
68
+ const included: string[] = Reflect.getOwnMetadata(UNIFIED_FIELD_KEYS, current.prototype) || [];
69
+ const excluded: string[] = Reflect.getOwnMetadata(EXCLUDED_FIELD_KEYS, current.prototype) || [];
70
+ const forceIncluded: string[] = Reflect.getOwnMetadata(FORCE_INCLUDED_FIELD_KEYS, current.prototype) || [];
71
+
72
+ for (const key of forceIncluded) {
73
+ if (!explicitlyResolved.has(key)) {
74
+ explicitIncludes.add(key);
75
+ explicitlyResolved.add(key);
76
+ }
77
+ }
78
+
79
+ for (const key of excluded) {
80
+ if (!explicitlyResolved.has(key)) {
81
+ explicitExcludes.add(key);
82
+ explicitlyResolved.add(key);
83
+ }
84
+ }
85
+
86
+ for (const key of included) {
87
+ implicitIncludes.add(key);
88
+ }
89
+
90
+ current = Object.getPrototypeOf(current);
91
+ }
92
+
93
+ // Build final set: start with implicit includes
94
+ const result = new Set(implicitIncludes);
95
+
96
+ // Add explicit includes (force re-enabled keys)
97
+ for (const key of explicitIncludes) {
98
+ result.add(key);
99
+ }
100
+
101
+ // Remove explicit excludes
102
+ for (const key of explicitExcludes) {
103
+ result.delete(key);
104
+ }
105
+
106
+ Reflect.defineMetadata(cacheKey, result, metatype);
107
+ return result;
108
+ }
109
+
37
110
  export interface UnifiedFieldOptions {
38
111
  /** Description used for both Swagger & Gql */
39
112
  description?: string;
113
+ /**
114
+ * Control input whitelist behavior:
115
+ * - true: Exclude this property (reject via REST/GraphQL, no decorators applied)
116
+ * - false: Explicitly re-enable (can override parent's exclude: true)
117
+ * - undefined (default): Normal @UnifiedField behavior
118
+ */
119
+ exclude?: boolean;
40
120
  /** Enum for class-validator */
41
121
  enum?: { enum: EnumAllowedTypes; enumName?: string; options?: ValidationOptions };
42
122
  /** Example value for swagger api documentation */
@@ -83,6 +163,56 @@ export interface UnifiedFieldOptions {
83
163
 
84
164
  export function UnifiedField(opts: UnifiedFieldOptions = {}): PropertyDecorator {
85
165
  return (target: any, propertyKey: string | symbol) => {
166
+ const key = String(propertyKey);
167
+
168
+ if (opts.exclude === true) {
169
+ // ── EXPLICIT EXCLUSION ──
170
+ const excludedKeys: string[] = Reflect.getOwnMetadata(EXCLUDED_FIELD_KEYS, target) || [];
171
+ if (!excludedKeys.includes(key)) {
172
+ Reflect.defineMetadata(EXCLUDED_FIELD_KEYS, [...excludedKeys, key], target);
173
+ }
174
+ HideField()(target, propertyKey);
175
+ ApiHideProperty()(target, propertyKey);
176
+ return;
177
+ } else if (opts.exclude === false) {
178
+ // ── EXPLICIT RE-ENABLE ──
179
+ const forceKeys: string[] = Reflect.getOwnMetadata(FORCE_INCLUDED_FIELD_KEYS, target) || [];
180
+ if (!forceKeys.includes(key)) {
181
+ Reflect.defineMetadata(FORCE_INCLUDED_FIELD_KEYS, [...forceKeys, key], target);
182
+ }
183
+ // Fall through to register in UNIFIED_FIELD_KEYS AND apply normal decorators
184
+ } else {
185
+ // ── IMPLICIT INCLUSION ──
186
+ // Check if any parent has exclude: true for this key
187
+ let parentExcluded = false;
188
+ let proto = Object.getPrototypeOf(target.constructor);
189
+ while (proto && proto !== Object && proto !== Function && proto.prototype) {
190
+ const excluded: string[] = Reflect.getOwnMetadata(EXCLUDED_FIELD_KEYS, proto.prototype) || [];
191
+ if (excluded.includes(key)) {
192
+ parentExcluded = true;
193
+ break;
194
+ }
195
+ proto = Object.getPrototypeOf(proto);
196
+ }
197
+
198
+ if (parentExcluded) {
199
+ // Parent has exclude: true — register for tracking but skip schema decorators
200
+ const existingKeys: string[] = Reflect.getOwnMetadata(UNIFIED_FIELD_KEYS, target) || [];
201
+ if (!existingKeys.includes(key)) {
202
+ Reflect.defineMetadata(UNIFIED_FIELD_KEYS, [...existingKeys, key], target);
203
+ }
204
+ HideField()(target, propertyKey);
205
+ ApiHideProperty()(target, propertyKey);
206
+ return;
207
+ }
208
+ }
209
+
210
+ // Register in implicit includes (for both exclude: false and exclude: undefined without parent exclusion)
211
+ const existingKeys: string[] = Reflect.getOwnMetadata(UNIFIED_FIELD_KEYS, target) || [];
212
+ if (!existingKeys.includes(key)) {
213
+ Reflect.defineMetadata(UNIFIED_FIELD_KEYS, [...existingKeys, key], target);
214
+ }
215
+
86
216
  const metadataType = Reflect.getMetadata('design:type', target, propertyKey);
87
217
  const userType = opts.type;
88
218
  const isArrayField = opts.isArray === true || metadataType === Array;
@@ -1407,7 +1407,22 @@ export interface IServerOptions {
1407
1407
  * See @lenne.tech/nest-server/src/core/common/pipes/map-and-validate.pipe.ts
1408
1408
  * default = true
1409
1409
  */
1410
- mapAndValidatePipe?: boolean;
1410
+ mapAndValidatePipe?:
1411
+ | boolean
1412
+ | {
1413
+ /**
1414
+ * How to handle input properties not decorated with @UnifiedField.
1415
+ * Applies recursively to nested objects.
1416
+ * Only applies to classes with at least one @UnifiedField.
1417
+ *
1418
+ * - 'strip' (default): Silently remove non-whitelisted properties
1419
+ * - 'error': Throw BadRequestException listing the forbidden properties
1420
+ * - false: Disable check entirely (allow all properties)
1421
+ *
1422
+ * @default 'strip'
1423
+ */
1424
+ nonWhitelistedFields?: 'strip' | 'error' | false;
1425
+ };
1411
1426
  };
1412
1427
 
1413
1428
  /**
@@ -25,8 +25,10 @@ import {
25
25
  import { ValidationMetadata } from 'class-validator/types/metadata/ValidationMetadata';
26
26
  import { inspect } from 'util';
27
27
 
28
- import { nestedTypeRegistry } from '../decorators/unified-field.decorator';
28
+ import { getUnifiedFieldKeys, nestedTypeRegistry } from '../decorators/unified-field.decorator';
29
29
  import { isBasicType } from '../helpers/input.helper';
30
+ import { ConfigService } from '../services/config.service';
31
+ import { ErrorCode } from '../../modules/error-code/error-codes';
30
32
 
31
33
  // Debug mode can be enabled via environment variable: DEBUG_VALIDATION=true
32
34
  const DEBUG_VALIDATION = process.env.DEBUG_VALIDATION === 'true';
@@ -615,8 +617,77 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
615
617
  return errors;
616
618
  }
617
619
 
620
+ /**
621
+ * Recursively process non-whitelisted @UnifiedField properties.
622
+ *
623
+ * @param mode - 'strip' removes forbidden keys from plainValue in-place,
624
+ * 'error' collects and returns their paths.
625
+ * @returns Array of forbidden key paths (only relevant in 'error' mode;
626
+ * empty in 'strip' mode since keys are deleted immediately).
627
+ */
628
+ function processNonWhitelistedFields(
629
+ plainValue: Record<string, any>,
630
+ metatype: Function,
631
+ mode: 'strip' | 'error',
632
+ path: string = '',
633
+ ): string[] {
634
+ const whitelistedKeys = getUnifiedFieldKeys(metatype);
635
+ // Skip classes without any @UnifiedField (e.g. raw class-validator usage)
636
+ if (whitelistedKeys.size === 0) return [];
637
+
638
+ const forbiddenKeys: string[] = [];
639
+
640
+ for (const key of Object.keys(plainValue)) {
641
+ const fullPath = path ? `${path}.${key}` : key;
642
+
643
+ if (!whitelistedKeys.has(key)) {
644
+ if (mode === 'strip') {
645
+ delete plainValue[key];
646
+ } else {
647
+ forbiddenKeys.push(fullPath);
648
+ }
649
+ continue;
650
+ }
651
+
652
+ // Recurse into nested objects using nestedTypeRegistry
653
+ if (plainValue[key] && typeof plainValue[key] === 'object') {
654
+ let nestedType: Function | undefined;
655
+ let current = metatype;
656
+ while (current && current !== Object) {
657
+ nestedType = nestedTypeRegistry.get(`${current.name}.${key}`);
658
+ if (nestedType) break;
659
+ current = Object.getPrototypeOf(current);
660
+ }
661
+
662
+ if (nestedType) {
663
+ if (Array.isArray(plainValue[key])) {
664
+ for (let i = 0; i < plainValue[key].length; i++) {
665
+ const item = plainValue[key][i];
666
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
667
+ forbiddenKeys.push(...processNonWhitelistedFields(item, nestedType, mode, `${fullPath}[${i}]`));
668
+ }
669
+ }
670
+ } else {
671
+ forbiddenKeys.push(...processNonWhitelistedFields(plainValue[key], nestedType, mode, fullPath));
672
+ }
673
+ }
674
+ }
675
+ }
676
+
677
+ return forbiddenKeys;
678
+ }
679
+
618
680
  @Injectable()
619
681
  export class MapAndValidatePipe implements PipeTransform {
682
+ private readonly nonWhitelistedFieldsMode: 'strip' | 'error' | false = 'strip';
683
+
684
+ constructor() {
685
+ const pipeConfig = ConfigService.getFastButReadOnly('security.mapAndValidatePipe');
686
+ if (typeof pipeConfig === 'object' && pipeConfig?.nonWhitelistedFields !== undefined) {
687
+ this.nonWhitelistedFieldsMode = pipeConfig.nonWhitelistedFields;
688
+ }
689
+ }
690
+
620
691
  async transform(value: any, metadata: ArgumentMetadata) {
621
692
  const { metatype } = metadata;
622
693
 
@@ -667,6 +738,38 @@ export class MapAndValidatePipe implements PipeTransform {
667
738
  }
668
739
  }
669
740
 
741
+ // Whitelist check: handle properties not decorated with @UnifiedField
742
+ if (this.nonWhitelistedFieldsMode && metatype && originalPlainKeys.length > 0) {
743
+ const plainObj = originalPlainValue || value;
744
+
745
+ if (this.nonWhitelistedFieldsMode === 'strip') {
746
+ processNonWhitelistedFields(plainObj, metatype, 'strip');
747
+
748
+ if (DEBUG_VALIDATION) {
749
+ console.debug('Stripped non-whitelisted properties. Remaining keys:', Object.keys(plainObj));
750
+ }
751
+
752
+ // Re-transform after stripping so the instance matches the cleaned plain object
753
+ if (originalPlainValue) {
754
+ originalPlainKeys = Object.keys(originalPlainValue);
755
+ value = plainToInstance(metatype, originalPlainValue, {
756
+ enableImplicitConversion: false,
757
+ excludeExtraneousValues: false,
758
+ exposeDefaultValues: false,
759
+ exposeUnsetFields: false,
760
+ });
761
+ }
762
+ } else if (this.nonWhitelistedFieldsMode === 'error') {
763
+ const forbiddenKeys = processNonWhitelistedFields(plainObj, metatype, 'error');
764
+ if (forbiddenKeys.length > 0) {
765
+ if (DEBUG_VALIDATION) {
766
+ console.debug(`Forbidden non-whitelisted properties: ${forbiddenKeys.join(', ')}`);
767
+ }
768
+ throw new BadRequestException(`${ErrorCode.NON_WHITELISTED_PROPERTIES} [${forbiddenKeys.join(', ')}]`);
769
+ }
770
+ }
771
+ }
772
+
670
773
  // Validate with inheritance (checks all parent classes in the prototype chain)
671
774
  if (DEBUG_VALIDATION) {
672
775
  console.debug('Starting validation with inheritance');
@@ -370,6 +370,15 @@ export const LtnsErrors = {
370
370
  },
371
371
  },
372
372
 
373
+ NON_WHITELISTED_PROPERTIES: {
374
+ code: 'LTNS_0303',
375
+ message: 'Non-whitelisted properties found',
376
+ translations: {
377
+ de: 'Die folgenden Eigenschaften sind nicht erlaubt: {{properties}}. Nur mit @UnifiedField dekorierte Eigenschaften werden akzeptiert.',
378
+ en: 'The following properties are not allowed: {{properties}}. Only properties decorated with @UnifiedField are accepted.',
379
+ },
380
+ },
381
+
373
382
  // =====================================================
374
383
  // Resource Errors (LTNS_0400-LTNS_0499)
375
384
  // =====================================================
@@ -1,7 +1,7 @@
1
- import { Field, InputType } from '@nestjs/graphql';
2
- import { IsOptional } from 'class-validator';
1
+ import { InputType } from '@nestjs/graphql';
3
2
 
4
3
  import { Restricted } from '../../../../core/common/decorators/restricted.decorator';
4
+ import { UnifiedField } from '../../../../core/common/decorators/unified-field.decorator';
5
5
  import { RoleEnum } from '../../../../core/common/enums/role.enum';
6
6
  import { CoreUserCreateInput } from '../../../../core/modules/user/inputs/core-user-create.input';
7
7
 
@@ -12,11 +12,10 @@ import { CoreUserCreateInput } from '../../../../core/modules/user/inputs/core-u
12
12
  @Restricted(RoleEnum.ADMIN)
13
13
  export class UserCreateInput extends CoreUserCreateInput {
14
14
  // Extend UserCreateInput here
15
- @Field(() => String, {
15
+ @UnifiedField({
16
16
  description: 'Job Title of the user',
17
- nullable: true,
17
+ isOptional: true,
18
+ roles: RoleEnum.ADMIN,
18
19
  })
19
- @IsOptional()
20
- @Restricted(RoleEnum.ADMIN)
21
20
  jobTitle?: string = undefined;
22
21
  }
@@ -1,7 +1,7 @@
1
- import { Field, InputType } from '@nestjs/graphql';
2
- import { IsOptional } from 'class-validator';
1
+ import { InputType } from '@nestjs/graphql';
3
2
 
4
3
  import { Restricted } from '../../../../core/common/decorators/restricted.decorator';
4
+ import { UnifiedField } from '../../../../core/common/decorators/unified-field.decorator';
5
5
  import { RoleEnum } from '../../../../core/common/enums/role.enum';
6
6
  import { CoreUserInput } from '../../../../core/modules/user/inputs/core-user.input';
7
7
 
@@ -12,11 +12,10 @@ import { CoreUserInput } from '../../../../core/modules/user/inputs/core-user.in
12
12
  @Restricted(RoleEnum.ADMIN)
13
13
  export class UserInput extends CoreUserInput {
14
14
  // Extend UserInput here
15
- @Field(() => String, {
15
+ @UnifiedField({
16
16
  description: 'Job Title of the user',
17
- nullable: true,
17
+ isOptional: true,
18
+ roles: RoleEnum.ADMIN,
18
19
  })
19
- @IsOptional()
20
- @Restricted(RoleEnum.ADMIN)
21
20
  jobTitle?: string = undefined;
22
21
  }