webspresso 0.0.102 → 0.0.104

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.
Files changed (91) hide show
  1. package/.agents/ORM.md +13 -4
  2. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  3. package/README.md +2 -2
  4. package/bin/commands/dev.js +3 -2
  5. package/bin/utils/orm-map-html.js +2 -8
  6. package/core/auth/jwt.js +12 -21
  7. package/core/auth/oauth.js +38 -14
  8. package/core/auth/totp.js +3 -15
  9. package/core/auth/user-agent.js +59 -60
  10. package/core/compression/compression-stream.js +2 -2
  11. package/core/compression/middleware.js +1 -1
  12. package/core/compression/negotiate.js +11 -4
  13. package/core/content/renderer.js +1 -13
  14. package/core/errors/middleware.js +2 -8
  15. package/core/events/dispatcher.js +56 -33
  16. package/core/forms/form-builder.js +1 -9
  17. package/core/openapi/orm-components.js +2 -3
  18. package/core/orm/cache/listeners.js +12 -2
  19. package/core/orm/index.js +5 -0
  20. package/core/orm/json-fields.js +7 -1
  21. package/core/orm/model.js +99 -8
  22. package/core/orm/query-builder.js +71 -25
  23. package/core/orm/query-filters.js +393 -0
  24. package/core/orm/repository.js +42 -17
  25. package/core/orm/schema-helpers.js +49 -0
  26. package/core/orm/scopes.js +40 -9
  27. package/core/orm/types.js +12 -1
  28. package/core/queue/manager.js +5 -0
  29. package/core/shutdown/shutdown-manager.js +16 -16
  30. package/core/utils/crypto.js +73 -0
  31. package/core/utils/escape-html.js +7 -0
  32. package/core/utils/fetch.js +120 -0
  33. package/core/utils/get-client-ip.js +6 -0
  34. package/core/utils/html.js +72 -0
  35. package/core/utils/ip.js +86 -0
  36. package/core/utils/safe-compare.js +3 -0
  37. package/core/validation/index.js +9 -3
  38. package/index.d.ts +33 -6
  39. package/index.js +10 -0
  40. package/package.json +2 -1
  41. package/plugins/admin-panel/auth.js +15 -7
  42. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  43. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  44. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  45. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  46. package/plugins/admin-panel/core/api-extensions.js +1 -75
  47. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  48. package/plugins/api-key/crypto.js +1 -24
  49. package/plugins/audit-log/api-handlers.js +11 -1
  50. package/plugins/audit-log/middleware.js +50 -6
  51. package/plugins/basic-auth/index.js +2 -26
  52. package/plugins/cors.js +2 -2
  53. package/plugins/csrf/index.js +3 -15
  54. package/plugins/email/service.js +32 -1
  55. package/plugins/file-manager/admin-component.js +3 -1
  56. package/plugins/file-manager/api-handlers.js +10 -17
  57. package/plugins/file-manager/core.js +34 -4
  58. package/plugins/file-manager/index.js +11 -9
  59. package/plugins/file-manager/security.js +18 -6
  60. package/plugins/mcp/auth.js +1 -13
  61. package/plugins/mcp/index.js +23 -1
  62. package/plugins/mcp/transports/sse.js +10 -1
  63. package/plugins/orm-cache-admin/admin-component.js +3 -2
  64. package/plugins/polar/src/api.js +9 -2
  65. package/plugins/polar/src/webhooks.js +3 -8
  66. package/plugins/recaptcha/helpers.js +1 -7
  67. package/plugins/recaptcha/verify.js +37 -23
  68. package/plugins/redirect/index.js +51 -3
  69. package/plugins/rest-resources/index.js +1 -207
  70. package/plugins/schema-explorer/admin-component.js +4 -2
  71. package/plugins/schema-explorer.js +40 -29
  72. package/plugins/seo-checker/index.js +5 -2
  73. package/plugins/site-analytics/bot-patterns.js +3 -0
  74. package/plugins/site-analytics/client-error-handler.js +3 -1
  75. package/plugins/site-analytics/index.js +10 -1
  76. package/plugins/site-analytics/tracking.js +265 -63
  77. package/plugins/sitemap.js +18 -15
  78. package/plugins/swagger.js +9 -14
  79. package/plugins/webhooks/crypto.js +1 -18
  80. package/plugins/webhooks/dispatcher.js +6 -1
  81. package/plugins/webhooks/matcher.js +25 -10
  82. package/plugins/webhooks/sender.js +32 -2
  83. package/src/api/api-loader.js +1 -1
  84. package/src/dev-reload/index.js +47 -6
  85. package/src/discovery/scan-routes.js +14 -4
  86. package/src/file-router.js +62 -6
  87. package/src/helpers.js +157 -32
  88. package/src/plugin-manager.js +34 -13
  89. package/src/routing/route-table.js +14 -4
  90. package/src/server.js +25 -9
  91. package/src/services/validator.js +57 -32
package/index.js CHANGED
@@ -219,6 +219,12 @@ module.exports = {
219
219
  defineSignal: require('./core/events').defineSignal,
220
220
  defineListener: require('./core/events').defineListener,
221
221
  loadListeners: require('./core/events').loadListeners,
222
+
223
+ // Shared Fetch, Crypto & HTML Utilities
224
+ fetchWithTimeout: require('./core/utils/fetch').fetchWithTimeout,
225
+ safeCompare: require('./core/utils/crypto').safeCompare,
226
+ escapeHtml: require('./core/utils/html').escapeHtml,
227
+ unescapeHtml: require('./core/utils/html').unescapeHtml,
222
228
  };
223
229
 
224
230
  // Define lazy getters for plugins, ORM, and heavy core subsystems
@@ -319,6 +325,10 @@ Object.defineProperties(module.exports, {
319
325
  generateQrDataUri: { get: () => require('./core/auth/qrcode').generateQrDataUri, enumerable: true, configurable: true },
320
326
  createAuthMiddleware: { get: () => require('./core/auth').createAuthMiddleware, enumerable: true, configurable: true },
321
327
  setupAuthMiddleware: { get: () => require('./core/auth').setupAuthMiddleware, enumerable: true, configurable: true },
328
+ getClientIp: { get: () => require('./core/utils/ip').getClientIp, enumerable: true, configurable: true },
329
+ parseUserAgent: { get: () => require('./core/auth').parseUserAgent, enumerable: true, configurable: true },
330
+ sessions: { get: () => require('./core/auth').sessions, enumerable: true, configurable: true },
331
+ SessionManager: { get: () => require('./core/auth').SessionManager, enumerable: true, configurable: true },
322
332
 
323
333
  // Validation
324
334
  z: { get: () => require('./core/validation').z, enumerable: true, configurable: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.102",
3
+ "version": "0.0.104",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -176,6 +176,7 @@
176
176
  "dotenv": "^16.3.1",
177
177
  "express-rate-limit": "^8.4.1",
178
178
  "release-it": "^20.2.1",
179
+ "softscope": "^0.3.2",
179
180
  "supertest": "^6.3.4",
180
181
  "typescript": "~5.6.3",
181
182
  "vitest": "^4.1.11",
@@ -115,13 +115,21 @@ async function login(adminUserRepo, email, password, compare) {
115
115
  */
116
116
  async function logout(req, res) {
117
117
  return new Promise((resolve, reject) => {
118
- req.session.destroy((err) => {
119
- if (err) {
120
- reject(err);
121
- } else {
122
- resolve();
123
- }
124
- });
118
+ if (!req || !req.session) {
119
+ return resolve();
120
+ }
121
+ if (typeof req.session.destroy === 'function') {
122
+ req.session.destroy((err) => {
123
+ if (err) {
124
+ reject(err);
125
+ } else {
126
+ resolve();
127
+ }
128
+ });
129
+ } else {
130
+ delete req.session.adminUser;
131
+ resolve();
132
+ }
125
133
  });
126
134
  }
127
135
 
@@ -1,14 +1,82 @@
1
1
 
2
+ /**
3
+ * Shared client-side fetch wrapper with AbortController, timeout, and signal composition.
4
+ * @param {string} url
5
+ * @param {RequestInit & { timeout?: number }} [options]
6
+ * @returns {Promise<Response>}
7
+ */
8
+ async function fetchWithTimeout(url, options = {}) {
9
+ const { timeout = 30000, signal, ...fetchOptions } = options;
10
+
11
+ if (signal && signal.aborted) {
12
+ const reason = (signal.reason !== undefined && signal.reason !== null)
13
+ ? signal.reason
14
+ : new Error('This operation was aborted');
15
+ throw reason;
16
+ }
17
+
18
+ if (typeof AbortController === 'undefined') {
19
+ return fetch(url, { ...fetchOptions, signal });
20
+ }
21
+
22
+ const controller = new AbortController();
23
+ let timer = null;
24
+ let callerSignalListener = null;
25
+
26
+ if (typeof timeout === 'number' && timeout > 0 && typeof setTimeout !== 'undefined') {
27
+ timer = setTimeout(() => {
28
+ const err = new Error('Request timed out after ' + timeout + 'ms');
29
+ err.name = 'TimeoutError';
30
+ err.code = 'ETIMEDOUT';
31
+ controller.abort(err);
32
+ }, timeout);
33
+ }
34
+
35
+ if (signal) {
36
+ callerSignalListener = () => {
37
+ controller.abort(signal.reason);
38
+ };
39
+ signal.addEventListener('abort', callerSignalListener, { once: true });
40
+ }
41
+
42
+ try {
43
+ return await fetch(url, {
44
+ ...fetchOptions,
45
+ signal: controller.signal,
46
+ });
47
+ } catch (err) {
48
+ if (controller.signal.aborted && controller.signal.reason) {
49
+ const reason = controller.signal.reason;
50
+ if (reason instanceof Error && (reason.name === 'TimeoutError' || reason.code === 'ETIMEDOUT')) {
51
+ throw reason;
52
+ }
53
+ }
54
+ if (err && err.name === 'AbortError' && controller.signal.aborted && controller.signal.reason) {
55
+ throw controller.signal.reason;
56
+ }
57
+ throw err;
58
+ } finally {
59
+ if (timer) clearTimeout(timer);
60
+ if (signal && callerSignalListener) {
61
+ signal.removeEventListener('abort', callerSignalListener);
62
+ }
63
+ }
64
+ }
65
+
2
66
  // API helper
3
67
  const api = {
68
+ fetch: fetchWithTimeout,
4
69
  async request(path, options = {}) {
5
70
  const adminPath = window.__ADMIN_PATH__ || '/_admin';
6
71
  const url = adminPath + '/api' + path;
7
- const response = await fetch(url, {
8
- ...options,
72
+ const { timeout = 30000, signal, ...fetchOpts } = options;
73
+ const response = await fetchWithTimeout(url, {
74
+ ...fetchOpts,
75
+ timeout,
76
+ signal,
9
77
  headers: {
10
78
  'Content-Type': 'application/json',
11
- ...options.headers,
79
+ ...fetchOpts.headers,
12
80
  },
13
81
  credentials: 'include',
14
82
  });
@@ -28,20 +96,24 @@ const api = {
28
96
  return response.json();
29
97
  },
30
98
 
31
- get(path) { return this.request(path, { method: 'GET' }); },
32
- post(path, data) { return this.request(path, { method: 'POST', body: JSON.stringify(data) }); },
33
- put(path, data) { return this.request(path, { method: 'PUT', body: JSON.stringify(data) }); },
34
- delete(path) { return this.request(path, { method: 'DELETE' }); },
99
+ get(path, options) { return this.request(path, { ...options, method: 'GET' }); },
100
+ post(path, data, options) { return this.request(path, { ...options, method: 'POST', body: JSON.stringify(data) }); },
101
+ put(path, data, options) { return this.request(path, { ...options, method: 'PUT', body: JSON.stringify(data) }); },
102
+ delete(path, options) { return this.request(path, { ...options, method: 'DELETE' }); },
35
103
  };
36
104
 
37
105
  /** POST /data-exchange/export/:model — validates JSON errors vs .xlsx blob */
38
- async function downloadDataExchangeXlsx(modelName, payload) {
106
+ async function downloadDataExchangeXlsx(modelName, payload, options = {}) {
39
107
  const adminPath = window.__ADMIN_PATH__ || '/_admin';
40
- const res = await fetch(adminPath + '/api/data-exchange/export/' + modelName, {
108
+ const { timeout = 60000, signal, headers = {}, ...restOpts } = options;
109
+ const res = await fetchWithTimeout(adminPath + '/api/data-exchange/export/' + modelName, {
41
110
  method: 'POST',
42
111
  credentials: 'include',
43
- headers: { 'Content-Type': 'application/json' },
112
+ headers: { 'Content-Type': 'application/json', ...headers },
44
113
  body: JSON.stringify(payload),
114
+ timeout,
115
+ signal,
116
+ ...restOpts,
45
117
  });
46
118
  const ct = (res.headers.get('content-type') || '').toLowerCase();
47
119
  if (!res.ok || ct.indexOf('spreadsheet') === -1) {
@@ -572,7 +572,8 @@ async function handleAdminFileUpload(file, onChange, meta, state) {
572
572
  var fd = new FormData();
573
573
  fd.append('file', file);
574
574
  try {
575
- var res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
575
+ var fetchFn = (typeof fetchWithTimeout === 'function' ? fetchWithTimeout : fetch);
576
+ var res = await fetchFn(uploadUrl, { method: 'POST', body: fd, credentials: 'include', timeout: 60000 });
576
577
  var data = {};
577
578
  try { data = await res.json(); } catch (e2) { data = {}; }
578
579
  if (!res.ok) {
@@ -619,7 +620,8 @@ async function handleAdminMultipleFilesUpload(files, onChange, meta, state, curr
619
620
  var fd = new FormData();
620
621
  fd.append('file', file);
621
622
  try {
622
- var res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
623
+ var fetchFn = (typeof fetchWithTimeout === 'function' ? fetchWithTimeout : fetch);
624
+ var res = await fetchFn(uploadUrl, { method: 'POST', body: fd, credentials: 'include', timeout: 60000 });
623
625
  var data = {};
624
626
  try { data = await res.json(); } catch (_) { data = {}; }
625
627
  if (res.ok && (data.url || data.publicUrl)) {
@@ -316,10 +316,11 @@ const RecordList = {
316
316
  const fd = new FormData();
317
317
  fd.append('file', file);
318
318
  try {
319
- const res = await fetch(
319
+ const fetchFn = (typeof fetchWithTimeout === 'function' ? fetchWithTimeout : fetch);
320
+ const res = await fetchFn(
320
321
  adminPath + '/api/data-exchange/import/' + modelName +
321
322
  '?mode=' + encodeURIComponent(mode) + '&upsertKey=' + encodeURIComponent(upsertKey),
322
- { method: 'POST', body: fd, credentials: 'include' }
323
+ { method: 'POST', body: fd, credentials: 'include', timeout: 60000 }
323
324
  );
324
325
  const body = await res.json().catch(function () { return ({}); });
325
326
  if (!res.ok) {
@@ -8,6 +8,8 @@ window.__ADMIN_COMPONENTS__ = {
8
8
  RecordForm,
9
9
  api,
10
10
  state,
11
+ fetch: typeof fetchWithTimeout !== 'undefined' ? fetchWithTimeout : undefined,
12
+ fetchWithTimeout: typeof fetchWithTimeout !== 'undefined' ? fetchWithTimeout : undefined,
11
13
  notify: typeof window !== 'undefined' ? window.notify : undefined,
12
14
  confirmModal: typeof window !== 'undefined' ? window.confirmModal : undefined,
13
15
  toastState: typeof window !== 'undefined' ? window.toastState : undefined,
@@ -22,6 +24,10 @@ if (typeof window !== 'undefined') {
22
24
  window.Admin = window.Admin || {};
23
25
  window.Admin.api = api;
24
26
  window.Admin.state = state;
27
+ if (typeof fetchWithTimeout !== 'undefined') {
28
+ window.Admin.fetch = fetchWithTimeout;
29
+ window.Admin.fetchWithTimeout = fetchWithTimeout;
30
+ }
25
31
  if (typeof window.AdminUI !== 'undefined') {
26
32
  window.Admin.ui = window.AdminUI;
27
33
  }
@@ -7,6 +7,7 @@
7
7
  const os = require('os');
8
8
  const https = require('https');
9
9
  const { sanitizeForOutput } = require('../../../core/orm/utils');
10
+ const { buildFilteredQuery } = require('../../../core/orm/query-filters');
10
11
 
11
12
  function getWebspressoVersion() {
12
13
  try {
@@ -115,82 +116,7 @@ function formatBytes(bytes) {
115
116
  return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
116
117
  }
117
118
 
118
- /**
119
- * Build query with filters applied
120
- * @param {Object} repo - Repository instance
121
- * @param {Object} filters - Filter object from frontend
122
- * @param {Object} [options] - Options
123
- * @param {boolean} [options.onlyTrashed] - For soft delete: only trashed records
124
- * @returns {Object} Query builder with filters applied
125
- */
126
- function buildFilteredQuery(repo, filters, options = {}) {
127
- let query = repo.query();
128
- if (options.onlyTrashed) {
129
- query = query.onlyTrashed();
130
- }
131
-
132
- if (!filters || Object.keys(filters).length === 0) {
133
- return query;
134
- }
135
-
136
- for (const [column, filter] of Object.entries(filters)) {
137
- if (!filter) continue;
138
-
139
- const op = filter.op || filter.operator || 'contains';
140
119
 
141
- if (op === 'is_null') {
142
- query = query.whereNull(column);
143
- continue;
144
- }
145
- if (op === 'is_not_null') {
146
- query = query.whereNotNull(column);
147
- continue;
148
- }
149
-
150
- if (filter.value === '' && !filter.from && !filter.to) continue;
151
-
152
- switch (op) {
153
- case 'contains':
154
- query = query.where(column, 'like', `%${filter.value}%`);
155
- break;
156
- case 'equals':
157
- query = query.where(column, '=', filter.value);
158
- break;
159
- case 'starts_with':
160
- query = query.where(column, 'like', `${filter.value}%`);
161
- break;
162
- case 'ends_with':
163
- query = query.where(column, 'like', `%${filter.value}`);
164
- break;
165
- case 'eq':
166
- query = query.where(column, '=', filter.value);
167
- break;
168
- case 'gt':
169
- query = query.where(column, '>', filter.value);
170
- break;
171
- case 'gte':
172
- query = query.where(column, '>=', filter.value);
173
- break;
174
- case 'lt':
175
- query = query.where(column, '<', filter.value);
176
- break;
177
- case 'lte':
178
- query = query.where(column, '<=', filter.value);
179
- break;
180
- case 'between':
181
- if (filter.from) query = query.where(column, '>=', filter.from);
182
- if (filter.to) query = query.where(column, '<=', filter.to);
183
- break;
184
- case 'in':
185
- if (Array.isArray(filter.value) && filter.value.length > 0) {
186
- query = query.whereIn(column, filter.value);
187
- }
188
- break;
189
- }
190
- }
191
-
192
- return query;
193
- }
194
120
 
195
121
  /**
196
122
  * Get all record IDs matching filters (for selectAll mode)
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { getClientIp } = require('../../../core/utils/ip');
4
+
3
5
  /**
4
6
  * In-memory brute-force rate limiter for admin login.
5
7
  * Zero external dependencies.
@@ -20,19 +22,7 @@ class LoginRateLimiter {
20
22
  }
21
23
 
22
24
  getClientIp(req) {
23
- if (!req) return '127.0.0.1';
24
- if (req.ip) return String(req.ip);
25
- if (req.headers && req.headers['x-forwarded-for']) {
26
- const forwarded = String(req.headers['x-forwarded-for']).split(',')[0].trim();
27
- if (forwarded) return forwarded;
28
- }
29
- if (req.connection && req.connection.remoteAddress) {
30
- return String(req.connection.remoteAddress);
31
- }
32
- if (req.socket && req.socket.remoteAddress) {
33
- return String(req.socket.remoteAddress);
34
- }
35
- return '127.0.0.1';
25
+ return getClientIp(req);
36
26
  }
37
27
 
38
28
  isBlocked(req) {
@@ -111,6 +101,18 @@ class LoginRateLimiter {
111
101
  this.attempts.delete(ip);
112
102
  }
113
103
  }
104
+
105
+ // Hard ceiling to prevent unbounded memory growth under distributed IP attacks
106
+ const MAX_ENTRIES = 5000;
107
+ if (this.attempts.size > MAX_ENTRIES) {
108
+ const excess = this.attempts.size - MAX_ENTRIES;
109
+ let count = 0;
110
+ for (const ip of this.attempts.keys()) {
111
+ this.attempts.delete(ip);
112
+ count++;
113
+ if (count >= excess) break;
114
+ }
115
+ }
114
116
  }
115
117
  }
116
118
 
@@ -8,30 +8,7 @@
8
8
 
9
9
  const crypto = require('crypto');
10
10
 
11
- /**
12
- * Compare two strings in constant time to prevent timing attacks.
13
- * Uses dummy buffer comparison if lengths differ to avoid leaking length via response timing.
14
- *
15
- * @param {string} a
16
- * @param {string} b
17
- * @returns {boolean}
18
- */
19
- function safeCompare(a, b) {
20
- if (typeof a !== 'string' || typeof b !== 'string') {
21
- return false;
22
- }
23
-
24
- const bufA = Buffer.from(a, 'utf8');
25
- const bufB = Buffer.from(b, 'utf8');
26
-
27
- if (bufA.length !== bufB.length) {
28
- // Perform dummy timing-safe comparison with itself to preserve constant execution time
29
- crypto.timingSafeEqual(bufA, bufA);
30
- return false;
31
- }
32
-
33
- return crypto.timingSafeEqual(bufA, bufB);
34
- }
11
+ const { safeCompare } = require('../../core/utils/crypto');
35
12
 
36
13
  /**
37
14
  * Hash a raw API key using SHA-256
@@ -66,8 +66,18 @@ function createAuditLogHandlers(options) {
66
66
  .offset((page - 1) * perPage)
67
67
  .limit(perPage);
68
68
 
69
+ const normalizedRows = rows.map((r) => {
70
+ let parsedMetadata = r.metadata;
71
+ if (typeof parsedMetadata === 'string') {
72
+ try {
73
+ parsedMetadata = JSON.parse(parsedMetadata);
74
+ } catch {}
75
+ }
76
+ return { ...r, metadata: parsedMetadata };
77
+ });
78
+
69
79
  res.json({
70
- data: rows,
80
+ data: normalizedRows,
71
81
  meta: { page, perPage, total },
72
82
  });
73
83
  } catch (err) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  const { parseAdminModelAudit } = require('./parse');
7
+ const { getClientIp } = require('../../core/utils/ip');
7
8
 
8
9
  function stringifyId(val) {
9
10
  if (val === undefined || val === null) {
@@ -81,6 +82,14 @@ function createAuditMiddleware(options) {
81
82
  }
82
83
 
83
84
  const metadata = buildMetadata(action, req);
85
+ let metadataVal = metadata || null;
86
+ if (metadataVal && typeof metadataVal === 'object') {
87
+ const clientName = knex?.client?.config?.client || '';
88
+ if (typeof clientName === 'string' && clientName.includes('sqlite')) {
89
+ metadataVal = JSON.stringify(metadataVal);
90
+ }
91
+ }
92
+
84
93
  const pathStr = (req.originalUrl || req.url || req.path || '').slice(0, 2000);
85
94
  const row = {
86
95
  actor_id: actor.id != null ? Number(actor.id) : null,
@@ -90,16 +99,14 @@ function createAuditMiddleware(options) {
90
99
  resource_id: resourceId,
91
100
  http_method: req.method,
92
101
  path: pathStr,
93
- ip: req.ip || (req.connection && req.connection.remoteAddress) || null,
102
+ ip: getClientIp(req, null),
94
103
  user_agent: req.get && req.get('user-agent') ? req.get('user-agent').slice(0, 2000) : null,
95
- metadata: metadata || null,
104
+ metadata: metadataVal,
96
105
  };
97
106
 
98
- knex(tableName).insert(row).catch((err) => {
99
- console.warn('[audit-log] Failed to insert audit row:', err.message);
100
- });
107
+ knex(tableName).insert(row).catch(logAuditError);
101
108
  } catch (e) {
102
- console.warn('[audit-log] finish handler error:', e.message);
109
+ logAuditError(e, 'finish handler error');
103
110
  }
104
111
  });
105
112
 
@@ -107,9 +114,46 @@ function createAuditMiddleware(options) {
107
114
  };
108
115
  }
109
116
 
117
+ let lastAuditWarnTime = 0;
118
+ let suppressedAuditErrors = 0;
119
+
120
+ /**
121
+ * Format and sanitize DB errors to prevent leaking raw SQL queries
122
+ * @param {Error|Object|string} e
123
+ * @returns {string}
124
+ */
125
+ function formatDbError(e) {
126
+ if (!e) return 'Unknown error';
127
+ let msg;
128
+ if (typeof e.message === 'string' && e.message.includes(' - ')) {
129
+ msg = e.message.split(' - ').pop().trim();
130
+ } else {
131
+ msg = String(e.code || e.message || 'Database error');
132
+ }
133
+ return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
134
+ }
135
+
136
+ function logAuditError(err, prefix = 'Failed to insert audit row') {
137
+ const now = Date.now();
138
+ const formatted = formatDbError(err);
139
+ if (lastAuditWarnTime === 0 || now - lastAuditWarnTime >= 60000) {
140
+ const elapsedSec = lastAuditWarnTime > 0 ? Math.round((now - lastAuditWarnTime) / 1000) : 0;
141
+ if (suppressedAuditErrors > 0) {
142
+ console.warn(`[audit-log] ${prefix} (suppressed ${suppressedAuditErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
143
+ } else {
144
+ console.warn(`[audit-log] ${prefix}: ${formatted}`);
145
+ }
146
+ lastAuditWarnTime = now;
147
+ suppressedAuditErrors = 0;
148
+ } else {
149
+ suppressedAuditErrors++;
150
+ }
151
+ }
152
+
110
153
  module.exports = {
111
154
  createAuditMiddleware,
112
155
  stringifyId,
113
156
  extractResourceIdFromJsonBody,
114
157
  buildMetadata,
158
+ formatDbError,
115
159
  };
@@ -4,32 +4,7 @@
4
4
  * @module plugins/basic-auth
5
5
  */
6
6
 
7
- const crypto = require('crypto');
8
-
9
- /**
10
- * Compare two strings in constant time to prevent timing attacks.
11
- * Uses dummy buffer comparison if lengths differ to avoid leaking length via response timing.
12
- *
13
- * @param {string} a
14
- * @param {string} b
15
- * @returns {boolean}
16
- */
17
- function safeCompare(a, b) {
18
- if (typeof a !== 'string' || typeof b !== 'string') {
19
- return false;
20
- }
21
-
22
- const bufA = Buffer.from(a, 'utf8');
23
- const bufB = Buffer.from(b, 'utf8');
24
-
25
- if (bufA.length !== bufB.length) {
26
- // Perform dummy timing-safe comparison with itself to preserve constant execution time
27
- crypto.timingSafeEqual(bufA, bufA);
28
- return false;
29
- }
30
-
31
- return crypto.timingSafeEqual(bufA, bufB);
32
- }
7
+ const { safeCompare } = require('../../core/utils/crypto');
33
8
 
34
9
  /**
35
10
  * Parse an HTTP Authorization header for Basic credentials.
@@ -69,6 +44,7 @@ function parseBasicAuthHeader(header) {
69
44
  * @returns {{ username: string, password: string } | null}
70
45
  */
71
46
  function extractCredentials(req) {
47
+ if (!req || !req.headers) return null;
72
48
  const header = req.headers.authorization || req.headers.Authorization;
73
49
  return parseBasicAuthHeader(header);
74
50
  }
package/plugins/cors.js CHANGED
@@ -105,7 +105,7 @@ function createCorsMiddleware(options = {}) {
105
105
  return next();
106
106
  }
107
107
 
108
- const reqOrigin = req.headers.origin;
108
+ const reqOrigin = req.headers?.origin;
109
109
 
110
110
  isOriginAllowed(reqOrigin, (err, allowedOrigin) => {
111
111
  if (err) {
@@ -134,7 +134,7 @@ function createCorsMiddleware(options = {}) {
134
134
  }
135
135
  if (allowedHeadersStr) {
136
136
  res.setHeader('Access-Control-Allow-Headers', allowedHeadersStr);
137
- } else if (req.headers['access-control-request-headers']) {
137
+ } else if (req.headers?.['access-control-request-headers']) {
138
138
  res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
139
139
  }
140
140
  if (config.maxAge) {
@@ -15,21 +15,7 @@ const NOOP = () => {};
15
15
  const EMPTY_OBJECT = Object.freeze({});
16
16
  const ignoreRegexCache = new Map();
17
17
 
18
- /**
19
- * Compare two strings in constant time to prevent timing attacks
20
- * @param {string} a
21
- * @param {string} b
22
- * @returns {boolean}
23
- */
24
- function safeCompare(a, b) {
25
- if (typeof a !== 'string' || typeof b !== 'string') return false;
26
- if (a.length !== b.length) return false;
27
- try {
28
- return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
29
- } catch (err) {
30
- return false;
31
- }
32
- }
18
+ const { safeCompare } = require('../../core/utils/crypto');
33
19
 
34
20
  /**
35
21
  * Check if the request path matches any ignore patterns
@@ -241,3 +227,5 @@ function csrfPlugin(options = {}) {
241
227
 
242
228
  module.exports = csrfPlugin;
243
229
  module.exports.csrfPlugin = csrfPlugin;
230
+ module.exports.safeCompare = safeCompare;
231
+ csrfPlugin.safeCompare = safeCompare;
@@ -30,6 +30,37 @@ function createEmailService(options) {
30
30
  console.warn('[email]', err.message);
31
31
  }
32
32
 
33
+ let lastEmailLogWarnTime = 0;
34
+ let suppressedEmailLogErrors = 0;
35
+
36
+ function formatDbError(e) {
37
+ if (!e) return 'Unknown error';
38
+ let msg;
39
+ if (typeof e.message === 'string' && e.message.includes(' - ')) {
40
+ msg = e.message.split(' - ').pop().trim();
41
+ } else {
42
+ msg = String(e.code || e.message || 'Database error');
43
+ }
44
+ return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
45
+ }
46
+
47
+ function logEmailError(err) {
48
+ const now = Date.now();
49
+ const formatted = formatDbError(err);
50
+ if (lastEmailLogWarnTime === 0 || now - lastEmailLogWarnTime >= 60000) {
51
+ const elapsedSec = lastEmailLogWarnTime > 0 ? Math.round((now - lastEmailLogWarnTime) / 1000) : 0;
52
+ if (suppressedEmailLogErrors > 0) {
53
+ console.warn(`[email] Failed to write email log (suppressed ${suppressedEmailLogErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
54
+ } else {
55
+ console.warn('[email] Failed to write email log:', formatted);
56
+ }
57
+ lastEmailLogWarnTime = now;
58
+ suppressedEmailLogErrors = 0;
59
+ } else {
60
+ suppressedEmailLogErrors++;
61
+ }
62
+ }
63
+
33
64
  /**
34
65
  * @param {Object} row
35
66
  */
@@ -49,7 +80,7 @@ function createEmailService(options) {
49
80
  created_at: new Date(),
50
81
  });
51
82
  } catch (err) {
52
- console.warn('[email] Failed to write email log:', err.message);
83
+ logEmailError(err);
53
84
  }
54
85
  }
55
86