strapi-plugin-magic-sessionmanager 4.4.5 → 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) => {
@@ -207,11 +215,12 @@ function encryptToken$2(token) {
207
215
  const authTag = cipher.getAuthTag();
208
216
  return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
209
217
  } catch (err) {
210
- 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);
211
220
  throw new Error("Failed to encrypt token");
212
221
  }
213
222
  }
214
- function decryptToken$3(encryptedToken) {
223
+ function decryptToken$2(encryptedToken) {
215
224
  if (!encryptedToken) return null;
216
225
  try {
217
226
  const key = getEncryptionKey();
@@ -228,7 +237,8 @@ function decryptToken$3(encryptedToken) {
228
237
  decrypted += decipher.final("utf8");
229
238
  return decrypted;
230
239
  } catch (err) {
231
- 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);
232
242
  return null;
233
243
  }
234
244
  }
@@ -244,7 +254,7 @@ function hashToken$3(token) {
244
254
  }
245
255
  var encryption = {
246
256
  encryptToken: encryptToken$2,
247
- decryptToken: decryptToken$3,
257
+ decryptToken: decryptToken$2,
248
258
  generateSessionId: generateSessionId$1,
249
259
  hashToken: hashToken$3
250
260
  };
@@ -275,12 +285,25 @@ function isAuthEndpoint(path2) {
275
285
  }
276
286
  const userIdCache = /* @__PURE__ */ new Map();
277
287
  const CACHE_TTL = 5 * 60 * 1e3;
288
+ const CACHE_MAX_SIZE = 1e3;
278
289
  async function getDocumentIdFromNumericId(strapi2, numericId) {
279
290
  const cacheKey = `user_${numericId}`;
280
291
  const cached2 = userIdCache.get(cacheKey);
281
292
  if (cached2 && Date.now() - cached2.timestamp < CACHE_TTL) {
282
293
  return cached2.documentId;
283
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
+ }
284
307
  try {
285
308
  const user = await strapi2.entityService.findOne(USER_UID$3, numericId, {
286
309
  fields: ["documentId"]
@@ -323,7 +346,7 @@ var lastSeen = ({ strapi: strapi2, sessionService }) => {
323
346
  isActive: false
324
347
  },
325
348
  limit: 5,
326
- fields: ["documentId", "terminatedManually", "lastActive"],
349
+ fields: ["documentId", "terminatedManually", "lastActive", "loginTime"],
327
350
  sort: [{ lastActive: "desc" }]
328
351
  });
329
352
  if (inactiveSessions && inactiveSessions.length > 0) {
@@ -333,6 +356,14 @@ var lastSeen = ({ strapi: strapi2, sessionService }) => {
333
356
  return ctx.unauthorized("Session has been terminated. Please login again.");
334
357
  }
335
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
+ }
336
367
  await strapi2.documents(SESSION_UID$4).update({
337
368
  documentId: sessionToReactivate.documentId,
338
369
  data: {
@@ -373,7 +404,7 @@ var lastSeen = ({ strapi: strapi2, sessionService }) => {
373
404
  };
374
405
  };
375
406
  const getClientIp = getClientIp_1;
376
- const { encryptToken: encryptToken$1, decryptToken: decryptToken$2, hashToken: hashToken$2 } = encryption;
407
+ const { encryptToken: encryptToken$1, decryptToken: decryptToken$1, hashToken: hashToken$2 } = encryption;
377
408
  const { createLogger: createLogger$3 } = logger;
378
409
  const SESSION_UID$3 = "plugin::magic-sessionmanager.session";
379
410
  const USER_UID$2 = "plugin::users-permissions.user";
@@ -407,11 +438,11 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
407
438
  log.info("║ [SUCCESS] SESSION MANAGER LICENSE ACTIVE ║");
408
439
  log.info("║ ║");
409
440
  if (licenseStatus.data) {
410
- 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) + "║");
411
443
  log.info(`║ User: ${licenseStatus.data.firstName} ${licenseStatus.data.lastName}`.padEnd(66) + "║");
412
- log.info(`║ Email: ${licenseStatus.data.email}`.padEnd(66) + "║");
413
444
  } else if (storedKey) {
414
- log.info(`║ License: ${storedKey} (Offline Mode)`.padEnd(66) + "║");
445
+ log.info(`║ License: ${storedKey.substring(0, 8)}... (Offline Mode)`.padEnd(66) + "║");
415
446
  log.info(`║ Status: Grace Period Active`.padEnd(66) + "║");
416
447
  }
417
448
  log.info("║ ║");
@@ -443,10 +474,21 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
443
474
  try {
444
475
  const token = ctx.request.headers?.authorization?.replace("Bearer ", "");
445
476
  if (!token) {
446
- ctx.status = 200;
447
- ctx.body = { message: "Logged out successfully" };
477
+ ctx.status = 401;
478
+ ctx.body = { error: { status: 401, message: "Authorization token required" } };
448
479
  return;
449
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
+ }
450
492
  const tokenHashValue = hashToken$2(token);
451
493
  const matchingSession = await strapi2.documents(SESSION_UID$3).findFirst({
452
494
  filters: {
@@ -468,6 +510,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
468
510
  },
469
511
  config: {
470
512
  auth: false
513
+ // We handle auth manually above to support expired-but-valid tokens
471
514
  }
472
515
  }]);
473
516
  log.info("[SUCCESS] /api/auth/logout route registered");
@@ -527,8 +570,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
527
570
  ctx.body = {
528
571
  error: {
529
572
  status: 403,
530
- message: "Login blocked for security reasons",
531
- details: { reason: blockReason }
573
+ message: "Login blocked for security reasons. Please contact support."
532
574
  }
533
575
  };
534
576
  return;
@@ -590,11 +632,22 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
590
632
  geoData
591
633
  });
592
634
  if (config2.discordWebhookUrl) {
593
- await notificationService.sendWebhook({
594
- event: "session.login",
595
- data: webhookData,
596
- webhookUrl: config2.discordWebhookUrl
597
- });
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
+ }
598
651
  }
599
652
  }
600
653
  } catch (notifErr) {
@@ -775,6 +828,30 @@ async function ensureTokenHashIndex(strapi2, log) {
775
828
  log.debug("[INDEX] Could not create tokenHash index (will retry on next startup):", err.message);
776
829
  }
777
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
+ }
778
855
  async function registerSessionAwareAuthStrategy(strapi2, log) {
779
856
  try {
780
857
  const usersPermissionsPlugin = strapi2.plugin("users-permissions");
@@ -796,15 +873,15 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
796
873
  }
797
874
  const config2 = strapi2.config.get("plugin::magic-sessionmanager") || {};
798
875
  const strictMode = config2.strictSessionEnforcement === true;
876
+ const maxSessionAgeDays = config2.maxSessionAgeDays || 30;
799
877
  try {
800
878
  const tokenHashValue = hashToken$2(token);
801
- let userDocId = null;
802
879
  const user = await strapi2.entityService.findOne(
803
880
  "plugin::users-permissions.user",
804
881
  decoded.id,
805
882
  { fields: ["documentId"] }
806
883
  );
807
- userDocId = user?.documentId;
884
+ const userDocId = user?.documentId;
808
885
  if (!userDocId) {
809
886
  strapi2.log.debug("[magic-sessionmanager] [JWT] No documentId found, allowing through");
810
887
  return decoded;
@@ -814,9 +891,19 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
814
891
  user: { documentId: userDocId },
815
892
  tokenHash: tokenHashValue
816
893
  },
817
- fields: ["documentId", "isActive", "terminatedManually", "lastActive"]
894
+ fields: ["documentId", "isActive", "terminatedManually", "lastActive", "loginTime"]
818
895
  });
819
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
+ }
820
907
  if (thisSession.terminatedManually === true) {
821
908
  strapi2.log.info(
822
909
  `[magic-sessionmanager] [JWT-BLOCKED] Session was manually terminated (user: ${userDocId.substring(0, 8)}...)`
@@ -824,38 +911,32 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
824
911
  return null;
825
912
  }
826
913
  if (thisSession.isActive) {
914
+ resetErrorCounter();
827
915
  return decoded;
828
916
  }
829
917
  await strapi2.documents(SESSION_UID$3).update({
830
918
  documentId: thisSession.documentId,
831
- data: {
832
- isActive: true,
833
- lastActive: /* @__PURE__ */ new Date()
834
- }
919
+ data: { isActive: true, lastActive: /* @__PURE__ */ new Date() }
835
920
  });
836
921
  strapi2.log.info(
837
922
  `[magic-sessionmanager] [JWT-REACTIVATED] Session reactivated for user ${userDocId.substring(0, 8)}...`
838
923
  );
924
+ resetErrorCounter();
839
925
  return decoded;
840
926
  }
841
927
  const anyActiveSessions = await strapi2.documents(SESSION_UID$3).findMany({
842
- filters: {
843
- user: { documentId: userDocId },
844
- isActive: true
845
- },
928
+ filters: { user: { documentId: userDocId }, isActive: true },
846
929
  limit: 1
847
930
  });
848
931
  if (anyActiveSessions && anyActiveSessions.length > 0) {
849
932
  strapi2.log.debug(
850
933
  `[magic-sessionmanager] [JWT] No session for token but user has other active sessions (allowing)`
851
934
  );
935
+ resetErrorCounter();
852
936
  return decoded;
853
937
  }
854
938
  const terminatedSessions = await strapi2.documents(SESSION_UID$3).findMany({
855
- filters: {
856
- user: { documentId: userDocId },
857
- terminatedManually: true
858
- },
939
+ filters: { user: { documentId: userDocId }, terminatedManually: true },
859
940
  limit: 1
860
941
  });
861
942
  if (terminatedSessions && terminatedSessions.length > 0) {
@@ -873,10 +954,15 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
873
954
  strapi2.log.warn(
874
955
  `[magic-sessionmanager] [JWT-WARN] No session for user ${userDocId.substring(0, 8)}... (allowing)`
875
956
  );
957
+ resetErrorCounter();
876
958
  return decoded;
877
959
  } catch (err) {
878
- strapi2.log.warn("[magic-sessionmanager] [JWT] Session check error (allowing):", err.message);
879
- 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;
880
966
  }
881
967
  };
882
968
  strapi2.log.info("[magic-sessionmanager] [AUTH] [SUCCESS] JWT verify wrapped with session validation");
@@ -1341,7 +1427,7 @@ function extractVersion(userAgent, regex) {
1341
1427
  var userAgentParser = {
1342
1428
  parseUserAgent: parseUserAgent$2
1343
1429
  };
1344
- const { decryptToken: decryptToken$1, hashToken: hashToken$1 } = encryption;
1430
+ const { hashToken: hashToken$1 } = encryption;
1345
1431
  const { parseUserAgent: parseUserAgent$1 } = userAgentParser;
1346
1432
  const SESSION_UID$2 = "plugin::magic-sessionmanager.session";
1347
1433
  const USER_UID$1 = "plugin::users-permissions.user";
@@ -1401,7 +1487,8 @@ var session$3 = {
1401
1487
  }
1402
1488
  const allSessions = await strapi.documents(SESSION_UID$2).findMany({
1403
1489
  filters: { user: { documentId: userId } },
1404
- sort: { loginTime: "desc" }
1490
+ sort: { loginTime: "desc" },
1491
+ limit: 200
1405
1492
  });
1406
1493
  const config2 = strapi.config.get("plugin::magic-sessionmanager") || {};
1407
1494
  const inactivityTimeout = config2.inactivityTimeout || 15 * 60 * 1e3;
@@ -1439,7 +1526,7 @@ var session$3 = {
1439
1526
  strapi.documents(SESSION_UID$2).update({
1440
1527
  documentId: session2.documentId,
1441
1528
  data: {
1442
- geoLocation: JSON.stringify(geoLocation),
1529
+ geoLocation,
1443
1530
  securityScore: geoData.securityScore || null
1444
1531
  }
1445
1532
  }).catch(() => {
@@ -1501,9 +1588,15 @@ var session$3 = {
1501
1588
  const { userId } = ctx.params;
1502
1589
  const isAdminRequest = ctx.state.userAbility || ctx.state.admin;
1503
1590
  const requestingUserDocId = ctx.state.user?.documentId;
1504
- if (!isAdminRequest && requestingUserDocId && String(requestingUserDocId) !== String(userId)) {
1505
- strapi.log.warn(`[magic-sessionmanager] Security: User ${requestingUserDocId} tried to access sessions of user ${userId}`);
1506
- 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
+ }
1507
1600
  }
1508
1601
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
1509
1602
  const sessions = await sessionService.getUserSessions(userId);
@@ -1627,7 +1720,7 @@ var session$3 = {
1627
1720
  strapi.documents(SESSION_UID$2).update({
1628
1721
  documentId: currentSession.documentId,
1629
1722
  data: {
1630
- geoLocation: JSON.stringify(geoLocation),
1723
+ geoLocation,
1631
1724
  securityScore: geoData.securityScore || null
1632
1725
  }
1633
1726
  }).catch(() => {
@@ -1709,22 +1802,6 @@ var session$3 = {
1709
1802
  ctx.throw(500, "Error terminating session");
1710
1803
  }
1711
1804
  },
1712
- /**
1713
- * Terminate specific session
1714
- * DELETE /magic-sessionmanager/sessions/:sessionId
1715
- */
1716
- async terminateSession(ctx) {
1717
- try {
1718
- const { sessionId } = ctx.params;
1719
- const sessionService = strapi.plugin("magic-sessionmanager").service("session");
1720
- await sessionService.terminateSession({ sessionId });
1721
- ctx.body = {
1722
- message: `Session ${sessionId} terminated`
1723
- };
1724
- } catch (err) {
1725
- ctx.throw(500, "Error terminating session");
1726
- }
1727
- },
1728
1805
  /**
1729
1806
  * Simulate session timeout for testing (Admin action)
1730
1807
  * POST /magic-sessionmanager/sessions/:sessionId/simulate-timeout
@@ -1733,6 +1810,10 @@ var session$3 = {
1733
1810
  */
1734
1811
  async simulateTimeout(ctx) {
1735
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
+ }
1736
1817
  const { sessionId } = ctx.params;
1737
1818
  const session2 = await strapi.documents(SESSION_UID$2).findOne({
1738
1819
  documentId: sessionId
@@ -1805,9 +1886,15 @@ var session$3 = {
1805
1886
  if (!ipAddress) {
1806
1887
  return ctx.badRequest("IP address is required");
1807
1888
  }
1808
- const IPV4_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/;
1809
- const IPV6_REGEX = /^[0-9a-fA-F:]+$/;
1810
- if (!IPV4_REGEX.test(ipAddress) && !IPV6_REGEX.test(ipAddress)) {
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) {
1811
1898
  return ctx.badRequest("Invalid IP address format");
1812
1899
  }
1813
1900
  const licenseGuard2 = strapi.plugin("magic-sessionmanager").service("license-guard");
@@ -1969,12 +2056,11 @@ var license$1 = ({ strapi: strapi2 }) => ({
1969
2056
  message: "No license found. Running in demo mode."
1970
2057
  });
1971
2058
  }
1972
- 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)}...`);
1973
2060
  const verification = await licenseGuard2.verifyLicense(licenseKey);
1974
2061
  const license2 = await licenseGuard2.getLicenseByKey(licenseKey);
1975
2062
  strapi2.log.info("[magic-sessionmanager/license-controller] License data from MagicAPI:", {
1976
- licenseKey: license2?.licenseKey,
1977
- email: license2?.email,
2063
+ licenseKey: license2?.licenseKey ? `${license2.licenseKey.substring(0, 8)}...` : "N/A",
1978
2064
  featurePremium: license2?.featurePremium,
1979
2065
  isActive: license2?.isActive,
1980
2066
  pluginName: license2?.pluginName
@@ -2118,6 +2204,55 @@ var license$1 = ({ strapi: strapi2 }) => ({
2118
2204
  }
2119
2205
  }
2120
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
+ }
2121
2256
  var settings$1 = {
2122
2257
  /**
2123
2258
  * Get plugin settings
@@ -2179,29 +2314,26 @@ var settings$1 = {
2179
2314
  name: "magic-sessionmanager"
2180
2315
  });
2181
2316
  const sanitizedSettings = {
2182
- inactivityTimeout: parseInt(body.inactivityTimeout) || 15,
2183
- cleanupInterval: parseInt(body.cleanupInterval) || 30,
2184
- lastSeenRateLimit: parseInt(body.lastSeenRateLimit) || 30,
2185
- 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)),
2186
2322
  enableGeolocation: !!body.enableGeolocation,
2187
2323
  enableSecurityScoring: !!body.enableSecurityScoring,
2188
2324
  blockSuspiciousSessions: !!body.blockSuspiciousSessions,
2189
- maxFailedLogins: parseInt(body.maxFailedLogins) || 5,
2325
+ maxFailedLogins: Math.max(1, Math.min(parseInt(body.maxFailedLogins) || 5, 100)),
2190
2326
  enableEmailAlerts: !!body.enableEmailAlerts,
2191
2327
  alertOnSuspiciousLogin: !!body.alertOnSuspiciousLogin,
2192
2328
  alertOnNewLocation: !!body.alertOnNewLocation,
2193
2329
  alertOnVpnProxy: !!body.alertOnVpnProxy,
2194
2330
  enableWebhooks: !!body.enableWebhooks,
2195
- discordWebhookUrl: String(body.discordWebhookUrl || ""),
2196
- slackWebhookUrl: String(body.slackWebhookUrl || ""),
2331
+ discordWebhookUrl: sanitizeWebhookUrl(body.discordWebhookUrl, "discord"),
2332
+ slackWebhookUrl: sanitizeWebhookUrl(body.slackWebhookUrl, "slack"),
2197
2333
  enableGeofencing: !!body.enableGeofencing,
2198
- allowedCountries: Array.isArray(body.allowedCountries) ? body.allowedCountries : [],
2199
- blockedCountries: Array.isArray(body.blockedCountries) ? body.blockedCountries : [],
2200
- emailTemplates: body.emailTemplates || {
2201
- suspiciousLogin: { subject: "", html: "", text: "" },
2202
- newLocation: { subject: "", html: "", text: "" },
2203
- vpnProxy: { subject: "", html: "", text: "" }
2204
- }
2334
+ allowedCountries: sanitizeCountryList(body.allowedCountries),
2335
+ blockedCountries: sanitizeCountryList(body.blockedCountries),
2336
+ emailTemplates: sanitizeEmailTemplates(body.emailTemplates)
2205
2337
  };
2206
2338
  await pluginStore.set({
2207
2339
  key: "settings",
@@ -2272,15 +2404,15 @@ var session$1 = ({ strapi: strapi2 }) => {
2272
2404
  deviceType: parsedUA.deviceType,
2273
2405
  browserName: parsedUA.browserVersion ? `${parsedUA.browserName} ${parsedUA.browserVersion}` : parsedUA.browserName,
2274
2406
  osName: parsedUA.osVersion ? `${parsedUA.osName} ${parsedUA.osVersion}` : parsedUA.osName,
2275
- // Geolocation data (if available from Premium features)
2276
- geoLocation: geoData ? JSON.stringify({
2407
+ // Geolocation data (stored as JSON object, schema type: json)
2408
+ geoLocation: geoData ? {
2277
2409
  country: geoData.country,
2278
2410
  country_code: geoData.country_code,
2279
2411
  country_flag: geoData.country_flag,
2280
2412
  city: geoData.city,
2281
2413
  region: geoData.region,
2282
2414
  timezone: geoData.timezone
2283
- }) : null,
2415
+ } : null,
2284
2416
  securityScore: geoData?.securityScore || null
2285
2417
  }
2286
2418
  });
@@ -2301,6 +2433,14 @@ var session$1 = ({ strapi: strapi2 }) => {
2301
2433
  try {
2302
2434
  const now = /* @__PURE__ */ new Date();
2303
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
+ }
2304
2444
  await strapi2.documents(SESSION_UID$1).update({
2305
2445
  documentId: sessionId,
2306
2446
  data: {
@@ -2392,7 +2532,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2392
2532
  strapi2.documents(SESSION_UID$1).update({
2393
2533
  documentId: session2.documentId,
2394
2534
  data: {
2395
- geoLocation: JSON.stringify(geoLocation),
2535
+ geoLocation,
2396
2536
  securityScore: geoData.securityScore || null
2397
2537
  }
2398
2538
  }).catch(() => {
@@ -2442,7 +2582,8 @@ var session$1 = ({ strapi: strapi2 }) => {
2442
2582
  const sessions = await strapi2.documents(SESSION_UID$1).findMany({
2443
2583
  filters: { isActive: true },
2444
2584
  populate: { user: { fields: ["documentId", "email", "username"] } },
2445
- sort: { loginTime: "desc" }
2585
+ sort: { loginTime: "desc" },
2586
+ limit: 1e3
2446
2587
  });
2447
2588
  const config2 = strapi2.config.get("plugin::magic-sessionmanager") || {};
2448
2589
  const inactivityTimeout = config2.inactivityTimeout || 15 * 60 * 1e3;
@@ -2479,7 +2620,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2479
2620
  strapi2.documents(SESSION_UID$1).update({
2480
2621
  documentId: session2.documentId,
2481
2622
  data: {
2482
- geoLocation: JSON.stringify(geoLocation),
2623
+ geoLocation,
2483
2624
  securityScore: geoData.securityScore || null
2484
2625
  }
2485
2626
  }).catch(() => {
@@ -2572,7 +2713,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2572
2713
  strapi2.documents(SESSION_UID$1).update({
2573
2714
  documentId: session2.documentId,
2574
2715
  data: {
2575
- geoLocation: JSON.stringify(geoLocation),
2716
+ geoLocation,
2576
2717
  securityScore: geoData.securityScore || null
2577
2718
  }
2578
2719
  }).catch(() => {
@@ -2709,20 +2850,36 @@ var session$1 = ({ strapi: strapi2 }) => {
2709
2850
  }
2710
2851
  },
2711
2852
  /**
2712
- * Delete all inactive sessions from database
2853
+ * Delete all inactive sessions from database in batches
2713
2854
  * WARNING: This permanently deletes records!
2714
2855
  * @returns {Promise<number>} Number of deleted sessions
2715
2856
  */
2716
2857
  async deleteInactiveSessions() {
2717
2858
  try {
2718
2859
  log.info("[DELETE] Deleting all inactive sessions...");
2719
- const inactiveSessions = await strapi2.documents(SESSION_UID$1).findMany({
2720
- filters: { isActive: false }
2721
- });
2722
2860
  let deletedCount = 0;
2723
- for (const session2 of inactiveSessions) {
2724
- await strapi2.documents(SESSION_UID$1).delete({ documentId: session2.documentId });
2725
- 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
+ }
2726
2883
  }
2727
2884
  log.info(`[SUCCESS] Deleted ${deletedCount} inactive sessions`);
2728
2885
  return deletedCount;
@@ -2733,7 +2890,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2733
2890
  }
2734
2891
  };
2735
2892
  };
2736
- const version$1 = "4.4.4";
2893
+ const version$1 = "4.4.5";
2737
2894
  const require$$2 = {
2738
2895
  version: version$1
2739
2896
  };
@@ -3007,7 +3164,7 @@ var geolocation$1 = ({ strapi: strapi2 }) => ({
3007
3164
  */
3008
3165
  async getIpInfo(ipAddress) {
3009
3166
  try {
3010
- if (!ipAddress || ipAddress === "127.0.0.1" || ipAddress === "::1" || ipAddress.startsWith("192.168.") || ipAddress.startsWith("10.")) {
3167
+ if (!ipAddress || this.isPrivateIp(ipAddress)) {
3011
3168
  return {
3012
3169
  ip: ipAddress,
3013
3170
  country: "Local Network",
@@ -3110,6 +3267,26 @@ var geolocation$1 = ({ strapi: strapi2 }) => ({
3110
3267
  const codePoints = countryCode.toUpperCase().split("").map((char) => 127397 + char.charCodeAt());
3111
3268
  return String.fromCodePoint(...codePoints);
3112
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
+ },
3113
3290
  /**
3114
3291
  * Fallback data when API fails
3115
3292
  */
@@ -3245,25 +3422,36 @@ VPN: {{reason.isVpn}}, Proxy: {{reason.isProxy}}`
3245
3422
  };
3246
3423
  },
3247
3424
  /**
3248
- * 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
3249
3436
  */
3250
3437
  replaceVariables(template2, data) {
3251
3438
  let result = template2;
3252
- result = result.replace(/\{\{user\.email\}\}/g, data.user?.email || "N/A");
3253
- 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"));
3254
3442
  result = result.replace(
3255
3443
  /\{\{session\.loginTime\}\}/g,
3256
- data.session?.loginTime ? new Date(data.session.loginTime).toLocaleString() : "N/A"
3444
+ esc2(data.session?.loginTime ? new Date(data.session.loginTime).toLocaleString() : "N/A")
3257
3445
  );
3258
- result = result.replace(/\{\{session\.ipAddress\}\}/g, data.session?.ipAddress || "N/A");
3259
- result = result.replace(/\{\{session\.userAgent\}\}/g, data.session?.userAgent || "N/A");
3260
- result = result.replace(/\{\{geo\.city\}\}/g, data.geoData?.city || "Unknown");
3261
- result = result.replace(/\{\{geo\.country\}\}/g, data.geoData?.country || "Unknown");
3262
- 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"));
3263
3451
  result = result.replace(/\{\{reason\.isVpn\}\}/g, data.reason?.isVpn ? "Yes" : "No");
3264
3452
  result = result.replace(/\{\{reason\.isProxy\}\}/g, data.reason?.isProxy ? "Yes" : "No");
3265
3453
  result = result.replace(/\{\{reason\.isThreat\}\}/g, data.reason?.isThreat ? "Yes" : "No");
3266
- result = result.replace(/\{\{reason\.securityScore\}\}/g, data.reason?.securityScore || "0");
3454
+ result = result.replace(/\{\{reason\.securityScore\}\}/g, esc2(String(data.reason?.securityScore || "0")));
3267
3455
  return result;
3268
3456
  },
3269
3457
  /**
@@ -39039,7 +39227,7 @@ const dist = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty
39039
39227
  const require$$0 = /* @__PURE__ */ getAugmentedNamespace(dist);
39040
39228
  const SESSION_UID = "plugin::magic-sessionmanager.session";
39041
39229
  const { errors } = require$$0;
39042
- var sessionRequired$1 = async (policyContext, config2, { strapi: strapi2 }) => {
39230
+ var sessionRequired$1 = async (policyContext, _policyConfig, { strapi: strapi2 }) => {
39043
39231
  if (!policyContext.state.user) {
39044
39232
  return true;
39045
39233
  }
@@ -39057,8 +39245,8 @@ var sessionRequired$1 = async (policyContext, config2, { strapi: strapi2 }) => {
39057
39245
  if (!userDocId) {
39058
39246
  return true;
39059
39247
  }
39060
- const config3 = strapi2.config.get("plugin::magic-sessionmanager") || {};
39061
- const strictMode = config3.strictSessionEnforcement === true;
39248
+ const pluginConfig = strapi2.config.get("plugin::magic-sessionmanager") || {};
39249
+ const strictMode = pluginConfig.strictSessionEnforcement === true;
39062
39250
  const activeSessions = await strapi2.documents(SESSION_UID).findMany({
39063
39251
  filters: {
39064
39252
  user: { documentId: userDocId },