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.
@@ -172,11 +172,19 @@ const getClientIp$1 = (ctx) => {
172
172
  };
173
173
  const cleanIp = (ip) => {
174
174
  if (!ip) return "unknown";
175
- ip = ip.split(":")[0];
175
+ ip = ip.trim();
176
+ if (ip.startsWith("[")) {
177
+ const bracketEnd = ip.indexOf("]");
178
+ if (bracketEnd !== -1) {
179
+ ip = ip.substring(1, bracketEnd);
180
+ }
181
+ }
176
182
  if (ip.startsWith("::ffff:")) {
177
183
  ip = ip.substring(7);
178
184
  }
179
- ip = ip.trim();
185
+ if (ip.includes(".") && ip.includes(":") && ip.indexOf(":") === ip.lastIndexOf(":")) {
186
+ ip = ip.split(":")[0];
187
+ }
180
188
  return ip || "unknown";
181
189
  };
182
190
  const isPrivateIp = (ip) => {
@@ -199,14 +207,15 @@ const IV_LENGTH = 16;
199
207
  function getEncryptionKey() {
200
208
  const envKey = process.env.SESSION_ENCRYPTION_KEY;
201
209
  if (envKey) {
202
- const key2 = crypto$1.createHash("sha256").update(envKey).digest();
203
- return key2;
210
+ return crypto$1.createHash("sha256").update(envKey).digest();
211
+ }
212
+ const strapiKeys = process.env.APP_KEYS || process.env.API_TOKEN_SALT;
213
+ if (!strapiKeys) {
214
+ throw new Error(
215
+ "[magic-sessionmanager] No encryption key available. Set SESSION_ENCRYPTION_KEY in your .env file, or ensure APP_KEYS is configured."
216
+ );
204
217
  }
205
- const strapiKeys = process.env.APP_KEYS || process.env.API_TOKEN_SALT || "default-insecure-key";
206
- const key = crypto$1.createHash("sha256").update(strapiKeys).digest();
207
- console.warn("[magic-sessionmanager/encryption] [WARNING] No SESSION_ENCRYPTION_KEY found. Using fallback (not recommended for production).");
208
- console.warn("[magic-sessionmanager/encryption] Set SESSION_ENCRYPTION_KEY in .env for better security.");
209
- return key;
218
+ return crypto$1.createHash("sha256").update(strapiKeys).digest();
210
219
  }
211
220
  function encryptToken$2(token) {
212
221
  if (!token) return null;
@@ -219,11 +228,12 @@ function encryptToken$2(token) {
219
228
  const authTag = cipher.getAuthTag();
220
229
  return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
221
230
  } catch (err) {
222
- console.error("[magic-sessionmanager/encryption] Encryption failed:", err);
231
+ const errMsg = err instanceof Error ? err.message : "Unknown encryption error";
232
+ console.error("[magic-sessionmanager/encryption] Encryption failed:", errMsg);
223
233
  throw new Error("Failed to encrypt token");
224
234
  }
225
235
  }
226
- function decryptToken$3(encryptedToken) {
236
+ function decryptToken$2(encryptedToken) {
227
237
  if (!encryptedToken) return null;
228
238
  try {
229
239
  const key = getEncryptionKey();
@@ -240,7 +250,8 @@ function decryptToken$3(encryptedToken) {
240
250
  decrypted += decipher.final("utf8");
241
251
  return decrypted;
242
252
  } catch (err) {
243
- console.error("[magic-sessionmanager/encryption] Decryption failed:", err);
253
+ const errMsg = err instanceof Error ? err.message : "Unknown decryption error";
254
+ console.error("[magic-sessionmanager/encryption] Decryption failed:", errMsg);
244
255
  return null;
245
256
  }
246
257
  }
@@ -256,7 +267,7 @@ function hashToken$3(token) {
256
267
  }
257
268
  var encryption = {
258
269
  encryptToken: encryptToken$2,
259
- decryptToken: decryptToken$3,
270
+ decryptToken: decryptToken$2,
260
271
  generateSessionId: generateSessionId$1,
261
272
  hashToken: hashToken$3
262
273
  };
@@ -287,12 +298,25 @@ function isAuthEndpoint(path2) {
287
298
  }
288
299
  const userIdCache = /* @__PURE__ */ new Map();
289
300
  const CACHE_TTL = 5 * 60 * 1e3;
301
+ const CACHE_MAX_SIZE = 1e3;
290
302
  async function getDocumentIdFromNumericId(strapi2, numericId) {
291
303
  const cacheKey = `user_${numericId}`;
292
304
  const cached2 = userIdCache.get(cacheKey);
293
305
  if (cached2 && Date.now() - cached2.timestamp < CACHE_TTL) {
294
306
  return cached2.documentId;
295
307
  }
308
+ if (userIdCache.size >= CACHE_MAX_SIZE) {
309
+ const now = Date.now();
310
+ for (const [key, value] of userIdCache) {
311
+ if (now - value.timestamp >= CACHE_TTL) {
312
+ userIdCache.delete(key);
313
+ }
314
+ }
315
+ if (userIdCache.size >= CACHE_MAX_SIZE) {
316
+ const keysToDelete = [...userIdCache.keys()].slice(0, Math.floor(CACHE_MAX_SIZE / 4));
317
+ keysToDelete.forEach((key) => userIdCache.delete(key));
318
+ }
319
+ }
296
320
  try {
297
321
  const user = await strapi2.entityService.findOne(USER_UID$3, numericId, {
298
322
  fields: ["documentId"]
@@ -335,7 +359,7 @@ var lastSeen = ({ strapi: strapi2, sessionService }) => {
335
359
  isActive: false
336
360
  },
337
361
  limit: 5,
338
- fields: ["documentId", "terminatedManually", "lastActive"],
362
+ fields: ["documentId", "terminatedManually", "lastActive", "loginTime"],
339
363
  sort: [{ lastActive: "desc" }]
340
364
  });
341
365
  if (inactiveSessions && inactiveSessions.length > 0) {
@@ -345,6 +369,14 @@ var lastSeen = ({ strapi: strapi2, sessionService }) => {
345
369
  return ctx.unauthorized("Session has been terminated. Please login again.");
346
370
  }
347
371
  const sessionToReactivate = inactiveSessions[0];
372
+ const maxAgeDays = config2.maxSessionAgeDays || 30;
373
+ const loginTime = sessionToReactivate.loginTime ? new Date(sessionToReactivate.loginTime).getTime() : sessionToReactivate.lastActive ? new Date(sessionToReactivate.lastActive).getTime() : 0;
374
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1e3;
375
+ const isExpired = loginTime > 0 && Date.now() - loginTime > maxAgeMs;
376
+ if (isExpired) {
377
+ strapi2.log.info(`[magic-sessionmanager] [BLOCKED] Session exceeded max age of ${maxAgeDays} days (user: ${userDocId2.substring(0, 8)}...)`);
378
+ return ctx.unauthorized("Session expired. Please login again.");
379
+ }
348
380
  await strapi2.documents(SESSION_UID$4).update({
349
381
  documentId: sessionToReactivate.documentId,
350
382
  data: {
@@ -385,7 +417,7 @@ var lastSeen = ({ strapi: strapi2, sessionService }) => {
385
417
  };
386
418
  };
387
419
  const getClientIp = getClientIp_1;
388
- const { encryptToken: encryptToken$1, decryptToken: decryptToken$2, hashToken: hashToken$2 } = encryption;
420
+ const { encryptToken: encryptToken$1, decryptToken: decryptToken$1, hashToken: hashToken$2 } = encryption;
389
421
  const { createLogger: createLogger$3 } = logger;
390
422
  const SESSION_UID$3 = "plugin::magic-sessionmanager.session";
391
423
  const USER_UID$2 = "plugin::users-permissions.user";
@@ -419,11 +451,11 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
419
451
  log.info("║ [SUCCESS] SESSION MANAGER LICENSE ACTIVE ║");
420
452
  log.info("║ ║");
421
453
  if (licenseStatus.data) {
422
- log.info(`║ License: ${licenseStatus.data.licenseKey}`.padEnd(66) + "");
454
+ const maskedKey = licenseStatus.data.licenseKey ? `${licenseStatus.data.licenseKey.substring(0, 8)}...` : "N/A";
455
+ log.info(`║ License: ${maskedKey}`.padEnd(66) + "║");
423
456
  log.info(`║ User: ${licenseStatus.data.firstName} ${licenseStatus.data.lastName}`.padEnd(66) + "║");
424
- log.info(`║ Email: ${licenseStatus.data.email}`.padEnd(66) + "║");
425
457
  } else if (storedKey) {
426
- log.info(`║ License: ${storedKey} (Offline Mode)`.padEnd(66) + "║");
458
+ log.info(`║ License: ${storedKey.substring(0, 8)}... (Offline Mode)`.padEnd(66) + "║");
427
459
  log.info(`║ Status: Grace Period Active`.padEnd(66) + "║");
428
460
  }
429
461
  log.info("║ ║");
@@ -455,10 +487,21 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
455
487
  try {
456
488
  const token = ctx.request.headers?.authorization?.replace("Bearer ", "");
457
489
  if (!token) {
458
- ctx.status = 200;
459
- ctx.body = { message: "Logged out successfully" };
490
+ ctx.status = 401;
491
+ ctx.body = { error: { status: 401, message: "Authorization token required" } };
460
492
  return;
461
493
  }
494
+ try {
495
+ const jwtService = strapi2.plugin("users-permissions").service("jwt");
496
+ const decoded = await jwtService.verify(token);
497
+ if (!decoded || !decoded.id) {
498
+ ctx.status = 401;
499
+ ctx.body = { error: { status: 401, message: "Invalid token" } };
500
+ return;
501
+ }
502
+ } catch (jwtErr) {
503
+ log.debug("JWT verify failed during logout (cleaning up anyway):", jwtErr.message);
504
+ }
462
505
  const tokenHashValue = hashToken$2(token);
463
506
  const matchingSession = await strapi2.documents(SESSION_UID$3).findFirst({
464
507
  filters: {
@@ -480,6 +523,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
480
523
  },
481
524
  config: {
482
525
  auth: false
526
+ // We handle auth manually above to support expired-but-valid tokens
483
527
  }
484
528
  }]);
485
529
  log.info("[SUCCESS] /api/auth/logout route registered");
@@ -539,8 +583,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
539
583
  ctx.body = {
540
584
  error: {
541
585
  status: 403,
542
- message: "Login blocked for security reasons",
543
- details: { reason: blockReason }
586
+ message: "Login blocked for security reasons. Please contact support."
544
587
  }
545
588
  };
546
589
  return;
@@ -602,11 +645,22 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
602
645
  geoData
603
646
  });
604
647
  if (config2.discordWebhookUrl) {
605
- await notificationService.sendWebhook({
606
- event: "session.login",
607
- data: webhookData,
608
- webhookUrl: config2.discordWebhookUrl
609
- });
648
+ const webhookUrl = config2.discordWebhookUrl;
649
+ try {
650
+ const parsed = new URL(webhookUrl);
651
+ const isValidDomain = parsed.protocol === "https:" && (parsed.hostname === "discord.com" || parsed.hostname === "discordapp.com" || parsed.hostname.endsWith(".discord.com") || parsed.hostname === "hooks.slack.com");
652
+ if (isValidDomain) {
653
+ await notificationService.sendWebhook({
654
+ event: "session.login",
655
+ data: webhookData,
656
+ webhookUrl
657
+ });
658
+ } else {
659
+ log.warn(`[SECURITY] Blocked webhook to untrusted domain: ${parsed.hostname}`);
660
+ }
661
+ } catch {
662
+ log.warn("[SECURITY] Invalid webhook URL in plugin config");
663
+ }
610
664
  }
611
665
  }
612
666
  } catch (notifErr) {
@@ -787,6 +841,30 @@ async function ensureTokenHashIndex(strapi2, log) {
787
841
  log.debug("[INDEX] Could not create tokenHash index (will retry on next startup):", err.message);
788
842
  }
789
843
  }
844
+ const sessionCheckErrors = { count: 0, lastReset: Date.now() };
845
+ const MAX_CONSECUTIVE_ERRORS = 10;
846
+ const ERROR_RESET_INTERVAL = 60 * 1e3;
847
+ function shouldFailOpen() {
848
+ const now = Date.now();
849
+ if (now - sessionCheckErrors.lastReset > ERROR_RESET_INTERVAL) {
850
+ sessionCheckErrors.count = 0;
851
+ sessionCheckErrors.lastReset = now;
852
+ }
853
+ sessionCheckErrors.count++;
854
+ if (sessionCheckErrors.count > MAX_CONSECUTIVE_ERRORS) {
855
+ return false;
856
+ }
857
+ return true;
858
+ }
859
+ function resetErrorCounter() {
860
+ sessionCheckErrors.count = 0;
861
+ }
862
+ function isSessionExpired(session2, maxAgeDays = 30) {
863
+ if (!session2.loginTime) return false;
864
+ const loginTime = new Date(session2.loginTime).getTime();
865
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1e3;
866
+ return Date.now() - loginTime > maxAgeMs;
867
+ }
790
868
  async function registerSessionAwareAuthStrategy(strapi2, log) {
791
869
  try {
792
870
  const usersPermissionsPlugin = strapi2.plugin("users-permissions");
@@ -808,15 +886,15 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
808
886
  }
809
887
  const config2 = strapi2.config.get("plugin::magic-sessionmanager") || {};
810
888
  const strictMode = config2.strictSessionEnforcement === true;
889
+ const maxSessionAgeDays = config2.maxSessionAgeDays || 30;
811
890
  try {
812
891
  const tokenHashValue = hashToken$2(token);
813
- let userDocId = null;
814
892
  const user = await strapi2.entityService.findOne(
815
893
  "plugin::users-permissions.user",
816
894
  decoded.id,
817
895
  { fields: ["documentId"] }
818
896
  );
819
- userDocId = user?.documentId;
897
+ const userDocId = user?.documentId;
820
898
  if (!userDocId) {
821
899
  strapi2.log.debug("[magic-sessionmanager] [JWT] No documentId found, allowing through");
822
900
  return decoded;
@@ -826,9 +904,19 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
826
904
  user: { documentId: userDocId },
827
905
  tokenHash: tokenHashValue
828
906
  },
829
- fields: ["documentId", "isActive", "terminatedManually", "lastActive"]
907
+ fields: ["documentId", "isActive", "terminatedManually", "lastActive", "loginTime"]
830
908
  });
831
909
  if (thisSession) {
910
+ if (isSessionExpired(thisSession, maxSessionAgeDays)) {
911
+ strapi2.log.info(
912
+ `[magic-sessionmanager] [JWT-EXPIRED] Session exceeded max age of ${maxSessionAgeDays} days (user: ${userDocId.substring(0, 8)}...)`
913
+ );
914
+ await strapi2.documents(SESSION_UID$3).update({
915
+ documentId: thisSession.documentId,
916
+ data: { isActive: false, terminatedManually: true, logoutTime: /* @__PURE__ */ new Date() }
917
+ });
918
+ return null;
919
+ }
832
920
  if (thisSession.terminatedManually === true) {
833
921
  strapi2.log.info(
834
922
  `[magic-sessionmanager] [JWT-BLOCKED] Session was manually terminated (user: ${userDocId.substring(0, 8)}...)`
@@ -836,38 +924,32 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
836
924
  return null;
837
925
  }
838
926
  if (thisSession.isActive) {
927
+ resetErrorCounter();
839
928
  return decoded;
840
929
  }
841
930
  await strapi2.documents(SESSION_UID$3).update({
842
931
  documentId: thisSession.documentId,
843
- data: {
844
- isActive: true,
845
- lastActive: /* @__PURE__ */ new Date()
846
- }
932
+ data: { isActive: true, lastActive: /* @__PURE__ */ new Date() }
847
933
  });
848
934
  strapi2.log.info(
849
935
  `[magic-sessionmanager] [JWT-REACTIVATED] Session reactivated for user ${userDocId.substring(0, 8)}...`
850
936
  );
937
+ resetErrorCounter();
851
938
  return decoded;
852
939
  }
853
940
  const anyActiveSessions = await strapi2.documents(SESSION_UID$3).findMany({
854
- filters: {
855
- user: { documentId: userDocId },
856
- isActive: true
857
- },
941
+ filters: { user: { documentId: userDocId }, isActive: true },
858
942
  limit: 1
859
943
  });
860
944
  if (anyActiveSessions && anyActiveSessions.length > 0) {
861
945
  strapi2.log.debug(
862
946
  `[magic-sessionmanager] [JWT] No session for token but user has other active sessions (allowing)`
863
947
  );
948
+ resetErrorCounter();
864
949
  return decoded;
865
950
  }
866
951
  const terminatedSessions = await strapi2.documents(SESSION_UID$3).findMany({
867
- filters: {
868
- user: { documentId: userDocId },
869
- terminatedManually: true
870
- },
952
+ filters: { user: { documentId: userDocId }, terminatedManually: true },
871
953
  limit: 1
872
954
  });
873
955
  if (terminatedSessions && terminatedSessions.length > 0) {
@@ -885,10 +967,15 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
885
967
  strapi2.log.warn(
886
968
  `[magic-sessionmanager] [JWT-WARN] No session for user ${userDocId.substring(0, 8)}... (allowing)`
887
969
  );
970
+ resetErrorCounter();
888
971
  return decoded;
889
972
  } catch (err) {
890
- strapi2.log.warn("[magic-sessionmanager] [JWT] Session check error (allowing):", err.message);
891
- return decoded;
973
+ if (shouldFailOpen()) {
974
+ strapi2.log.warn("[magic-sessionmanager] [JWT] Session check error (allowing):", err.message);
975
+ return decoded;
976
+ }
977
+ strapi2.log.error("[magic-sessionmanager] [JWT] Too many consecutive errors, blocking request:", err.message);
978
+ return null;
892
979
  }
893
980
  };
894
981
  strapi2.log.info("[magic-sessionmanager] [AUTH] [SUCCESS] JWT verify wrapped with session validation");
@@ -1198,7 +1285,7 @@ var admin$1 = {
1198
1285
  path: "/license/status",
1199
1286
  handler: "license.getStatus",
1200
1287
  config: {
1201
- policies: []
1288
+ policies: ["admin::isAuthenticatedAdmin"]
1202
1289
  }
1203
1290
  },
1204
1291
  {
@@ -1206,7 +1293,7 @@ var admin$1 = {
1206
1293
  path: "/license/auto-create",
1207
1294
  handler: "license.autoCreate",
1208
1295
  config: {
1209
- policies: []
1296
+ policies: ["admin::isAuthenticatedAdmin"]
1210
1297
  }
1211
1298
  },
1212
1299
  {
@@ -1214,7 +1301,7 @@ var admin$1 = {
1214
1301
  path: "/license/create",
1215
1302
  handler: "license.createAndActivate",
1216
1303
  config: {
1217
- policies: []
1304
+ policies: ["admin::isAuthenticatedAdmin"]
1218
1305
  }
1219
1306
  },
1220
1307
  {
@@ -1222,7 +1309,7 @@ var admin$1 = {
1222
1309
  path: "/license/ping",
1223
1310
  handler: "license.ping",
1224
1311
  config: {
1225
- policies: []
1312
+ policies: ["admin::isAuthenticatedAdmin"]
1226
1313
  }
1227
1314
  },
1228
1315
  {
@@ -1230,7 +1317,7 @@ var admin$1 = {
1230
1317
  path: "/license/store-key",
1231
1318
  handler: "license.storeKey",
1232
1319
  config: {
1233
- policies: []
1320
+ policies: ["admin::isAuthenticatedAdmin"]
1234
1321
  }
1235
1322
  },
1236
1323
  // Geolocation (Premium Feature)
@@ -1353,7 +1440,7 @@ function extractVersion(userAgent, regex) {
1353
1440
  var userAgentParser = {
1354
1441
  parseUserAgent: parseUserAgent$2
1355
1442
  };
1356
- const { decryptToken: decryptToken$1, hashToken: hashToken$1 } = encryption;
1443
+ const { hashToken: hashToken$1 } = encryption;
1357
1444
  const { parseUserAgent: parseUserAgent$1 } = userAgentParser;
1358
1445
  const SESSION_UID$2 = "plugin::magic-sessionmanager.session";
1359
1446
  const USER_UID$1 = "plugin::users-permissions.user";
@@ -1413,7 +1500,8 @@ var session$3 = {
1413
1500
  }
1414
1501
  const allSessions = await strapi.documents(SESSION_UID$2).findMany({
1415
1502
  filters: { user: { documentId: userId } },
1416
- sort: { loginTime: "desc" }
1503
+ sort: { loginTime: "desc" },
1504
+ limit: 200
1417
1505
  });
1418
1506
  const config2 = strapi.config.get("plugin::magic-sessionmanager") || {};
1419
1507
  const inactivityTimeout = config2.inactivityTimeout || 15 * 60 * 1e3;
@@ -1451,7 +1539,7 @@ var session$3 = {
1451
1539
  strapi.documents(SESSION_UID$2).update({
1452
1540
  documentId: session2.documentId,
1453
1541
  data: {
1454
- geoLocation: JSON.stringify(geoLocation),
1542
+ geoLocation,
1455
1543
  securityScore: geoData.securityScore || null
1456
1544
  }
1457
1545
  }).catch(() => {
@@ -1513,9 +1601,15 @@ var session$3 = {
1513
1601
  const { userId } = ctx.params;
1514
1602
  const isAdminRequest = ctx.state.userAbility || ctx.state.admin;
1515
1603
  const requestingUserDocId = ctx.state.user?.documentId;
1516
- if (!isAdminRequest && requestingUserDocId && String(requestingUserDocId) !== String(userId)) {
1517
- strapi.log.warn(`[magic-sessionmanager] Security: User ${requestingUserDocId} tried to access sessions of user ${userId}`);
1518
- return ctx.forbidden("You can only access your own sessions");
1604
+ if (!isAdminRequest) {
1605
+ if (!requestingUserDocId) {
1606
+ strapi.log.warn(`[magic-sessionmanager] Security: Request without documentId tried to access sessions of user ${userId}`);
1607
+ return ctx.forbidden("Cannot verify user identity");
1608
+ }
1609
+ if (String(requestingUserDocId) !== String(userId)) {
1610
+ strapi.log.warn(`[magic-sessionmanager] Security: User ${requestingUserDocId} tried to access sessions of user ${userId}`);
1611
+ return ctx.forbidden("You can only access your own sessions");
1612
+ }
1519
1613
  }
1520
1614
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
1521
1615
  const sessions = await sessionService.getUserSessions(userId);
@@ -1639,7 +1733,7 @@ var session$3 = {
1639
1733
  strapi.documents(SESSION_UID$2).update({
1640
1734
  documentId: currentSession.documentId,
1641
1735
  data: {
1642
- geoLocation: JSON.stringify(geoLocation),
1736
+ geoLocation,
1643
1737
  securityScore: geoData.securityScore || null
1644
1738
  }
1645
1739
  }).catch(() => {
@@ -1721,22 +1815,6 @@ var session$3 = {
1721
1815
  ctx.throw(500, "Error terminating session");
1722
1816
  }
1723
1817
  },
1724
- /**
1725
- * Terminate specific session
1726
- * DELETE /magic-sessionmanager/sessions/:sessionId
1727
- */
1728
- async terminateSession(ctx) {
1729
- try {
1730
- const { sessionId } = ctx.params;
1731
- const sessionService = strapi.plugin("magic-sessionmanager").service("session");
1732
- await sessionService.terminateSession({ sessionId });
1733
- ctx.body = {
1734
- message: `Session ${sessionId} terminated`
1735
- };
1736
- } catch (err) {
1737
- ctx.throw(500, "Error terminating session");
1738
- }
1739
- },
1740
1818
  /**
1741
1819
  * Simulate session timeout for testing (Admin action)
1742
1820
  * POST /magic-sessionmanager/sessions/:sessionId/simulate-timeout
@@ -1745,6 +1823,10 @@ var session$3 = {
1745
1823
  */
1746
1824
  async simulateTimeout(ctx) {
1747
1825
  try {
1826
+ const nodeEnv = process.env.NODE_ENV || "development";
1827
+ if (nodeEnv === "production") {
1828
+ return ctx.forbidden("simulate-timeout is disabled in production");
1829
+ }
1748
1830
  const { sessionId } = ctx.params;
1749
1831
  const session2 = await strapi.documents(SESSION_UID$2).findOne({
1750
1832
  documentId: sessionId
@@ -1817,6 +1899,17 @@ var session$3 = {
1817
1899
  if (!ipAddress) {
1818
1900
  return ctx.badRequest("IP address is required");
1819
1901
  }
1902
+ const IPV4_REGEX = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
1903
+ const ipv4Match = ipAddress.match(IPV4_REGEX);
1904
+ const isValidIpv4 = ipv4Match && ipv4Match.slice(1).every((octet) => {
1905
+ const n = parseInt(octet, 10);
1906
+ return n >= 0 && n <= 255;
1907
+ });
1908
+ const IPV6_REGEX = /^[0-9a-fA-F:]{3,45}$/;
1909
+ const isValidIpv6 = !isValidIpv4 && IPV6_REGEX.test(ipAddress) && (ipAddress.match(/:/g) || []).length >= 2;
1910
+ if (!isValidIpv4 && !isValidIpv6) {
1911
+ return ctx.badRequest("Invalid IP address format");
1912
+ }
1820
1913
  const licenseGuard2 = strapi.plugin("magic-sessionmanager").service("license-guard");
1821
1914
  const pluginStore = strapi.store({
1822
1915
  type: "plugin",
@@ -1976,12 +2069,11 @@ var license$1 = ({ strapi: strapi2 }) => ({
1976
2069
  message: "No license found. Running in demo mode."
1977
2070
  });
1978
2071
  }
1979
- strapi2.log.info(`[magic-sessionmanager/license-controller] Checking stored license: ${licenseKey}`);
2072
+ strapi2.log.info(`[magic-sessionmanager/license-controller] Checking stored license: ${licenseKey.substring(0, 8)}...`);
1980
2073
  const verification = await licenseGuard2.verifyLicense(licenseKey);
1981
2074
  const license2 = await licenseGuard2.getLicenseByKey(licenseKey);
1982
2075
  strapi2.log.info("[magic-sessionmanager/license-controller] License data from MagicAPI:", {
1983
- licenseKey: license2?.licenseKey,
1984
- email: license2?.email,
2076
+ licenseKey: license2?.licenseKey ? `${license2.licenseKey.substring(0, 8)}...` : "N/A",
1985
2077
  featurePremium: license2?.featurePremium,
1986
2078
  isActive: license2?.isActive,
1987
2079
  pluginName: license2?.pluginName
@@ -2125,6 +2217,55 @@ var license$1 = ({ strapi: strapi2 }) => ({
2125
2217
  }
2126
2218
  }
2127
2219
  });
2220
+ const ALLOWED_WEBHOOK_DOMAINS = {
2221
+ discord: ["discord.com", "discordapp.com"],
2222
+ slack: ["hooks.slack.com"]
2223
+ };
2224
+ function sanitizeWebhookUrl(url, type2) {
2225
+ if (!url || typeof url !== "string") return "";
2226
+ const trimmed = url.trim();
2227
+ if (!trimmed) return "";
2228
+ try {
2229
+ const parsed = new URL(trimmed);
2230
+ if (parsed.protocol !== "https:") return "";
2231
+ const allowedDomains = ALLOWED_WEBHOOK_DOMAINS[type2] || [];
2232
+ const isAllowed = allowedDomains.some(
2233
+ (domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`)
2234
+ );
2235
+ if (!isAllowed) return "";
2236
+ return trimmed;
2237
+ } catch {
2238
+ return "";
2239
+ }
2240
+ }
2241
+ function sanitizeCountryList(list) {
2242
+ if (!Array.isArray(list)) return [];
2243
+ return list.filter((code) => typeof code === "string" && /^[A-Z]{2}$/.test(code.trim().toUpperCase())).map((code) => code.trim().toUpperCase());
2244
+ }
2245
+ function sanitizeEmailTemplates(templates) {
2246
+ const defaults = {
2247
+ suspiciousLogin: { subject: "", html: "", text: "" },
2248
+ newLocation: { subject: "", html: "", text: "" },
2249
+ vpnProxy: { subject: "", html: "", text: "" }
2250
+ };
2251
+ if (!templates || typeof templates !== "object") return defaults;
2252
+ const dangerousTags = /<\s*\/?\s*(script|iframe|object|embed|form|input|button|link|meta|base)\b[^>]*>/gi;
2253
+ const dangerousAttrs = /\s(on\w+|javascript\s*:)[^=]*=/gi;
2254
+ const result = {};
2255
+ for (const [key, defaultVal] of Object.entries(defaults)) {
2256
+ const tpl = templates[key];
2257
+ if (!tpl || typeof tpl !== "object") {
2258
+ result[key] = defaultVal;
2259
+ continue;
2260
+ }
2261
+ result[key] = {
2262
+ subject: typeof tpl.subject === "string" ? tpl.subject.substring(0, 200) : "",
2263
+ html: typeof tpl.html === "string" ? tpl.html.replace(dangerousTags, "").replace(dangerousAttrs, " ").substring(0, 1e4) : "",
2264
+ text: typeof tpl.text === "string" ? tpl.text.substring(0, 5e3) : ""
2265
+ };
2266
+ }
2267
+ return result;
2268
+ }
2128
2269
  var settings$1 = {
2129
2270
  /**
2130
2271
  * Get plugin settings
@@ -2186,29 +2327,26 @@ var settings$1 = {
2186
2327
  name: "magic-sessionmanager"
2187
2328
  });
2188
2329
  const sanitizedSettings = {
2189
- inactivityTimeout: parseInt(body.inactivityTimeout) || 15,
2190
- cleanupInterval: parseInt(body.cleanupInterval) || 30,
2191
- lastSeenRateLimit: parseInt(body.lastSeenRateLimit) || 30,
2192
- retentionDays: parseInt(body.retentionDays) || 90,
2330
+ inactivityTimeout: Math.max(1, Math.min(parseInt(body.inactivityTimeout) || 15, 1440)),
2331
+ cleanupInterval: Math.max(5, Math.min(parseInt(body.cleanupInterval) || 30, 1440)),
2332
+ lastSeenRateLimit: Math.max(5, Math.min(parseInt(body.lastSeenRateLimit) || 30, 300)),
2333
+ retentionDays: Math.max(1, Math.min(parseInt(body.retentionDays) || 90, 365)),
2334
+ maxSessionAgeDays: Math.max(1, Math.min(parseInt(body.maxSessionAgeDays) || 30, 365)),
2193
2335
  enableGeolocation: !!body.enableGeolocation,
2194
2336
  enableSecurityScoring: !!body.enableSecurityScoring,
2195
2337
  blockSuspiciousSessions: !!body.blockSuspiciousSessions,
2196
- maxFailedLogins: parseInt(body.maxFailedLogins) || 5,
2338
+ maxFailedLogins: Math.max(1, Math.min(parseInt(body.maxFailedLogins) || 5, 100)),
2197
2339
  enableEmailAlerts: !!body.enableEmailAlerts,
2198
2340
  alertOnSuspiciousLogin: !!body.alertOnSuspiciousLogin,
2199
2341
  alertOnNewLocation: !!body.alertOnNewLocation,
2200
2342
  alertOnVpnProxy: !!body.alertOnVpnProxy,
2201
2343
  enableWebhooks: !!body.enableWebhooks,
2202
- discordWebhookUrl: String(body.discordWebhookUrl || ""),
2203
- slackWebhookUrl: String(body.slackWebhookUrl || ""),
2344
+ discordWebhookUrl: sanitizeWebhookUrl(body.discordWebhookUrl, "discord"),
2345
+ slackWebhookUrl: sanitizeWebhookUrl(body.slackWebhookUrl, "slack"),
2204
2346
  enableGeofencing: !!body.enableGeofencing,
2205
- allowedCountries: Array.isArray(body.allowedCountries) ? body.allowedCountries : [],
2206
- blockedCountries: Array.isArray(body.blockedCountries) ? body.blockedCountries : [],
2207
- emailTemplates: body.emailTemplates || {
2208
- suspiciousLogin: { subject: "", html: "", text: "" },
2209
- newLocation: { subject: "", html: "", text: "" },
2210
- vpnProxy: { subject: "", html: "", text: "" }
2211
- }
2347
+ allowedCountries: sanitizeCountryList(body.allowedCountries),
2348
+ blockedCountries: sanitizeCountryList(body.blockedCountries),
2349
+ emailTemplates: sanitizeEmailTemplates(body.emailTemplates)
2212
2350
  };
2213
2351
  await pluginStore.set({
2214
2352
  key: "settings",
@@ -2279,15 +2417,15 @@ var session$1 = ({ strapi: strapi2 }) => {
2279
2417
  deviceType: parsedUA.deviceType,
2280
2418
  browserName: parsedUA.browserVersion ? `${parsedUA.browserName} ${parsedUA.browserVersion}` : parsedUA.browserName,
2281
2419
  osName: parsedUA.osVersion ? `${parsedUA.osName} ${parsedUA.osVersion}` : parsedUA.osName,
2282
- // Geolocation data (if available from Premium features)
2283
- geoLocation: geoData ? JSON.stringify({
2420
+ // Geolocation data (stored as JSON object, schema type: json)
2421
+ geoLocation: geoData ? {
2284
2422
  country: geoData.country,
2285
2423
  country_code: geoData.country_code,
2286
2424
  country_flag: geoData.country_flag,
2287
2425
  city: geoData.city,
2288
2426
  region: geoData.region,
2289
2427
  timezone: geoData.timezone
2290
- }) : null,
2428
+ } : null,
2291
2429
  securityScore: geoData?.securityScore || null
2292
2430
  }
2293
2431
  });
@@ -2308,6 +2446,14 @@ var session$1 = ({ strapi: strapi2 }) => {
2308
2446
  try {
2309
2447
  const now = /* @__PURE__ */ new Date();
2310
2448
  if (sessionId) {
2449
+ const existing = await strapi2.documents(SESSION_UID$1).findOne({
2450
+ documentId: sessionId,
2451
+ fields: ["documentId"]
2452
+ });
2453
+ if (!existing) {
2454
+ log.warn(`Session ${sessionId} not found for termination`);
2455
+ return;
2456
+ }
2311
2457
  await strapi2.documents(SESSION_UID$1).update({
2312
2458
  documentId: sessionId,
2313
2459
  data: {
@@ -2399,7 +2545,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2399
2545
  strapi2.documents(SESSION_UID$1).update({
2400
2546
  documentId: session2.documentId,
2401
2547
  data: {
2402
- geoLocation: JSON.stringify(geoLocation),
2548
+ geoLocation,
2403
2549
  securityScore: geoData.securityScore || null
2404
2550
  }
2405
2551
  }).catch(() => {
@@ -2449,7 +2595,8 @@ var session$1 = ({ strapi: strapi2 }) => {
2449
2595
  const sessions = await strapi2.documents(SESSION_UID$1).findMany({
2450
2596
  filters: { isActive: true },
2451
2597
  populate: { user: { fields: ["documentId", "email", "username"] } },
2452
- sort: { loginTime: "desc" }
2598
+ sort: { loginTime: "desc" },
2599
+ limit: 1e3
2453
2600
  });
2454
2601
  const config2 = strapi2.config.get("plugin::magic-sessionmanager") || {};
2455
2602
  const inactivityTimeout = config2.inactivityTimeout || 15 * 60 * 1e3;
@@ -2486,7 +2633,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2486
2633
  strapi2.documents(SESSION_UID$1).update({
2487
2634
  documentId: session2.documentId,
2488
2635
  data: {
2489
- geoLocation: JSON.stringify(geoLocation),
2636
+ geoLocation,
2490
2637
  securityScore: geoData.securityScore || null
2491
2638
  }
2492
2639
  }).catch(() => {
@@ -2579,7 +2726,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2579
2726
  strapi2.documents(SESSION_UID$1).update({
2580
2727
  documentId: session2.documentId,
2581
2728
  data: {
2582
- geoLocation: JSON.stringify(geoLocation),
2729
+ geoLocation,
2583
2730
  securityScore: geoData.securityScore || null
2584
2731
  }
2585
2732
  }).catch(() => {
@@ -2716,20 +2863,36 @@ var session$1 = ({ strapi: strapi2 }) => {
2716
2863
  }
2717
2864
  },
2718
2865
  /**
2719
- * Delete all inactive sessions from database
2866
+ * Delete all inactive sessions from database in batches
2720
2867
  * WARNING: This permanently deletes records!
2721
2868
  * @returns {Promise<number>} Number of deleted sessions
2722
2869
  */
2723
2870
  async deleteInactiveSessions() {
2724
2871
  try {
2725
2872
  log.info("[DELETE] Deleting all inactive sessions...");
2726
- const inactiveSessions = await strapi2.documents(SESSION_UID$1).findMany({
2727
- filters: { isActive: false }
2728
- });
2729
2873
  let deletedCount = 0;
2730
- for (const session2 of inactiveSessions) {
2731
- await strapi2.documents(SESSION_UID$1).delete({ documentId: session2.documentId });
2732
- deletedCount++;
2874
+ const BATCH_SIZE = 100;
2875
+ let hasMore = true;
2876
+ while (hasMore) {
2877
+ const batch = await strapi2.documents(SESSION_UID$1).findMany({
2878
+ filters: { isActive: false },
2879
+ fields: ["documentId"],
2880
+ limit: BATCH_SIZE
2881
+ });
2882
+ if (!batch || batch.length === 0) {
2883
+ hasMore = false;
2884
+ break;
2885
+ }
2886
+ const deleteResults = await Promise.allSettled(
2887
+ batch.map(
2888
+ (session2) => strapi2.documents(SESSION_UID$1).delete({ documentId: session2.documentId })
2889
+ )
2890
+ );
2891
+ const batchDeleted = deleteResults.filter((r) => r.status === "fulfilled").length;
2892
+ deletedCount += batchDeleted;
2893
+ if (batch.length < BATCH_SIZE) {
2894
+ hasMore = false;
2895
+ }
2733
2896
  }
2734
2897
  log.info(`[SUCCESS] Deleted ${deletedCount} inactive sessions`);
2735
2898
  return deletedCount;
@@ -2740,7 +2903,7 @@ var session$1 = ({ strapi: strapi2 }) => {
2740
2903
  }
2741
2904
  };
2742
2905
  };
2743
- const version$1 = "4.4.3";
2906
+ const version$1 = "4.4.5";
2744
2907
  const require$$2 = {
2745
2908
  version: version$1
2746
2909
  };
@@ -3014,7 +3177,7 @@ var geolocation$1 = ({ strapi: strapi2 }) => ({
3014
3177
  */
3015
3178
  async getIpInfo(ipAddress) {
3016
3179
  try {
3017
- if (!ipAddress || ipAddress === "127.0.0.1" || ipAddress === "::1" || ipAddress.startsWith("192.168.") || ipAddress.startsWith("10.")) {
3180
+ if (!ipAddress || this.isPrivateIp(ipAddress)) {
3018
3181
  return {
3019
3182
  ip: ipAddress,
3020
3183
  country: "Local Network",
@@ -3117,6 +3280,26 @@ var geolocation$1 = ({ strapi: strapi2 }) => ({
3117
3280
  const codePoints = countryCode.toUpperCase().split("").map((char) => 127397 + char.charCodeAt());
3118
3281
  return String.fromCodePoint(...codePoints);
3119
3282
  },
3283
+ /**
3284
+ * Checks if an IP address is private/local (RFC 1918, RFC 4193, loopback, link-local)
3285
+ * @param {string} ip - IP address to check
3286
+ * @returns {boolean} True if IP is private/local
3287
+ */
3288
+ isPrivateIp(ip) {
3289
+ if (!ip || ip === "unknown") return true;
3290
+ if (ip === "127.0.0.1" || ip === "localhost" || ip === "::1") return true;
3291
+ if (ip.startsWith("192.168.")) return true;
3292
+ if (ip.startsWith("10.")) return true;
3293
+ if (ip.startsWith("172.")) {
3294
+ const second = parseInt(ip.split(".")[1], 10);
3295
+ if (second >= 16 && second <= 31) return true;
3296
+ }
3297
+ if (ip.startsWith("169.254.")) return true;
3298
+ if (ip.startsWith("fc00:") || ip.startsWith("fd00:")) return true;
3299
+ if (ip.startsWith("fe80:")) return true;
3300
+ if (ip === "::1") return true;
3301
+ return false;
3302
+ },
3120
3303
  /**
3121
3304
  * Fallback data when API fails
3122
3305
  */
@@ -3252,25 +3435,36 @@ VPN: {{reason.isVpn}}, Proxy: {{reason.isProxy}}`
3252
3435
  };
3253
3436
  },
3254
3437
  /**
3255
- * Replace template variables with actual values
3438
+ * Escapes HTML special characters to prevent XSS in email templates
3439
+ * @param {string} str - String to escape
3440
+ * @returns {string} HTML-safe string
3441
+ */
3442
+ escapeHtml(str2) {
3443
+ if (!str2 || typeof str2 !== "string") return str2 || "";
3444
+ return str2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3445
+ },
3446
+ /**
3447
+ * Replace template variables with HTML-escaped actual values
3448
+ * SECURITY: All dynamic values are HTML-escaped to prevent XSS via email
3256
3449
  */
3257
3450
  replaceVariables(template2, data) {
3258
3451
  let result = template2;
3259
- result = result.replace(/\{\{user\.email\}\}/g, data.user?.email || "N/A");
3260
- result = result.replace(/\{\{user\.username\}\}/g, data.user?.username || "N/A");
3452
+ const esc2 = this.escapeHtml.bind(this);
3453
+ result = result.replace(/\{\{user\.email\}\}/g, esc2(data.user?.email || "N/A"));
3454
+ result = result.replace(/\{\{user\.username\}\}/g, esc2(data.user?.username || "N/A"));
3261
3455
  result = result.replace(
3262
3456
  /\{\{session\.loginTime\}\}/g,
3263
- data.session?.loginTime ? new Date(data.session.loginTime).toLocaleString() : "N/A"
3457
+ esc2(data.session?.loginTime ? new Date(data.session.loginTime).toLocaleString() : "N/A")
3264
3458
  );
3265
- result = result.replace(/\{\{session\.ipAddress\}\}/g, data.session?.ipAddress || "N/A");
3266
- result = result.replace(/\{\{session\.userAgent\}\}/g, data.session?.userAgent || "N/A");
3267
- result = result.replace(/\{\{geo\.city\}\}/g, data.geoData?.city || "Unknown");
3268
- result = result.replace(/\{\{geo\.country\}\}/g, data.geoData?.country || "Unknown");
3269
- result = result.replace(/\{\{geo\.timezone\}\}/g, data.geoData?.timezone || "Unknown");
3459
+ result = result.replace(/\{\{session\.ipAddress\}\}/g, esc2(data.session?.ipAddress || "N/A"));
3460
+ result = result.replace(/\{\{session\.userAgent\}\}/g, esc2(data.session?.userAgent || "N/A"));
3461
+ result = result.replace(/\{\{geo\.city\}\}/g, esc2(data.geoData?.city || "Unknown"));
3462
+ result = result.replace(/\{\{geo\.country\}\}/g, esc2(data.geoData?.country || "Unknown"));
3463
+ result = result.replace(/\{\{geo\.timezone\}\}/g, esc2(data.geoData?.timezone || "Unknown"));
3270
3464
  result = result.replace(/\{\{reason\.isVpn\}\}/g, data.reason?.isVpn ? "Yes" : "No");
3271
3465
  result = result.replace(/\{\{reason\.isProxy\}\}/g, data.reason?.isProxy ? "Yes" : "No");
3272
3466
  result = result.replace(/\{\{reason\.isThreat\}\}/g, data.reason?.isThreat ? "Yes" : "No");
3273
- result = result.replace(/\{\{reason\.securityScore\}\}/g, data.reason?.securityScore || "0");
3467
+ result = result.replace(/\{\{reason\.securityScore\}\}/g, esc2(String(data.reason?.securityScore || "0")));
3274
3468
  return result;
3275
3469
  },
3276
3470
  /**
@@ -39046,7 +39240,7 @@ const dist = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty
39046
39240
  const require$$0 = /* @__PURE__ */ getAugmentedNamespace(dist);
39047
39241
  const SESSION_UID = "plugin::magic-sessionmanager.session";
39048
39242
  const { errors } = require$$0;
39049
- var sessionRequired$1 = async (policyContext, config2, { strapi: strapi2 }) => {
39243
+ var sessionRequired$1 = async (policyContext, _policyConfig, { strapi: strapi2 }) => {
39050
39244
  if (!policyContext.state.user) {
39051
39245
  return true;
39052
39246
  }
@@ -39064,8 +39258,8 @@ var sessionRequired$1 = async (policyContext, config2, { strapi: strapi2 }) => {
39064
39258
  if (!userDocId) {
39065
39259
  return true;
39066
39260
  }
39067
- const config3 = strapi2.config.get("plugin::magic-sessionmanager") || {};
39068
- const strictMode = config3.strictSessionEnforcement === true;
39261
+ const pluginConfig = strapi2.config.get("plugin::magic-sessionmanager") || {};
39262
+ const strictMode = pluginConfig.strictSessionEnforcement === true;
39069
39263
  const activeSessions = await strapi2.documents(SESSION_UID).findMany({
39070
39264
  filters: {
39071
39265
  user: { documentId: userDocId },