@lenne.tech/nest-server 11.28.0 → 11.29.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.
@@ -275,6 +275,14 @@ interface SocialProviderConfig {
275
275
  interface UserFieldConfig {
276
276
  defaultValue?: unknown;
277
277
  fieldName?: string;
278
+ /**
279
+ * Whether a client may supply this field's value via Better-Auth's native input parsing
280
+ * (sign-up create / update-user). When `false`, Better-Auth rejects client-supplied values
281
+ * (throws FIELD_NOT_ALLOWED on update, silently substitutes the server default on create).
282
+ * Defaults to `true` in Better-Auth when omitted. Used to lock server-managed fields
283
+ * (e.g. `roles`) so authenticated users cannot self-set them.
284
+ */
285
+ input?: boolean;
278
286
  required?: boolean;
279
287
  type: BetterAuthFieldType;
280
288
  }
@@ -808,6 +816,28 @@ export function buildTrustedOrigins(
808
816
  return undefined;
809
817
  }
810
818
 
819
+ /**
820
+ * Server-managed, security-critical user fields whose `input: false` MUST be re-asserted after
821
+ * merging project-supplied `additionalUserFields`, so a project override cannot silently re-open
822
+ * a protected key. Each of these, if client-settable, is a concrete vulnerability:
823
+ *
824
+ * - `roles` → vertical privilege escalation (self-granting `admin`)
825
+ * - `verified` → email-verification bypass
826
+ * - `verifiedAt` → email-verification bypass
827
+ * - `twoFactorEnabled` → self-toggling the 2FA state
828
+ * - `iamId` → identity / account-linking hijack
829
+ *
830
+ * Note: `termsAndPrivacyAcceptedAt` is server-managed by default (input:false above) but is
831
+ * intentionally NOT hard-locked here — a project may legitimately choose to accept a client-set
832
+ * consent timestamp; it is not a privilege boundary.
833
+ *
834
+ * These protections only concern Better-Auth's native input parsing (sign-up / update-user).
835
+ * nest-server's own role assignment (UserService/CrudService `checkRoles` + Mongoose writes,
836
+ * `setRoles`, and the user mapper's native `$set` writes) does NOT use Better-Auth input parsing
837
+ * and is therefore unaffected by `input: false`.
838
+ */
839
+ const PROTECTED_INPUT_FALSE_KEYS = ['iamId', 'roles', 'twoFactorEnabled', 'verified', 'verifiedAt'] as const;
840
+
811
841
  /**
812
842
  * Builds the user additional fields configuration.
813
843
  * Merges core fields (firstName, lastName, etc.) with custom fields from config.
@@ -824,6 +854,9 @@ function buildUserFields(config: IBetterAuth): Record<string, UserFieldConfig> {
824
854
  iamId: {
825
855
  defaultValue: null,
826
856
  fieldName: 'iamId',
857
+ // Server-managed: linked to the Better-Auth identity by the user mapper (native DB write),
858
+ // never from client input. input:false blocks identity/account-linking hijack.
859
+ input: false,
827
860
  type: 'string',
828
861
  },
829
862
  lastName: {
@@ -834,45 +867,83 @@ function buildUserFields(config: IBetterAuth): Record<string, UserFieldConfig> {
834
867
  roles: {
835
868
  defaultValue: [],
836
869
  fieldName: 'roles',
870
+ // Server-managed: assigned via UserService/CrudService (checkRoles guard) + setRoles,
871
+ // never from client input. input:false blocks vertical privilege escalation
872
+ // (e.g. a self-registered user POSTing {"roles":["admin"]} to /iam/update-user).
873
+ input: false,
837
874
  type: 'string[]',
838
875
  },
839
876
  // Track when terms and privacy policy were accepted (for sign-up checks)
840
877
  termsAndPrivacyAcceptedAt: {
841
878
  defaultValue: null,
842
879
  fieldName: 'termsAndPrivacyAcceptedAt',
880
+ // Server-managed consent timestamp: set by the sign-up flow, not client input.
881
+ input: false,
843
882
  type: 'date',
844
883
  },
845
884
  twoFactorEnabled: {
846
885
  defaultValue: false,
847
886
  fieldName: 'twoFactorEnabled',
887
+ // Server-managed: toggled by the 2FA enable/disable flow, never from client input.
888
+ // input:false blocks self-toggling the 2FA state.
889
+ input: false,
848
890
  type: 'boolean',
849
891
  },
850
892
  verified: {
851
893
  defaultValue: false,
852
894
  fieldName: 'verified',
895
+ // Server-managed: synced from Better-Auth email verification, never from client input.
896
+ // input:false blocks email-verification bypass.
897
+ input: false,
853
898
  type: 'boolean',
854
899
  },
855
900
  // Track when email was verified (synced from Better-Auth)
856
901
  verifiedAt: {
857
902
  defaultValue: null,
858
903
  fieldName: 'verifiedAt',
904
+ // Server-managed: synced from Better-Auth email verification, never from client input.
905
+ // input:false blocks email-verification bypass.
906
+ input: false,
859
907
  type: 'date',
860
908
  },
861
909
  };
862
910
 
863
911
  // Merge with custom additional fields from configuration
864
- // Custom fields can override core fields or add new ones
912
+ // Custom fields can override core fields or add new ones.
913
+ // The `input` flag is carried through so projects can mark their own fields as server-managed.
865
914
  if (config.additionalUserFields) {
866
915
  for (const [key, field] of Object.entries(config.additionalUserFields)) {
867
916
  coreFields[key] = {
868
917
  defaultValue: field.defaultValue,
869
918
  fieldName: field.fieldName || key,
919
+ input: field.input,
870
920
  required: field.required,
871
921
  type: field.type,
872
922
  };
873
923
  }
874
924
  }
875
925
 
926
+ // Security: re-assert input:false on server-managed, security-critical fields AFTER the merge,
927
+ // so a project-supplied additionalUserFields override cannot silently re-open a protected key
928
+ // (privilege escalation, email-verification bypass, 2FA bypass, identity hijack).
929
+ for (const key of PROTECTED_INPUT_FALSE_KEYS) {
930
+ if (coreFields[key]) {
931
+ coreFields[key].input = false;
932
+ }
933
+ }
934
+
935
+ // Security (defense-in-depth): the loop above keys on the well-known object key. A project could
936
+ // still reopen a protected COLUMN by registering a shadow field under a different key that maps to
937
+ // it, e.g. `additionalUserFields: { customRoles: { fieldName: 'roles', input: true } }`. Lock any
938
+ // such field too, so no additionalUserFields entry — regardless of its key — can feed client input
939
+ // into a protected column.
940
+ const protectedColumns = new Set<string>(PROTECTED_INPUT_FALSE_KEYS);
941
+ for (const [key, field] of Object.entries(coreFields)) {
942
+ if (!protectedColumns.has(key) && field.fieldName && protectedColumns.has(field.fieldName)) {
943
+ field.input = false;
944
+ }
945
+ }
946
+
876
947
  return coreFields;
877
948
  }
878
949
 
@@ -1,5 +1,5 @@
1
1
  import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
2
- import * as ejs from 'ejs';
2
+ import ejs = require('ejs');
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
 
@@ -53,6 +53,19 @@ export abstract class CoreUserService<
53
53
  serviceOptions = prepareServiceOptionsForCreate(serviceOptions);
54
54
  return this.process(
55
55
  async (data) => {
56
+ // Application-level email-uniqueness check. The unique index alone is NOT
57
+ // sufficient: on a FRESH database Mongoose builds indexes asynchronously
58
+ // (autoIndex), so an early duplicate sign-up can slip through before the
59
+ // index exists — observed as a load-dependent e2e flake, and the same
60
+ // window exists on a freshly deployed production database. The index
61
+ // remains the backstop for truly concurrent duplicate requests.
62
+ if (data.input?.email) {
63
+ const existing = await this.mainDbModel.findOne({ email: data.input.email }).lean().exec();
64
+ if (existing) {
65
+ throw new BadRequestException('Email address already in use');
66
+ }
67
+ }
68
+
56
69
  // Create user with verification token
57
70
  const currentUserId = serviceOptions?.currentUser?._id;
58
71
  const createdUser = new this.mainDbModel({
@@ -66,7 +79,7 @@ export abstract class CoreUserService<
66
79
  try {
67
80
  await createdUser.save();
68
81
  } catch (error) {
69
- if (error?.errors?.email?.kind === 'unique') {
82
+ if (error?.errors?.email?.kind === 'unique' || error?.code === 11000) {
70
83
  throw new BadRequestException('Email address already in use');
71
84
  } else {
72
85
  throw new UnprocessableEntityException();