strapi-plugin-magic-sessionmanager 4.4.4 → 4.4.6

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.
@@ -159,11 +159,19 @@ const getClientIp$1 = (ctx) => {
159
159
  };
160
160
  const cleanIp = (ip) => {
161
161
  if (!ip) return "unknown";
162
- ip = ip.split(":")[0];
162
+ ip = ip.trim();
163
+ if (ip.startsWith("[")) {
164
+ const bracketEnd = ip.indexOf("]");
165
+ if (bracketEnd !== -1) {
166
+ ip = ip.substring(1, bracketEnd);
167
+ }
168
+ }
163
169
  if (ip.startsWith("::ffff:")) {
164
170
  ip = ip.substring(7);
165
171
  }
166
- ip = ip.trim();
172
+ if (ip.includes(".") && ip.includes(":") && ip.indexOf(":") === ip.lastIndexOf(":")) {
173
+ ip = ip.split(":")[0];
174
+ }
167
175
  return ip || "unknown";
168
176
  };
169
177
  const isPrivateIp = (ip) => {
@@ -186,14 +194,15 @@ const IV_LENGTH = 16;
186
194
  function getEncryptionKey() {
187
195
  const envKey = process.env.SESSION_ENCRYPTION_KEY;
188
196
  if (envKey) {
189
- const key2 = crypto$1.createHash("sha256").update(envKey).digest();
190
- return key2;
197
+ return crypto$1.createHash("sha256").update(envKey).digest();
198
+ }
199
+ const strapiKeys = process.env.APP_KEYS || process.env.API_TOKEN_SALT;
200
+ if (!strapiKeys) {
201
+ throw new Error(
202
+ "[magic-sessionmanager] No encryption key available. Set SESSION_ENCRYPTION_KEY in your .env file, or ensure APP_KEYS is configured."
203
+ );
191
204
  }
192
- const strapiKeys = process.env.APP_KEYS || process.env.API_TOKEN_SALT || "default-insecure-key";
193
- const key = crypto$1.createHash("sha256").update(strapiKeys).digest();
194
- console.warn("[magic-sessionmanager/encryption] [WARNING] No SESSION_ENCRYPTION_KEY found. Using fallback (not recommended for production).");
195
- console.warn("[magic-sessionmanager/encryption] Set SESSION_ENCRYPTION_KEY in .env for better security.");
196
- return key;
205
+ return crypto$1.createHash("sha256").update(strapiKeys).digest();
197
206
  }
198
207
  function encryptToken$2(token) {
199
208
  if (!token) return null;
@@ -206,11 +215,12 @@ function encryptToken$2(token) {
206
215
  const authTag = cipher.getAuthTag();
207
216
  return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
208
217
  } catch (err) {
209
- console.error("[magic-sessionmanager/encryption] Encryption failed:", err);
218
+ const errMsg = err instanceof Error ? err.message : "Unknown encryption error";
219
+ console.error("[magic-sessionmanager/encryption] Encryption failed:", errMsg);
210
220
  throw new Error("Failed to encrypt token");
211
221
  }
212
222
  }
213
- function decryptToken$3(encryptedToken) {
223
+ function decryptToken$2(encryptedToken) {
214
224
  if (!encryptedToken) return null;
215
225
  try {
216
226
  const key = getEncryptionKey();
@@ -227,7 +237,8 @@ function decryptToken$3(encryptedToken) {
227
237
  decrypted += decipher.final("utf8");
228
238
  return decrypted;
229
239
  } catch (err) {
230
- console.error("[magic-sessionmanager/encryption] Decryption failed:", err);
240
+ const errMsg = err instanceof Error ? err.message : "Unknown decryption error";
241
+ console.error("[magic-sessionmanager/encryption] Decryption failed:", errMsg);
231
242
  return null;
232
243
  }
233
244
  }
@@ -243,7 +254,7 @@ function hashToken$3(token) {
243
254
  }
244
255
  var encryption = {
245
256
  encryptToken: encryptToken$2,
246
- decryptToken: decryptToken$3,
257
+ decryptToken: decryptToken$2,
247
258
  generateSessionId: generateSessionId$1,
248
259
  hashToken: hashToken$3
249
260
  };
@@ -274,12 +285,25 @@ function isAuthEndpoint(path2) {
274
285
  }
275
286
  const userIdCache = /* @__PURE__ */ new Map();
276
287
  const CACHE_TTL = 5 * 60 * 1e3;
288
+ const CACHE_MAX_SIZE = 1e3;
277
289
  async function getDocumentIdFromNumericId(strapi2, numericId) {
278
290
  const cacheKey = `user_${numericId}`;
279
291
  const cached2 = userIdCache.get(cacheKey);
280
292
  if (cached2 && Date.now() - cached2.timestamp < CACHE_TTL) {
281
293
  return cached2.documentId;
282
294
  }
295
+ if (userIdCache.size >= CACHE_MAX_SIZE) {
296
+ const now = Date.now();
297
+ for (const [key, value] of userIdCache) {
298
+ if (now - value.timestamp >= CACHE_TTL) {
299
+ userIdCache.delete(key);
300
+ }
301
+ }
302
+ if (userIdCache.size >= CACHE_MAX_SIZE) {
303
+ const keysToDelete = [...userIdCache.keys()].slice(0, Math.floor(CACHE_MAX_SIZE / 4));
304
+ keysToDelete.forEach((key) => userIdCache.delete(key));
305
+ }
306
+ }
283
307
  try {
284
308
  const user = await strapi2.entityService.findOne(USER_UID$3, numericId, {
285
309
  fields: ["documentId"]
@@ -322,7 +346,7 @@ var lastSeen = ({ strapi: strapi2, sessionService }) => {
322
346
  isActive: false
323
347
  },
324
348
  limit: 5,
325
- fields: ["documentId", "terminatedManually", "lastActive"],
349
+ fields: ["documentId", "terminatedManually", "lastActive", "loginTime"],
326
350
  sort: [{ lastActive: "desc" }]
327
351
  });
328
352
  if (inactiveSessions && inactiveSessions.length > 0) {
@@ -332,6 +356,14 @@ var lastSeen = ({ strapi: strapi2, sessionService }) => {
332
356
  return ctx.unauthorized("Session has been terminated. Please login again.");
333
357
  }
334
358
  const sessionToReactivate = inactiveSessions[0];
359
+ const maxAgeDays = config2.maxSessionAgeDays || 30;
360
+ const loginTime = sessionToReactivate.loginTime ? new Date(sessionToReactivate.loginTime).getTime() : sessionToReactivate.lastActive ? new Date(sessionToReactivate.lastActive).getTime() : 0;
361
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1e3;
362
+ const isExpired = loginTime > 0 && Date.now() - loginTime > maxAgeMs;
363
+ if (isExpired) {
364
+ strapi2.log.info(`[magic-sessionmanager] [BLOCKED] Session exceeded max age of ${maxAgeDays} days (user: ${userDocId2.substring(0, 8)}...)`);
365
+ return ctx.unauthorized("Session expired. Please login again.");
366
+ }
335
367
  await strapi2.documents(SESSION_UID$4).update({
336
368
  documentId: sessionToReactivate.documentId,
337
369
  data: {
@@ -372,7 +404,7 @@ var lastSeen = ({ strapi: strapi2, sessionService }) => {
372
404
  };
373
405
  };
374
406
  const getClientIp = getClientIp_1;
375
- const { encryptToken: encryptToken$1, decryptToken: decryptToken$2, hashToken: hashToken$2 } = encryption;
407
+ const { encryptToken: encryptToken$1, decryptToken: decryptToken$1, hashToken: hashToken$2 } = encryption;
376
408
  const { createLogger: createLogger$3 } = logger;
377
409
  const SESSION_UID$3 = "plugin::magic-sessionmanager.session";
378
410
  const USER_UID$2 = "plugin::users-permissions.user";
@@ -406,11 +438,11 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
406
438
  log.info("║ [SUCCESS] SESSION MANAGER LICENSE ACTIVE ║");
407
439
  log.info("║ ║");
408
440
  if (licenseStatus.data) {
409
- log.info(`║ License: ${licenseStatus.data.licenseKey}`.padEnd(66) + "");
441
+ const maskedKey = licenseStatus.data.licenseKey ? `${licenseStatus.data.licenseKey.substring(0, 8)}...` : "N/A";
442
+ log.info(`║ License: ${maskedKey}`.padEnd(66) + "║");
410
443
  log.info(`║ User: ${licenseStatus.data.firstName} ${licenseStatus.data.lastName}`.padEnd(66) + "║");
411
- log.info(`║ Email: ${licenseStatus.data.email}`.padEnd(66) + "║");
412
444
  } else if (storedKey) {
413
- log.info(`║ License: ${storedKey} (Offline Mode)`.padEnd(66) + "║");
445
+ log.info(`║ License: ${storedKey.substring(0, 8)}... (Offline Mode)`.padEnd(66) + "║");
414
446
  log.info(`║ Status: Grace Period Active`.padEnd(66) + "║");
415
447
  }
416
448
  log.info("║ ║");
@@ -442,10 +474,21 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
442
474
  try {
443
475
  const token = ctx.request.headers?.authorization?.replace("Bearer ", "");
444
476
  if (!token) {
445
- ctx.status = 200;
446
- ctx.body = { message: "Logged out successfully" };
477
+ ctx.status = 401;
478
+ ctx.body = { error: { status: 401, message: "Authorization token required" } };
447
479
  return;
448
480
  }
481
+ try {
482
+ const jwtService = strapi2.plugin("users-permissions").service("jwt");
483
+ const decoded = await jwtService.verify(token);
484
+ if (!decoded || !decoded.id) {
485
+ ctx.status = 401;
486
+ ctx.body = { error: { status: 401, message: "Invalid token" } };
487
+ return;
488
+ }
489
+ } catch (jwtErr) {
490
+ log.debug("JWT verify failed during logout (cleaning up anyway):", jwtErr.message);
491
+ }
449
492
  const tokenHashValue = hashToken$2(token);
450
493
  const matchingSession = await strapi2.documents(SESSION_UID$3).findFirst({
451
494
  filters: {
@@ -467,6 +510,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
467
510
  },
468
511
  config: {
469
512
  auth: false
513
+ // We handle auth manually above to support expired-but-valid tokens
470
514
  }
471
515
  }]);
472
516
  log.info("[SUCCESS] /api/auth/logout route registered");
@@ -526,8 +570,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
526
570
  ctx.body = {
527
571
  error: {
528
572
  status: 403,
529
- message: "Login blocked for security reasons",
530
- details: { reason: blockReason }
573
+ message: "Login blocked for security reasons. Please contact support."
531
574
  }
532
575
  };
533
576
  return;
@@ -589,11 +632,22 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
589
632
  geoData
590
633
  });
591
634
  if (config2.discordWebhookUrl) {
592
- await notificationService.sendWebhook({
593
- event: "session.login",
594
- data: webhookData,
595
- webhookUrl: config2.discordWebhookUrl
596
- });
635
+ const webhookUrl = config2.discordWebhookUrl;
636
+ try {
637
+ const parsed = new URL(webhookUrl);
638
+ const isValidDomain = parsed.protocol === "https:" && (parsed.hostname === "discord.com" || parsed.hostname === "discordapp.com" || parsed.hostname.endsWith(".discord.com") || parsed.hostname === "hooks.slack.com");
639
+ if (isValidDomain) {
640
+ await notificationService.sendWebhook({
641
+ event: "session.login",
642
+ data: webhookData,
643
+ webhookUrl
644
+ });
645
+ } else {
646
+ log.warn(`[SECURITY] Blocked webhook to untrusted domain: ${parsed.hostname}`);
647
+ }
648
+ } catch {
649
+ log.warn("[SECURITY] Invalid webhook URL in plugin config");
650
+ }
597
651
  }
598
652
  }
599
653
  } catch (notifErr) {
@@ -774,6 +828,30 @@ async function ensureTokenHashIndex(strapi2, log) {
774
828
  log.debug("[INDEX] Could not create tokenHash index (will retry on next startup):", err.message);
775
829
  }
776
830
  }
831
+ const sessionCheckErrors = { count: 0, lastReset: Date.now() };
832
+ const MAX_CONSECUTIVE_ERRORS = 10;
833
+ const ERROR_RESET_INTERVAL = 60 * 1e3;
834
+ function shouldFailOpen() {
835
+ const now = Date.now();
836
+ if (now - sessionCheckErrors.lastReset > ERROR_RESET_INTERVAL) {
837
+ sessionCheckErrors.count = 0;
838
+ sessionCheckErrors.lastReset = now;
839
+ }
840
+ sessionCheckErrors.count++;
841
+ if (sessionCheckErrors.count > MAX_CONSECUTIVE_ERRORS) {
842
+ return false;
843
+ }
844
+ return true;
845
+ }
846
+ function resetErrorCounter() {
847
+ sessionCheckErrors.count = 0;
848
+ }
849
+ function isSessionExpired(session2, maxAgeDays = 30) {
850
+ if (!session2.loginTime) return false;
851
+ const loginTime = new Date(session2.loginTime).getTime();
852
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1e3;
853
+ return Date.now() - loginTime > maxAgeMs;
854
+ }
777
855
  async function registerSessionAwareAuthStrategy(strapi2, log) {
778
856
  try {
779
857
  const usersPermissionsPlugin = strapi2.plugin("users-permissions");
@@ -795,15 +873,15 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
795
873
  }
796
874
  const config2 = strapi2.config.get("plugin::magic-sessionmanager") || {};
797
875
  const strictMode = config2.strictSessionEnforcement === true;
876
+ const maxSessionAgeDays = config2.maxSessionAgeDays || 30;
798
877
  try {
799
878
  const tokenHashValue = hashToken$2(token);
800
- let userDocId = null;
801
879
  const user = await strapi2.entityService.findOne(
802
880
  "plugin::users-permissions.user",
803
881
  decoded.id,
804
882
  { fields: ["documentId"] }
805
883
  );
806
- userDocId = user?.documentId;
884
+ const userDocId = user?.documentId;
807
885
  if (!userDocId) {
808
886
  strapi2.log.debug("[magic-sessionmanager] [JWT] No documentId found, allowing through");
809
887
  return decoded;
@@ -813,9 +891,19 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
813
891
  user: { documentId: userDocId },
814
892
  tokenHash: tokenHashValue
815
893
  },
816
- fields: ["documentId", "isActive", "terminatedManually", "lastActive"]
894
+ fields: ["documentId", "isActive", "terminatedManually", "lastActive", "loginTime"]
817
895
  });
818
896
  if (thisSession) {
897
+ if (isSessionExpired(thisSession, maxSessionAgeDays)) {
898
+ strapi2.log.info(
899
+ `[magic-sessionmanager] [JWT-EXPIRED] Session exceeded max age of ${maxSessionAgeDays} days (user: ${userDocId.substring(0, 8)}...)`
900
+ );
901
+ await strapi2.documents(SESSION_UID$3).update({
902
+ documentId: thisSession.documentId,
903
+ data: { isActive: false, terminatedManually: true, logoutTime: /* @__PURE__ */ new Date() }
904
+ });
905
+ return null;
906
+ }
819
907
  if (thisSession.terminatedManually === true) {
820
908
  strapi2.log.info(
821
909
  `[magic-sessionmanager] [JWT-BLOCKED] Session was manually terminated (user: ${userDocId.substring(0, 8)}...)`
@@ -823,38 +911,32 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
823
911
  return null;
824
912
  }
825
913
  if (thisSession.isActive) {
914
+ resetErrorCounter();
826
915
  return decoded;
827
916
  }
828
917
  await strapi2.documents(SESSION_UID$3).update({
829
918
  documentId: thisSession.documentId,
830
- data: {
831
- isActive: true,
832
- lastActive: /* @__PURE__ */ new Date()
833
- }
919
+ data: { isActive: true, lastActive: /* @__PURE__ */ new Date() }
834
920
  });
835
921
  strapi2.log.info(
836
922
  `[magic-sessionmanager] [JWT-REACTIVATED] Session reactivated for user ${userDocId.substring(0, 8)}...`
837
923
  );
924
+ resetErrorCounter();
838
925
  return decoded;
839
926
  }
840
927
  const anyActiveSessions = await strapi2.documents(SESSION_UID$3).findMany({
841
- filters: {
842
- user: { documentId: userDocId },
843
- isActive: true
844
- },
928
+ filters: { user: { documentId: userDocId }, isActive: true },
845
929
  limit: 1
846
930
  });
847
931
  if (anyActiveSessions && anyActiveSessions.length > 0) {
848
932
  strapi2.log.debug(
849
933
  `[magic-sessionmanager] [JWT] No session for token but user has other active sessions (allowing)`
850
934
  );
935
+ resetErrorCounter();
851
936
  return decoded;
852
937
  }
853
938
  const terminatedSessions = await strapi2.documents(SESSION_UID$3).findMany({
854
- filters: {
855
- user: { documentId: userDocId },
856
- terminatedManually: true
857
- },
939
+ filters: { user: { documentId: userDocId }, terminatedManually: true },
858
940
  limit: 1
859
941
  });
860
942
  if (terminatedSessions && terminatedSessions.length > 0) {
@@ -872,10 +954,15 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
872
954
  strapi2.log.warn(
873
955
  `[magic-sessionmanager] [JWT-WARN] No session for user ${userDocId.substring(0, 8)}... (allowing)`
874
956
  );
957
+ resetErrorCounter();
875
958
  return decoded;
876
959
  } catch (err) {
877
- strapi2.log.warn("[magic-sessionmanager] [JWT] Session check error (allowing):", err.message);
878
- return decoded;
960
+ if (shouldFailOpen()) {
961
+ strapi2.log.warn("[magic-sessionmanager] [JWT] Session check error (allowing):", err.message);
962
+ return decoded;
963
+ }
964
+ strapi2.log.error("[magic-sessionmanager] [JWT] Too many consecutive errors, blocking request:", err.message);
965
+ return null;
879
966
  }
880
967
  };
881
968
  strapi2.log.info("[magic-sessionmanager] [AUTH] [SUCCESS] JWT verify wrapped with session validation");
@@ -1185,7 +1272,7 @@ var admin$1 = {
1185
1272
  path: "/license/status",
1186
1273
  handler: "license.getStatus",
1187
1274
  config: {
1188
- policies: []
1275
+ policies: ["admin::isAuthenticatedAdmin"]
1189
1276
  }
1190
1277
  },
1191
1278
  {
@@ -1193,7 +1280,7 @@ var admin$1 = {
1193
1280
  path: "/license/auto-create",
1194
1281
  handler: "license.autoCreate",
1195
1282
  config: {
1196
- policies: []
1283
+ policies: ["admin::isAuthenticatedAdmin"]
1197
1284
  }
1198
1285
  },
1199
1286
  {
@@ -1201,7 +1288,7 @@ var admin$1 = {
1201
1288
  path: "/license/create",
1202
1289
  handler: "license.createAndActivate",
1203
1290
  config: {
1204
- policies: []
1291
+ policies: ["admin::isAuthenticatedAdmin"]
1205
1292
  }
1206
1293
  },
1207
1294
  {
@@ -1209,7 +1296,7 @@ var admin$1 = {
1209
1296
  path: "/license/ping",
1210
1297
  handler: "license.ping",
1211
1298
  config: {
1212
- policies: []
1299
+ policies: ["admin::isAuthenticatedAdmin"]
1213
1300
  }
1214
1301
  },
1215
1302
  {
@@ -1217,7 +1304,7 @@ var admin$1 = {
1217
1304
  path: "/license/store-key",
1218
1305
  handler: "license.storeKey",
1219
1306
  config: {
1220
- policies: []
1307
+ policies: ["admin::isAuthenticatedAdmin"]
1221
1308
  }
1222
1309
  },
1223
1310
  // Geolocation (Premium Feature)
@@ -1340,7 +1427,7 @@ function extractVersion(userAgent, regex) {
1340
1427
  var userAgentParser = {
1341
1428
  parseUserAgent: parseUserAgent$2
1342
1429
  };
1343
- const { decryptToken: decryptToken$1, hashToken: hashToken$1 } = encryption;
1430
+ const { hashToken: hashToken$1 } = encryption;
1344
1431
  const { parseUserAgent: parseUserAgent$1 } = userAgentParser;
1345
1432
  const SESSION_UID$2 = "plugin::magic-sessionmanager.session";
1346
1433
  const USER_UID$1 = "plugin::users-permissions.user";
@@ -1400,7 +1487,8 @@ var session$3 = {
1400
1487
  }
1401
1488
  const allSessions = await strapi.documents(SESSION_UID$2).findMany({
1402
1489
  filters: { user: { documentId: userId } },
1403
- sort: { loginTime: "desc" }
1490
+ sort: { loginTime: "desc" },
1491
+ limit: 200
1404
1492
  });
1405
1493
  const config2 = strapi.config.get("plugin::magic-sessionmanager") || {};
1406
1494
  const inactivityTimeout = config2.inactivityTimeout || 15 * 60 * 1e3;
@@ -1438,7 +1526,7 @@ var session$3 = {
1438
1526
  strapi.documents(SESSION_UID$2).update({
1439
1527
  documentId: session2.documentId,
1440
1528
  data: {
1441
- geoLocation: JSON.stringify(geoLocation),
1529
+ geoLocation,
1442
1530
  securityScore: geoData.securityScore || null
1443
1531
  }
1444
1532
  }).catch(() => {
@@ -1500,9 +1588,15 @@ var session$3 = {
1500
1588
  const { userId } = ctx.params;
1501
1589
  const isAdminRequest = ctx.state.userAbility || ctx.state.admin;
1502
1590
  const requestingUserDocId = ctx.state.user?.documentId;
1503
- if (!isAdminRequest && requestingUserDocId && String(requestingUserDocId) !== String(userId)) {
1504
- strapi.log.warn(`[magic-sessionmanager] Security: User ${requestingUserDocId} tried to access sessions of user ${userId}`);
1505
- return ctx.forbidden("You can only access your own sessions");
1591
+ if (!isAdminRequest) {
1592
+ if (!requestingUserDocId) {
1593
+ strapi.log.warn(`[magic-sessionmanager] Security: Request without documentId tried to access sessions of user ${userId}`);
1594
+ return ctx.forbidden("Cannot verify user identity");
1595
+ }
1596
+ if (String(requestingUserDocId) !== String(userId)) {
1597
+ strapi.log.warn(`[magic-sessionmanager] Security: User ${requestingUserDocId} tried to access sessions of user ${userId}`);
1598
+ return ctx.forbidden("You can only access your own sessions");
1599
+ }
1506
1600
  }
1507
1601
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
1508
1602
  const sessions = await sessionService.getUserSessions(userId);
@@ -1626,7 +1720,7 @@ var session$3 = {
1626
1720
  strapi.documents(SESSION_UID$2).update({
1627
1721
  documentId: currentSession.documentId,
1628
1722
  data: {
1629
- geoLocation: JSON.stringify(geoLocation),
1723
+ geoLocation,
1630
1724
  securityScore: geoData.securityScore || null
1631
1725
  }
1632
1726
  }).catch(() => {
@@ -1708,22 +1802,6 @@ var session$3 = {
1708
1802
  ctx.throw(500, "Error terminating session");
1709
1803
  }
1710
1804
  },
1711
- /**
1712
- * Terminate specific session
1713
- * DELETE /magic-sessionmanager/sessions/:sessionId
1714
- */
1715
- async terminateSession(ctx) {
1716
- try {
1717
- const { sessionId } = ctx.params;
1718
- const sessionService = strapi.plugin("magic-sessionmanager").service("session");
1719
- await sessionService.terminateSession({ sessionId });
1720
- ctx.body = {
1721
- message: `Session ${sessionId} terminated`
1722
- };
1723
- } catch (err) {
1724
- ctx.throw(500, "Error terminating session");
1725
- }
1726
- },
1727
1805
  /**
1728
1806
  * Simulate session timeout for testing (Admin action)
1729
1807
  * POST /magic-sessionmanager/sessions/:sessionId/simulate-timeout
@@ -1732,6 +1810,10 @@ var session$3 = {
1732
1810
  */
1733
1811
  async simulateTimeout(ctx) {
1734
1812
  try {
1813
+ const nodeEnv = process.env.NODE_ENV || "development";
1814
+ if (nodeEnv === "production") {
1815
+ return ctx.forbidden("simulate-timeout is disabled in production");
1816
+ }
1735
1817
  const { sessionId } = ctx.params;
1736
1818
  const session2 = await strapi.documents(SESSION_UID$2).findOne({
1737
1819
  documentId: sessionId
@@ -1804,6 +1886,17 @@ var session$3 = {
1804
1886
  if (!ipAddress) {
1805
1887
  return ctx.badRequest("IP address is required");
1806
1888
  }
1889
+ const IPV4_REGEX = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
1890
+ const ipv4Match = ipAddress.match(IPV4_REGEX);
1891
+ const isValidIpv4 = ipv4Match && ipv4Match.slice(1).every((octet) => {
1892
+ const n = parseInt(octet, 10);
1893
+ return n >= 0 && n <= 255;
1894
+ });
1895
+ const IPV6_REGEX = /^[0-9a-fA-F:]{3,45}$/;
1896
+ const isValidIpv6 = !isValidIpv4 && IPV6_REGEX.test(ipAddress) && (ipAddress.match(/:/g) || []).length >= 2;
1897
+ if (!isValidIpv4 && !isValidIpv6) {
1898
+ return ctx.badRequest("Invalid IP address format");
1899
+ }
1807
1900
  const licenseGuard2 = strapi.plugin("magic-sessionmanager").service("license-guard");
1808
1901
  const pluginStore = strapi.store({
1809
1902
  type: "plugin",
@@ -1963,12 +2056,11 @@ var license$1 = ({ strapi: strapi2 }) => ({
1963
2056
  message: "No license found. Running in demo mode."
1964
2057
  });
1965
2058
  }
1966
- strapi2.log.info(`[magic-sessionmanager/license-controller] Checking stored license: ${licenseKey}`);
2059
+ strapi2.log.info(`[magic-sessionmanager/license-controller] Checking stored license: ${licenseKey.substring(0, 8)}...`);
1967
2060
  const verification = await licenseGuard2.verifyLicense(licenseKey);
1968
2061
  const license2 = await licenseGuard2.getLicenseByKey(licenseKey);
1969
2062
  strapi2.log.info("[magic-sessionmanager/license-controller] License data from MagicAPI:", {
1970
- licenseKey: license2?.licenseKey,
1971
- email: license2?.email,
2063
+ licenseKey: license2?.licenseKey ? `${license2.licenseKey.substring(0, 8)}...` : "N/A",
1972
2064
  featurePremium: license2?.featurePremium,
1973
2065
  isActive: license2?.isActive,
1974
2066
  pluginName: license2?.pluginName
@@ -2112,6 +2204,55 @@ var license$1 = ({ strapi: strapi2 }) => ({
2112
2204
  }
2113
2205
  }
2114
2206
  });
2207
+ const ALLOWED_WEBHOOK_DOMAINS = {
2208
+ discord: ["discord.com", "discordapp.com"],
2209
+ slack: ["hooks.slack.com"]
2210
+ };
2211
+ function sanitizeWebhookUrl(url, type2) {
2212
+ if (!url || typeof url !== "string") return "";
2213
+ const trimmed = url.trim();
2214
+ if (!trimmed) return "";
2215
+ try {
2216
+ const parsed = new URL(trimmed);
2217
+ if (parsed.protocol !== "https:") return "";
2218
+ const allowedDomains = ALLOWED_WEBHOOK_DOMAINS[type2] || [];
2219
+ const isAllowed = allowedDomains.some(
2220
+ (domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`)
2221
+ );
2222
+ if (!isAllowed) return "";
2223
+ return trimmed;
2224
+ } catch {
2225
+ return "";
2226
+ }
2227
+ }
2228
+ function sanitizeCountryList(list) {
2229
+ if (!Array.isArray(list)) return [];
2230
+ return list.filter((code) => typeof code === "string" && /^[A-Z]{2}$/.test(code.trim().toUpperCase())).map((code) => code.trim().toUpperCase());
2231
+ }
2232
+ function sanitizeEmailTemplates(templates) {
2233
+ const defaults = {
2234
+ suspiciousLogin: { subject: "", html: "", text: "" },
2235
+ newLocation: { subject: "", html: "", text: "" },
2236
+ vpnProxy: { subject: "", html: "", text: "" }
2237
+ };
2238
+ if (!templates || typeof templates !== "object") return defaults;
2239
+ const dangerousTags = /<\s*\/?\s*(script|iframe|object|embed|form|input|button|link|meta|base)\b[^>]*>/gi;
2240
+ const dangerousAttrs = /\s(on\w+|javascript\s*:)[^=]*=/gi;
2241
+ const result = {};
2242
+ for (const [key, defaultVal] of Object.entries(defaults)) {
2243
+ const tpl = templates[key];
2244
+ if (!tpl || typeof tpl !== "object") {
2245
+ result[key] = defaultVal;
2246
+ continue;
2247
+ }
2248
+ result[key] = {
2249
+ subject: typeof tpl.subject === "string" ? tpl.subject.substring(0, 200) : "",
2250
+ html: typeof tpl.html === "string" ? tpl.html.replace(dangerousTags, "").replace(dangerousAttrs, " ").substring(0, 1e4) : "",
2251
+ text: typeof tpl.text === "string" ? tpl.text.substring(0, 5e3) : ""
2252
+ };
2253
+ }
2254
+ return result;
2255
+ }
2115
2256
  var settings$1 = {
2116
2257
  /**
2117
2258
  * Get plugin settings
@@ -2173,29 +2314,26 @@ var settings$1 = {
2173
2314
  name: "magic-sessionmanager"
2174
2315
  });
2175
2316
  const sanitizedSettings = {
2176
- inactivityTimeout: parseInt(body.inactivityTimeout) || 15,
2177
- cleanupInterval: parseInt(body.cleanupInterval) || 30,
2178
- lastSeenRateLimit: parseInt(body.lastSeenRateLimit) || 30,
2179
- retentionDays: parseInt(body.retentionDays) || 90,
2317
+ inactivityTimeout: Math.max(1, Math.min(parseInt(body.inactivityTimeout) || 15, 1440)),
2318
+ cleanupInterval: Math.max(5, Math.min(parseInt(body.cleanupInterval) || 30, 1440)),
2319
+ lastSeenRateLimit: Math.max(5, Math.min(parseInt(body.lastSeenRateLimit) || 30, 300)),
2320
+ retentionDays: Math.max(1, Math.min(parseInt(body.retentionDays) || 90, 365)),
2321
+ maxSessionAgeDays: Math.max(1, Math.min(parseInt(body.maxSessionAgeDays) || 30, 365)),
2180
2322
  enableGeolocation: !!body.enableGeolocation,
2181
2323
  enableSecurityScoring: !!body.enableSecurityScoring,
2182
2324
  blockSuspiciousSessions: !!body.blockSuspiciousSessions,
2183
- maxFailedLogins: parseInt(body.maxFailedLogins) || 5,
2325
+ maxFailedLogins: Math.max(1, Math.min(parseInt(body.maxFailedLogins) || 5, 100)),
2184
2326
  enableEmailAlerts: !!body.enableEmailAlerts,
2185
2327
  alertOnSuspiciousLogin: !!body.alertOnSuspiciousLogin,
2186
2328
  alertOnNewLocation: !!body.alertOnNewLocation,
2187
2329
  alertOnVpnProxy: !!body.alertOnVpnProxy,
2188
2330
  enableWebhooks: !!body.enableWebhooks,
2189
- discordWebhookUrl: String(body.discordWebhookUrl || ""),
2190
- slackWebhookUrl: String(body.slackWebhookUrl || ""),
2331
+ discordWebhookUrl: sanitizeWebhookUrl(body.discordWebhookUrl, "discord"),
2332
+ slackWebhookUrl: sanitizeWebhookUrl(body.slackWebhookUrl, "slack"),
2191
2333
  enableGeofencing: !!body.enableGeofencing,
2192
- allowedCountries: Array.isArray(body.allowedCountries) ? body.allowedCountries : [],
2193
- blockedCountries: Array.isArray(body.blockedCountries) ? body.blockedCountries : [],
2194
- emailTemplates: body.emailTemplates || {
2195
- suspiciousLogin: { subject: "", html: "", text: "" },
2196
- newLocation: { subject: "", html: "", text: "" },
2197
- vpnProxy: { subject: "", html: "", text: "" }
2198
- }
2334
+ allowedCountries: sanitizeCountryList(body.allowedCountries),
2335
+ blockedCountries: sanitizeCountryList(body.blockedCountries),
2336
+ emailTemplates: sanitizeEmailTemplates(body.emailTemplates)
2199
2337
  };
2200
2338
  await pluginStore.set({
2201
2339
  key: "settings",
@@ -2266,15 +2404,15 @@ var session$1 = ({ strapi: strapi2 }) => {
2266
2404
  deviceType: parsedUA.deviceType,
2267
2405
  browserName: parsedUA.browserVersion ? `${parsedUA.browserName} ${parsedUA.browserVersion}` : parsedUA.browserName,
2268
2406
  osName: parsedUA.osVersion ? `${parsedUA.osName} ${parsedUA.osVersion}` : parsedUA.osName,
2269
- // Geolocation data (if available from Premium features)
2270
- geoLocation: geoData ? JSON.stringify({
2407
+ // Geolocation data (stored as JSON object, schema type: json)
2408
+ geoLocation: geoData ? {
2271
2409
  country: geoData.country,
2272
2410
  country_code: geoData.country_code,
2273
2411
  country_flag: geoData.country_flag,
2274
2412
  city: geoData.city,
2275
2413
  region: geoData.region,
2276
2414
  timezone: geoData.timezone
2277
- }) : null,
2415
+ } : null,
2278
2416
  securityScore: geoData?.securityScore || null
2279
2417
  }
2280
2418
  });
@@ -2295,6 +2433,14 @@ var session$1 = ({ strapi: strapi2 }) => {
2295
2433
  try {
2296
2434
  const now = /* @__PURE__ */ new Date();
2297
2435
  if (sessionId) {
2436
+ const existing = await strapi2.documents(SESSION_UID$1).findOne({
2437
+ documentId: sessionId,
2438
+ fields: ["documentId"]
2439
+ });
2440
+ if (!existing) {
2441
+ log.warn(`Session ${sessionId} not found for termination`);
2442
+ return;
2443
+ }
2298
2444
  await strapi2.documents(SESSION_UID$1).update({
2299
2445
  documentId: sessionId,
2300
2446
  data: {
@@ -2386,7 +2532,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2386
2532
  strapi2.documents(SESSION_UID$1).update({
2387
2533
  documentId: session2.documentId,
2388
2534
  data: {
2389
- geoLocation: JSON.stringify(geoLocation),
2535
+ geoLocation,
2390
2536
  securityScore: geoData.securityScore || null
2391
2537
  }
2392
2538
  }).catch(() => {
@@ -2436,7 +2582,8 @@ var session$1 = ({ strapi: strapi2 }) => {
2436
2582
  const sessions = await strapi2.documents(SESSION_UID$1).findMany({
2437
2583
  filters: { isActive: true },
2438
2584
  populate: { user: { fields: ["documentId", "email", "username"] } },
2439
- sort: { loginTime: "desc" }
2585
+ sort: { loginTime: "desc" },
2586
+ limit: 1e3
2440
2587
  });
2441
2588
  const config2 = strapi2.config.get("plugin::magic-sessionmanager") || {};
2442
2589
  const inactivityTimeout = config2.inactivityTimeout || 15 * 60 * 1e3;
@@ -2473,7 +2620,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2473
2620
  strapi2.documents(SESSION_UID$1).update({
2474
2621
  documentId: session2.documentId,
2475
2622
  data: {
2476
- geoLocation: JSON.stringify(geoLocation),
2623
+ geoLocation,
2477
2624
  securityScore: geoData.securityScore || null
2478
2625
  }
2479
2626
  }).catch(() => {
@@ -2566,7 +2713,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2566
2713
  strapi2.documents(SESSION_UID$1).update({
2567
2714
  documentId: session2.documentId,
2568
2715
  data: {
2569
- geoLocation: JSON.stringify(geoLocation),
2716
+ geoLocation,
2570
2717
  securityScore: geoData.securityScore || null
2571
2718
  }
2572
2719
  }).catch(() => {
@@ -2703,20 +2850,36 @@ var session$1 = ({ strapi: strapi2 }) => {
2703
2850
  }
2704
2851
  },
2705
2852
  /**
2706
- * Delete all inactive sessions from database
2853
+ * Delete all inactive sessions from database in batches
2707
2854
  * WARNING: This permanently deletes records!
2708
2855
  * @returns {Promise<number>} Number of deleted sessions
2709
2856
  */
2710
2857
  async deleteInactiveSessions() {
2711
2858
  try {
2712
2859
  log.info("[DELETE] Deleting all inactive sessions...");
2713
- const inactiveSessions = await strapi2.documents(SESSION_UID$1).findMany({
2714
- filters: { isActive: false }
2715
- });
2716
2860
  let deletedCount = 0;
2717
- for (const session2 of inactiveSessions) {
2718
- await strapi2.documents(SESSION_UID$1).delete({ documentId: session2.documentId });
2719
- deletedCount++;
2861
+ const BATCH_SIZE = 100;
2862
+ let hasMore = true;
2863
+ while (hasMore) {
2864
+ const batch = await strapi2.documents(SESSION_UID$1).findMany({
2865
+ filters: { isActive: false },
2866
+ fields: ["documentId"],
2867
+ limit: BATCH_SIZE
2868
+ });
2869
+ if (!batch || batch.length === 0) {
2870
+ hasMore = false;
2871
+ break;
2872
+ }
2873
+ const deleteResults = await Promise.allSettled(
2874
+ batch.map(
2875
+ (session2) => strapi2.documents(SESSION_UID$1).delete({ documentId: session2.documentId })
2876
+ )
2877
+ );
2878
+ const batchDeleted = deleteResults.filter((r) => r.status === "fulfilled").length;
2879
+ deletedCount += batchDeleted;
2880
+ if (batch.length < BATCH_SIZE) {
2881
+ hasMore = false;
2882
+ }
2720
2883
  }
2721
2884
  log.info(`[SUCCESS] Deleted ${deletedCount} inactive sessions`);
2722
2885
  return deletedCount;
@@ -2727,7 +2890,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2727
2890
  }
2728
2891
  };
2729
2892
  };
2730
- const version$1 = "4.4.3";
2893
+ const version$1 = "4.4.5";
2731
2894
  const require$$2 = {
2732
2895
  version: version$1
2733
2896
  };
@@ -3001,7 +3164,7 @@ var geolocation$1 = ({ strapi: strapi2 }) => ({
3001
3164
  */
3002
3165
  async getIpInfo(ipAddress) {
3003
3166
  try {
3004
- if (!ipAddress || ipAddress === "127.0.0.1" || ipAddress === "::1" || ipAddress.startsWith("192.168.") || ipAddress.startsWith("10.")) {
3167
+ if (!ipAddress || this.isPrivateIp(ipAddress)) {
3005
3168
  return {
3006
3169
  ip: ipAddress,
3007
3170
  country: "Local Network",
@@ -3104,6 +3267,26 @@ var geolocation$1 = ({ strapi: strapi2 }) => ({
3104
3267
  const codePoints = countryCode.toUpperCase().split("").map((char) => 127397 + char.charCodeAt());
3105
3268
  return String.fromCodePoint(...codePoints);
3106
3269
  },
3270
+ /**
3271
+ * Checks if an IP address is private/local (RFC 1918, RFC 4193, loopback, link-local)
3272
+ * @param {string} ip - IP address to check
3273
+ * @returns {boolean} True if IP is private/local
3274
+ */
3275
+ isPrivateIp(ip) {
3276
+ if (!ip || ip === "unknown") return true;
3277
+ if (ip === "127.0.0.1" || ip === "localhost" || ip === "::1") return true;
3278
+ if (ip.startsWith("192.168.")) return true;
3279
+ if (ip.startsWith("10.")) return true;
3280
+ if (ip.startsWith("172.")) {
3281
+ const second = parseInt(ip.split(".")[1], 10);
3282
+ if (second >= 16 && second <= 31) return true;
3283
+ }
3284
+ if (ip.startsWith("169.254.")) return true;
3285
+ if (ip.startsWith("fc00:") || ip.startsWith("fd00:")) return true;
3286
+ if (ip.startsWith("fe80:")) return true;
3287
+ if (ip === "::1") return true;
3288
+ return false;
3289
+ },
3107
3290
  /**
3108
3291
  * Fallback data when API fails
3109
3292
  */
@@ -3239,25 +3422,36 @@ VPN: {{reason.isVpn}}, Proxy: {{reason.isProxy}}`
3239
3422
  };
3240
3423
  },
3241
3424
  /**
3242
- * Replace template variables with actual values
3425
+ * Escapes HTML special characters to prevent XSS in email templates
3426
+ * @param {string} str - String to escape
3427
+ * @returns {string} HTML-safe string
3428
+ */
3429
+ escapeHtml(str2) {
3430
+ if (!str2 || typeof str2 !== "string") return str2 || "";
3431
+ return str2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3432
+ },
3433
+ /**
3434
+ * Replace template variables with HTML-escaped actual values
3435
+ * SECURITY: All dynamic values are HTML-escaped to prevent XSS via email
3243
3436
  */
3244
3437
  replaceVariables(template2, data) {
3245
3438
  let result = template2;
3246
- result = result.replace(/\{\{user\.email\}\}/g, data.user?.email || "N/A");
3247
- result = result.replace(/\{\{user\.username\}\}/g, data.user?.username || "N/A");
3439
+ const esc2 = this.escapeHtml.bind(this);
3440
+ result = result.replace(/\{\{user\.email\}\}/g, esc2(data.user?.email || "N/A"));
3441
+ result = result.replace(/\{\{user\.username\}\}/g, esc2(data.user?.username || "N/A"));
3248
3442
  result = result.replace(
3249
3443
  /\{\{session\.loginTime\}\}/g,
3250
- data.session?.loginTime ? new Date(data.session.loginTime).toLocaleString() : "N/A"
3444
+ esc2(data.session?.loginTime ? new Date(data.session.loginTime).toLocaleString() : "N/A")
3251
3445
  );
3252
- result = result.replace(/\{\{session\.ipAddress\}\}/g, data.session?.ipAddress || "N/A");
3253
- result = result.replace(/\{\{session\.userAgent\}\}/g, data.session?.userAgent || "N/A");
3254
- result = result.replace(/\{\{geo\.city\}\}/g, data.geoData?.city || "Unknown");
3255
- result = result.replace(/\{\{geo\.country\}\}/g, data.geoData?.country || "Unknown");
3256
- result = result.replace(/\{\{geo\.timezone\}\}/g, data.geoData?.timezone || "Unknown");
3446
+ result = result.replace(/\{\{session\.ipAddress\}\}/g, esc2(data.session?.ipAddress || "N/A"));
3447
+ result = result.replace(/\{\{session\.userAgent\}\}/g, esc2(data.session?.userAgent || "N/A"));
3448
+ result = result.replace(/\{\{geo\.city\}\}/g, esc2(data.geoData?.city || "Unknown"));
3449
+ result = result.replace(/\{\{geo\.country\}\}/g, esc2(data.geoData?.country || "Unknown"));
3450
+ result = result.replace(/\{\{geo\.timezone\}\}/g, esc2(data.geoData?.timezone || "Unknown"));
3257
3451
  result = result.replace(/\{\{reason\.isVpn\}\}/g, data.reason?.isVpn ? "Yes" : "No");
3258
3452
  result = result.replace(/\{\{reason\.isProxy\}\}/g, data.reason?.isProxy ? "Yes" : "No");
3259
3453
  result = result.replace(/\{\{reason\.isThreat\}\}/g, data.reason?.isThreat ? "Yes" : "No");
3260
- result = result.replace(/\{\{reason\.securityScore\}\}/g, data.reason?.securityScore || "0");
3454
+ result = result.replace(/\{\{reason\.securityScore\}\}/g, esc2(String(data.reason?.securityScore || "0")));
3261
3455
  return result;
3262
3456
  },
3263
3457
  /**
@@ -39033,7 +39227,7 @@ const dist = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty
39033
39227
  const require$$0 = /* @__PURE__ */ getAugmentedNamespace(dist);
39034
39228
  const SESSION_UID = "plugin::magic-sessionmanager.session";
39035
39229
  const { errors } = require$$0;
39036
- var sessionRequired$1 = async (policyContext, config2, { strapi: strapi2 }) => {
39230
+ var sessionRequired$1 = async (policyContext, _policyConfig, { strapi: strapi2 }) => {
39037
39231
  if (!policyContext.state.user) {
39038
39232
  return true;
39039
39233
  }
@@ -39051,8 +39245,8 @@ var sessionRequired$1 = async (policyContext, config2, { strapi: strapi2 }) => {
39051
39245
  if (!userDocId) {
39052
39246
  return true;
39053
39247
  }
39054
- const config3 = strapi2.config.get("plugin::magic-sessionmanager") || {};
39055
- const strictMode = config3.strictSessionEnforcement === true;
39248
+ const pluginConfig = strapi2.config.get("plugin::magic-sessionmanager") || {};
39249
+ const strictMode = pluginConfig.strictSessionEnforcement === true;
39056
39250
  const activeSessions = await strapi2.documents(SESSION_UID).findMany({
39057
39251
  filters: {
39058
39252
  user: { documentId: userDocId },