@getstrata/core 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/core/auth/authContext.d.ts +5 -2
  3. package/dist/core/auth/emailVerification.d.ts +1 -1
  4. package/dist/core/auth/guard.d.ts +5 -1
  5. package/dist/core/auth/jwtGuard.d.ts +6 -2
  6. package/dist/core/auth/oauth/oidcIdToken.d.ts +19 -0
  7. package/dist/core/auth/oauth/oidcProvider.d.ts +16 -4
  8. package/dist/core/auth/oauth/samlProvider.d.ts +7 -4
  9. package/dist/core/auth/oneTimeToken.d.ts +19 -0
  10. package/dist/core/auth/passwordLogin.d.ts +20 -0
  11. package/dist/core/auth/saml/samlAssertionReplay.d.ts +20 -0
  12. package/dist/core/auth/saml/samlServiceProvider.d.ts +51 -0
  13. package/dist/core/contracts/authUserDirectory.d.ts +2 -3
  14. package/dist/core/database/errors.d.ts +2 -1
  15. package/dist/core/http/response.d.ts +3 -1
  16. package/dist/core/mail/mailer.d.ts +6 -1
  17. package/dist/core/security/oauthState.d.ts +6 -2
  18. package/dist/core/security/safeFetch.d.ts +1 -0
  19. package/dist/core/security/safePath.d.ts +4 -0
  20. package/dist/core/security/safeUrl.d.ts +14 -1
  21. package/dist/core/security/totp.d.ts +1 -1
  22. package/dist/core/tenant/databaseTenantContext.d.ts +2 -1
  23. package/dist/core/tenant/enableTenantRls.d.ts +6 -0
  24. package/dist/core/tenant/postgresAppRole.d.ts +75 -0
  25. package/dist/entries/audit/exportAuditLogs.js +223 -40
  26. package/dist/entries/auth/accessControl.js +1 -0
  27. package/dist/entries/auth/basicAuthGuard.js +638 -17
  28. package/dist/entries/auth/emailVerification.js +1 -1
  29. package/dist/entries/auth/intendedUrlCookie.js +32 -2
  30. package/dist/entries/auth/jwt.js +1 -1
  31. package/dist/entries/auth/jwtGuard.js +192 -3
  32. package/dist/entries/auth/membershipMiddleware.js +1 -0
  33. package/dist/entries/auth/membershipScope.js +1 -0
  34. package/dist/entries/auth/membershipService.js +1 -0
  35. package/dist/entries/auth/oauth/oidcProvider.js +542 -20
  36. package/dist/entries/auth/oauth/providers.js +313 -5
  37. package/dist/entries/auth/oauth/samlProvider.js +552 -74
  38. package/dist/entries/auth/oneTimeToken.js +362 -0
  39. package/dist/entries/auth/password.js +1 -1
  40. package/dist/entries/auth/passwordLogin.js +548 -0
  41. package/dist/entries/auth/policy.js +1 -0
  42. package/dist/entries/auth/saml/samlServiceProvider.js +571 -0
  43. package/dist/entries/auth/scimAuthMiddleware.js +10 -3
  44. package/dist/entries/auth/sessionCookie.js +1 -1
  45. package/dist/entries/auth/sessionGuard.js +1 -1
  46. package/dist/entries/auth/tokenHash.js +2 -6
  47. package/dist/entries/crypto/fieldEncryption.js +1 -1
  48. package/dist/entries/crypto/mfaSecret.js +31 -5
  49. package/dist/entries/database/errors.js +29 -12
  50. package/dist/entries/database/model.js +9 -9
  51. package/dist/entries/facades.js +72 -9
  52. package/dist/entries/http/authMiddleware.js +7 -14
  53. package/dist/entries/http/corsMiddleware.js +69 -2
  54. package/dist/entries/http/csrfMiddleware.js +33 -43
  55. package/dist/entries/http/csrfToken.js +2 -2
  56. package/dist/entries/http/flashMiddleware.js +4 -2
  57. package/dist/entries/http/flashSession.js +4 -2
  58. package/dist/entries/http/formRequest.js +2 -2
  59. package/dist/entries/http/memoryThrottleMiddleware.js +63 -0
  60. package/dist/entries/http/pagination.js +2 -2
  61. package/dist/entries/http/requirePasswordConfirmMiddleware.js +36 -3
  62. package/dist/entries/http/requireWebAuthMiddleware.js +32 -2
  63. package/dist/entries/http/response.js +101 -45
  64. package/dist/entries/http/routeModelBinding.js +2 -2
  65. package/dist/entries/http/safeInternalPath.js +32 -2
  66. package/dist/entries/http/scimThrottleMiddleware.js +7 -1
  67. package/dist/entries/http/securedRouteModelBinding.js +2 -2
  68. package/dist/entries/http/securityHeadersMiddleware.js +3 -64
  69. package/dist/entries/http/signedUrl.js +6 -5
  70. package/dist/entries/http/throttleMiddleware.js +63 -0
  71. package/dist/entries/http/validation.js +2 -2
  72. package/dist/entries/http/webFormRequest.js +2 -2
  73. package/dist/entries/jobs/exportAuditLogsJob.js +223 -40
  74. package/dist/entries/mail/mailer.js +54 -8
  75. package/dist/entries/media/imageTransform.js +12 -1
  76. package/dist/entries/openapi/generator.js +0 -7
  77. package/dist/entries/security/oauthState.js +30 -39
  78. package/dist/entries/security/publicReads.js +1 -1
  79. package/dist/entries/security/recoveryCodes.js +2 -2
  80. package/dist/entries/security/safeFetch.js +195 -94
  81. package/dist/entries/security/safePath.js +25 -0
  82. package/dist/entries/security/scimTenantTokens.js +10 -3
  83. package/dist/entries/security/totp.js +16 -4
  84. package/dist/entries/storage/storage.js +24 -2
  85. package/dist/entries/tenant/databaseTenantContext.js +34 -12
  86. package/dist/entries/tenant/enableTenantRls.js +385 -0
  87. package/dist/entries/tracing/tracingMiddleware.js +308 -5
  88. package/dist/framework/public-api.d.ts +10 -3
  89. package/dist/index.js +2767 -1515
  90. package/package.json +32 -3
@@ -96,34 +96,144 @@ var BLOCKED_HOSTNAMES = new Set([
96
96
  "::1",
97
97
  "metadata.google.internal"
98
98
  ]);
99
- function isPrivateIpv4(hostname) {
100
- const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
101
- if (!match) {
102
- return false;
99
+ function parseCanonicalDottedDecimal(hostname) {
100
+ const parts = hostname.split(".");
101
+ if (parts.length !== 4) {
102
+ return null;
103
103
  }
104
- const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
105
- if (octets.some((octet) => octet < 0 || octet > 255)) {
106
- return true;
104
+ const octets = [];
105
+ for (const part of parts) {
106
+ if (!/^(0|[1-9]\d{0,2})$/u.test(part)) {
107
+ return null;
108
+ }
109
+ const value = Number.parseInt(part, 10);
110
+ if (!Number.isInteger(value) || value < 0 || value > 255) {
111
+ return null;
112
+ }
113
+ octets.push(value);
107
114
  }
115
+ return octets;
116
+ }
117
+ function parseDottedIpv4(hostname) {
118
+ return parseCanonicalDottedDecimal(hostname);
119
+ }
120
+ function isBlockedIpv4Octets(octets) {
108
121
  const [a = 0, b = 0] = octets;
109
- if (a === 10) {
122
+ if (a === 10 || a === 127 || a === 0) {
110
123
  return true;
111
124
  }
112
- if (a === 127) {
125
+ if (a === 169 && b === 254) {
113
126
  return true;
114
127
  }
115
- if (a === 0) {
128
+ if (a === 172 && b >= 16 && b <= 31) {
116
129
  return true;
117
130
  }
118
- if (a === 169 && b === 254) {
131
+ if (a === 192 && b === 168) {
119
132
  return true;
120
133
  }
121
- if (a === 172 && b >= 16 && b <= 31) {
134
+ if (a === 100 && b >= 64 && b <= 127) {
122
135
  return true;
123
136
  }
124
- if (a === 192 && b === 168) {
137
+ if (a === 198 && (b === 18 || b === 19)) {
138
+ return true;
139
+ }
140
+ return false;
141
+ }
142
+ function expandIpv6(hostname) {
143
+ const trimmed = hostname.trim().toLowerCase();
144
+ const unbracketed = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
145
+ if (unbracketed.includes("%")) {
146
+ return null;
147
+ }
148
+ const mapped = unbracketed.match(/^::ffff:([0-9a-fx.]+)$/iu);
149
+ if (mapped?.[1]) {
150
+ const octets = parseDottedIpv4(mapped[1]);
151
+ if (!octets) {
152
+ return null;
153
+ }
154
+ return [
155
+ 0,
156
+ 0,
157
+ 0,
158
+ 0,
159
+ 0,
160
+ 65535,
161
+ octets[0] << 8 | octets[1],
162
+ octets[2] << 8 | octets[3]
163
+ ];
164
+ }
165
+ const halves = unbracketed.split("::");
166
+ if (halves.length > 2) {
167
+ return null;
168
+ }
169
+ const parseGroup = (part) => {
170
+ if (!/^[0-9a-f]{1,4}$/u.test(part)) {
171
+ return null;
172
+ }
173
+ return Number.parseInt(part, 16);
174
+ };
175
+ if (halves.length === 1) {
176
+ const groups = unbracketed.split(":");
177
+ if (groups.length !== 8) {
178
+ return null;
179
+ }
180
+ const values = groups.map(parseGroup);
181
+ return values.every((value) => value !== null) ? values : null;
182
+ }
183
+ const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
184
+ const tail = halves[1] ? halves[1].split(":").filter(Boolean) : [];
185
+ if (head.length + tail.length > 8) {
186
+ return null;
187
+ }
188
+ const values = [];
189
+ for (const part of head) {
190
+ const value = parseGroup(part);
191
+ if (value === null) {
192
+ return null;
193
+ }
194
+ values.push(value);
195
+ }
196
+ while (values.length < 8 - tail.length) {
197
+ values.push(0);
198
+ }
199
+ for (const part of tail) {
200
+ const value = parseGroup(part);
201
+ if (value === null) {
202
+ return null;
203
+ }
204
+ values.push(value);
205
+ }
206
+ return values.length === 8 ? values : null;
207
+ }
208
+ function isBlockedIpv6(hostname) {
209
+ const groups = expandIpv6(hostname);
210
+ if (!groups) {
211
+ return hostname.includes(":");
212
+ }
213
+ const first = groups[0] ?? 0;
214
+ const second = groups[1] ?? 0;
215
+ if (groups.every((group) => group === 0)) {
216
+ return true;
217
+ }
218
+ if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) {
219
+ return true;
220
+ }
221
+ if ((first & 65472) === 65152) {
222
+ return true;
223
+ }
224
+ if ((first & 65024) === 64512) {
225
+ return true;
226
+ }
227
+ if ((first & 65280) === 65280) {
125
228
  return true;
126
229
  }
230
+ if (first === 0 && second === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) {
231
+ const a = (groups[6] ?? 0) >> 8;
232
+ const b = (groups[6] ?? 0) & 255;
233
+ const c = (groups[7] ?? 0) >> 8;
234
+ const d = (groups[7] ?? 0) & 255;
235
+ return isBlockedIpv4Octets([a, b, c, d]);
236
+ }
127
237
  return false;
128
238
  }
129
239
  function isBlockedHostname(hostname) {
@@ -138,24 +248,43 @@ function isBlockedHostname(hostname) {
138
248
  return true;
139
249
  }
140
250
  if (normalized.includes(":")) {
251
+ return isBlockedIpv6(normalized);
252
+ }
253
+ if (/^\d+$/u.test(normalized)) {
141
254
  return true;
142
255
  }
143
- return isPrivateIpv4(normalized);
256
+ if (normalized.includes(".")) {
257
+ const canonical = parseCanonicalDottedDecimal(normalized);
258
+ if (canonical) {
259
+ return isBlockedIpv4Octets(canonical);
260
+ }
261
+ if (/^[0-9a-fx.]+$/iu.test(normalized)) {
262
+ return true;
263
+ }
264
+ }
265
+ return false;
266
+ }
267
+ function productionSafeOutboundOptions(options) {
268
+ if (!isProductionEnv()) {
269
+ return options;
270
+ }
271
+ return { ...options, allowPrivate: false, resolveDns: true };
144
272
  }
145
273
  function assertSafeOutboundUrl(rawUrl, options = {}) {
274
+ const resolved = productionSafeOutboundOptions(options);
146
275
  let parsed;
147
276
  try {
148
277
  parsed = new URL(rawUrl);
149
278
  } catch {
150
279
  throw new BadRequestError("Webhook URL is invalid.");
151
280
  }
152
- if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
281
+ if (parsed.protocol !== "https:" && !(resolved.allowHttp && parsed.protocol === "http:")) {
153
282
  throw new BadRequestError("Webhook URL must use HTTPS.");
154
283
  }
155
284
  if (parsed.username || parsed.password) {
156
285
  throw new BadRequestError("Webhook URL must not include credentials.");
157
286
  }
158
- if (isBlockedHostname(parsed.hostname)) {
287
+ if (!resolved.allowPrivate && isBlockedHostname(parsed.hostname)) {
159
288
  throw new BadRequestError("Webhook URL targets a blocked host.");
160
289
  }
161
290
  return parsed;
@@ -163,17 +292,35 @@ function assertSafeOutboundUrl(rawUrl, options = {}) {
163
292
  function isBlockedIpAddress(address) {
164
293
  return isBlockedHostname(address.trim().toLowerCase());
165
294
  }
166
- async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
167
- const parsed = assertSafeOutboundUrl(rawUrl, options);
168
- if (options.resolveDns === false) {
169
- return parsed;
295
+ function isLiteralIpHostname(hostname) {
296
+ return parseCanonicalDottedDecimal(hostname) !== null || expandIpv6(hostname) !== null;
297
+ }
298
+ function pinUrlToAddress(url, address) {
299
+ const pinned = new URL(url.toString());
300
+ const ipv6 = expandIpv6(address.replace(/^\[|\]$/gu, ""));
301
+ pinned.hostname = ipv6 ? `[${address.replace(/^\[|\]$/gu, "")}]` : address;
302
+ return pinned;
303
+ }
304
+ async function resolveSafeOutboundTarget(rawUrl, options = {}) {
305
+ const resolvedOptions = productionSafeOutboundOptions(options);
306
+ const parsed = assertSafeOutboundUrl(rawUrl, resolvedOptions);
307
+ if (resolvedOptions.resolveDns === false || resolvedOptions.allowPrivate) {
308
+ return { url: parsed, addresses: [] };
170
309
  }
171
310
  const hostname = parsed.hostname.trim().toLowerCase();
172
- const results = await dnsLookup(hostname, { all: true, verbatim: true });
173
- if (results.some((result) => isBlockedIpAddress(result.address))) {
311
+ if (isLiteralIpHostname(hostname)) {
312
+ return { url: parsed, addresses: [hostname] };
313
+ }
314
+ let results;
315
+ try {
316
+ results = await dnsLookup(hostname, { all: true, verbatim: true });
317
+ } catch {
174
318
  throw new BadRequestError("Webhook URL targets a blocked host.");
175
319
  }
176
- return parsed;
320
+ if (results.length === 0 || results.some((result) => isBlockedIpAddress(result.address))) {
321
+ throw new BadRequestError("Webhook URL targets a blocked host.");
322
+ }
323
+ return { url: parsed, addresses: results.map((result) => result.address) };
177
324
  }
178
325
 
179
326
  // ../../src/core/security/safeFetch.ts
@@ -181,25 +328,40 @@ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
181
328
  async function safeFetch(input, init = {}, options = {}) {
182
329
  const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
183
330
  const maxRedirects = options.maxRedirects ?? 0;
184
- const resolveDns = options.resolveDns ?? appEnv() === "production";
185
- const urlOptions = { allowHttp: options.allowHttp, resolveDns };
331
+ const resolveDns = options.resolveDns ?? true;
332
+ const urlOptions = {
333
+ allowHttp: options.allowHttp,
334
+ resolveDns,
335
+ allowPrivate: options.allowPrivate
336
+ };
186
337
  const controller = new AbortController;
187
338
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
188
339
  try {
189
- let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
340
+ let current = await resolveSafeOutboundTarget(input, urlOptions);
190
341
  let redirectCount = 0;
191
342
  while (true) {
192
- const response = await fetch(currentUrl, {
343
+ const address = current.addresses[0];
344
+ const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
345
+ const headers = new Headers(init.headers);
346
+ if (address && !headers.has("host")) {
347
+ headers.set("Host", current.url.host);
348
+ }
349
+ const fetchInit = {
193
350
  ...init,
351
+ headers,
194
352
  signal: controller.signal,
195
353
  redirect: "manual"
196
- });
354
+ };
355
+ if (address) {
356
+ fetchInit.tls = { serverName: current.url.hostname };
357
+ }
358
+ const response = await fetch(fetchUrl, fetchInit);
197
359
  if (response.status >= 300 && response.status < 400) {
198
360
  const location = response.headers.get("location");
199
361
  if (!location || redirectCount >= maxRedirects) {
200
362
  return response;
201
363
  }
202
- currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
364
+ current = await resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions);
203
365
  redirectCount += 1;
204
366
  continue;
205
367
  }
@@ -264,28 +426,49 @@ async function applyBypassToTransaction(transaction, bypass) {
264
426
  bypass ? "true" : "false"
265
427
  ]);
266
428
  }
267
- async function runWithMigrationBypass(callback) {
268
- if (!isRlsTenancy()) {
269
- return await callback();
429
+ function stringifyBypassIdentifier(identifier) {
430
+ if (typeof identifier === "number") {
431
+ return String(identifier);
270
432
  }
271
- if (hasActiveDatabaseConnection()) {
272
- const activeConnection = getActiveDatabaseConnection(getDefaultDatabasePool());
273
- await applyBypassToTransaction(activeConnection, true);
274
- try {
275
- return await callback();
276
- } finally {
277
- await applyBypassToTransaction(activeConnection, false);
433
+ return identifier.trim();
434
+ }
435
+ async function applyIdentifierToTransaction(transaction, identifier) {
436
+ await transaction.unsafe(`SELECT set_config('app.bypass_identifier', $1, true)`, [
437
+ stringifyBypassIdentifier(identifier)
438
+ ]);
439
+ }
440
+ function assertBypassIdentifier(identifier) {
441
+ if (typeof identifier === "number") {
442
+ if (!Number.isInteger(identifier) || identifier <= 0) {
443
+ throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
278
444
  }
445
+ return;
446
+ }
447
+ if (typeof identifier === "string" && identifier.trim() !== "") {
448
+ return;
449
+ }
450
+ throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
451
+ }
452
+ async function runWithScopedTenantTransaction(apply, callback) {
453
+ if (!isRlsTenancy()) {
454
+ return await callback();
279
455
  }
280
456
  const pool = getDefaultDatabasePool();
281
457
  if (typeof pool.begin !== "function") {
282
458
  throw new Error("RLS migration bypass requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
283
459
  }
284
460
  return await pool.begin(async (transaction) => {
285
- await applyBypassToTransaction(transaction, true);
461
+ await apply(transaction);
286
462
  return await runWithDatabaseConnection(transaction, callback);
287
463
  });
288
464
  }
465
+ async function runWithMigrationBypass(callback) {
466
+ return await runWithScopedTenantTransaction((transaction) => applyBypassToTransaction(transaction, true), callback);
467
+ }
468
+ async function runWithMigrationBypassForIdentifier(identifier, callback) {
469
+ assertBypassIdentifier(identifier);
470
+ return await runWithScopedTenantTransaction((transaction) => applyIdentifierToTransaction(transaction, identifier), callback);
471
+ }
289
472
 
290
473
  // ../../src/core/audit/siemFormatter.ts
291
474
  function formatSiemAuditEvent(input) {
@@ -18,6 +18,7 @@ function createAsyncContextStore(key) {
18
18
 
19
19
  // ../../src/core/auth/authContext.ts
20
20
  var authContext = createAsyncContextStore("@getstrata/authContext");
21
+ var credentialSourceContext = createAsyncContextStore("@getstrata/credentialSource");
21
22
  function currentAuthUser() {
22
23
  return authContext.getStore() ?? null;
23
24
  }