@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
@@ -82,7 +82,311 @@ function sdkClientClassName() {
82
82
  return fromName ? `${fromName}Client` : "AppClient";
83
83
  }
84
84
 
85
+ // ../../src/core/security/safeUrl.ts
86
+ import { lookup as dnsLookupImpl } from "dns/promises";
87
+ import { BadRequestError } from "@getstrata/core/errors/http";
88
+ var dnsLookup = dnsLookupImpl;
89
+ var BLOCKED_HOSTNAMES = new Set([
90
+ "localhost",
91
+ "127.0.0.1",
92
+ "0.0.0.0",
93
+ "::1",
94
+ "metadata.google.internal"
95
+ ]);
96
+ function parseCanonicalDottedDecimal(hostname) {
97
+ const parts = hostname.split(".");
98
+ if (parts.length !== 4) {
99
+ return null;
100
+ }
101
+ const octets = [];
102
+ for (const part of parts) {
103
+ if (!/^(0|[1-9]\d{0,2})$/u.test(part)) {
104
+ return null;
105
+ }
106
+ const value = Number.parseInt(part, 10);
107
+ if (!Number.isInteger(value) || value < 0 || value > 255) {
108
+ return null;
109
+ }
110
+ octets.push(value);
111
+ }
112
+ return octets;
113
+ }
114
+ function parseDottedIpv4(hostname) {
115
+ return parseCanonicalDottedDecimal(hostname);
116
+ }
117
+ function isBlockedIpv4Octets(octets) {
118
+ const [a = 0, b = 0] = octets;
119
+ if (a === 10 || a === 127 || a === 0) {
120
+ return true;
121
+ }
122
+ if (a === 169 && b === 254) {
123
+ return true;
124
+ }
125
+ if (a === 172 && b >= 16 && b <= 31) {
126
+ return true;
127
+ }
128
+ if (a === 192 && b === 168) {
129
+ return true;
130
+ }
131
+ if (a === 100 && b >= 64 && b <= 127) {
132
+ return true;
133
+ }
134
+ if (a === 198 && (b === 18 || b === 19)) {
135
+ return true;
136
+ }
137
+ return false;
138
+ }
139
+ function expandIpv6(hostname) {
140
+ const trimmed = hostname.trim().toLowerCase();
141
+ const unbracketed = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
142
+ if (unbracketed.includes("%")) {
143
+ return null;
144
+ }
145
+ const mapped = unbracketed.match(/^::ffff:([0-9a-fx.]+)$/iu);
146
+ if (mapped?.[1]) {
147
+ const octets = parseDottedIpv4(mapped[1]);
148
+ if (!octets) {
149
+ return null;
150
+ }
151
+ return [
152
+ 0,
153
+ 0,
154
+ 0,
155
+ 0,
156
+ 0,
157
+ 65535,
158
+ octets[0] << 8 | octets[1],
159
+ octets[2] << 8 | octets[3]
160
+ ];
161
+ }
162
+ const halves = unbracketed.split("::");
163
+ if (halves.length > 2) {
164
+ return null;
165
+ }
166
+ const parseGroup = (part) => {
167
+ if (!/^[0-9a-f]{1,4}$/u.test(part)) {
168
+ return null;
169
+ }
170
+ return Number.parseInt(part, 16);
171
+ };
172
+ if (halves.length === 1) {
173
+ const groups = unbracketed.split(":");
174
+ if (groups.length !== 8) {
175
+ return null;
176
+ }
177
+ const values = groups.map(parseGroup);
178
+ return values.every((value) => value !== null) ? values : null;
179
+ }
180
+ const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
181
+ const tail = halves[1] ? halves[1].split(":").filter(Boolean) : [];
182
+ if (head.length + tail.length > 8) {
183
+ return null;
184
+ }
185
+ const values = [];
186
+ for (const part of head) {
187
+ const value = parseGroup(part);
188
+ if (value === null) {
189
+ return null;
190
+ }
191
+ values.push(value);
192
+ }
193
+ while (values.length < 8 - tail.length) {
194
+ values.push(0);
195
+ }
196
+ for (const part of tail) {
197
+ const value = parseGroup(part);
198
+ if (value === null) {
199
+ return null;
200
+ }
201
+ values.push(value);
202
+ }
203
+ return values.length === 8 ? values : null;
204
+ }
205
+ function isBlockedIpv6(hostname) {
206
+ const groups = expandIpv6(hostname);
207
+ if (!groups) {
208
+ return hostname.includes(":");
209
+ }
210
+ const first = groups[0] ?? 0;
211
+ const second = groups[1] ?? 0;
212
+ if (groups.every((group) => group === 0)) {
213
+ return true;
214
+ }
215
+ if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) {
216
+ return true;
217
+ }
218
+ if ((first & 65472) === 65152) {
219
+ return true;
220
+ }
221
+ if ((first & 65024) === 64512) {
222
+ return true;
223
+ }
224
+ if ((first & 65280) === 65280) {
225
+ return true;
226
+ }
227
+ if (first === 0 && second === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) {
228
+ const a = (groups[6] ?? 0) >> 8;
229
+ const b = (groups[6] ?? 0) & 255;
230
+ const c = (groups[7] ?? 0) >> 8;
231
+ const d = (groups[7] ?? 0) & 255;
232
+ return isBlockedIpv4Octets([a, b, c, d]);
233
+ }
234
+ return false;
235
+ }
236
+ function isBlockedHostname(hostname) {
237
+ const normalized = hostname.trim().toLowerCase();
238
+ if (normalized.length === 0) {
239
+ return true;
240
+ }
241
+ if (BLOCKED_HOSTNAMES.has(normalized)) {
242
+ return true;
243
+ }
244
+ if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
245
+ return true;
246
+ }
247
+ if (normalized.includes(":")) {
248
+ return isBlockedIpv6(normalized);
249
+ }
250
+ if (/^\d+$/u.test(normalized)) {
251
+ return true;
252
+ }
253
+ if (normalized.includes(".")) {
254
+ const canonical = parseCanonicalDottedDecimal(normalized);
255
+ if (canonical) {
256
+ return isBlockedIpv4Octets(canonical);
257
+ }
258
+ if (/^[0-9a-fx.]+$/iu.test(normalized)) {
259
+ return true;
260
+ }
261
+ }
262
+ return false;
263
+ }
264
+ function productionSafeOutboundOptions(options) {
265
+ if (!isProductionEnv()) {
266
+ return options;
267
+ }
268
+ return { ...options, allowPrivate: false, resolveDns: true };
269
+ }
270
+ function assertSafeOutboundUrl(rawUrl, options = {}) {
271
+ const resolved = productionSafeOutboundOptions(options);
272
+ let parsed;
273
+ try {
274
+ parsed = new URL(rawUrl);
275
+ } catch {
276
+ throw new BadRequestError("Webhook URL is invalid.");
277
+ }
278
+ if (parsed.protocol !== "https:" && !(resolved.allowHttp && parsed.protocol === "http:")) {
279
+ throw new BadRequestError("Webhook URL must use HTTPS.");
280
+ }
281
+ if (parsed.username || parsed.password) {
282
+ throw new BadRequestError("Webhook URL must not include credentials.");
283
+ }
284
+ if (!resolved.allowPrivate && isBlockedHostname(parsed.hostname)) {
285
+ throw new BadRequestError("Webhook URL targets a blocked host.");
286
+ }
287
+ return parsed;
288
+ }
289
+ function isBlockedIpAddress(address) {
290
+ return isBlockedHostname(address.trim().toLowerCase());
291
+ }
292
+ function isLiteralIpHostname(hostname) {
293
+ return parseCanonicalDottedDecimal(hostname) !== null || expandIpv6(hostname) !== null;
294
+ }
295
+ function pinUrlToAddress(url, address) {
296
+ const pinned = new URL(url.toString());
297
+ const ipv6 = expandIpv6(address.replace(/^\[|\]$/gu, ""));
298
+ pinned.hostname = ipv6 ? `[${address.replace(/^\[|\]$/gu, "")}]` : address;
299
+ return pinned;
300
+ }
301
+ async function resolveSafeOutboundTarget(rawUrl, options = {}) {
302
+ const resolvedOptions = productionSafeOutboundOptions(options);
303
+ const parsed = assertSafeOutboundUrl(rawUrl, resolvedOptions);
304
+ if (resolvedOptions.resolveDns === false || resolvedOptions.allowPrivate) {
305
+ return { url: parsed, addresses: [] };
306
+ }
307
+ const hostname = parsed.hostname.trim().toLowerCase();
308
+ if (isLiteralIpHostname(hostname)) {
309
+ return { url: parsed, addresses: [hostname] };
310
+ }
311
+ let results;
312
+ try {
313
+ results = await dnsLookup(hostname, { all: true, verbatim: true });
314
+ } catch {
315
+ throw new BadRequestError("Webhook URL targets a blocked host.");
316
+ }
317
+ if (results.length === 0 || results.some((result) => isBlockedIpAddress(result.address))) {
318
+ throw new BadRequestError("Webhook URL targets a blocked host.");
319
+ }
320
+ return { url: parsed, addresses: results.map((result) => result.address) };
321
+ }
322
+
323
+ // ../../src/core/security/safeFetch.ts
324
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
325
+ async function safeFetch(input, init = {}, options = {}) {
326
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
327
+ const maxRedirects = options.maxRedirects ?? 0;
328
+ const resolveDns = options.resolveDns ?? true;
329
+ const urlOptions = {
330
+ allowHttp: options.allowHttp,
331
+ resolveDns,
332
+ allowPrivate: options.allowPrivate
333
+ };
334
+ const controller = new AbortController;
335
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
336
+ try {
337
+ let current = await resolveSafeOutboundTarget(input, urlOptions);
338
+ let redirectCount = 0;
339
+ while (true) {
340
+ const address = current.addresses[0];
341
+ const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
342
+ const headers = new Headers(init.headers);
343
+ if (address && !headers.has("host")) {
344
+ headers.set("Host", current.url.host);
345
+ }
346
+ const fetchInit = {
347
+ ...init,
348
+ headers,
349
+ signal: controller.signal,
350
+ redirect: "manual"
351
+ };
352
+ if (address) {
353
+ fetchInit.tls = { serverName: current.url.hostname };
354
+ }
355
+ const response = await fetch(fetchUrl, fetchInit);
356
+ if (response.status >= 300 && response.status < 400) {
357
+ const location = response.headers.get("location");
358
+ if (!location || redirectCount >= maxRedirects) {
359
+ return response;
360
+ }
361
+ current = await resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions);
362
+ redirectCount += 1;
363
+ continue;
364
+ }
365
+ return response;
366
+ }
367
+ } finally {
368
+ clearTimeout(timeout);
369
+ }
370
+ }
371
+
85
372
  // ../../src/core/auth/oauth/providers.ts
373
+ async function githubVerifiedEmail(accessToken) {
374
+ const emailsResponse = await safeFetch("https://api.github.com/user/emails", {
375
+ headers: {
376
+ authorization: `Bearer ${accessToken}`,
377
+ accept: "application/json",
378
+ "user-agent": appUserAgent()
379
+ }
380
+ }, { timeoutMs: 1e4, maxRedirects: 0 });
381
+ const emails = await emailsResponse.json();
382
+ if (!Array.isArray(emails)) {
383
+ return null;
384
+ }
385
+ const verified = emails.filter((item) => typeof item === "object" && item !== null && typeof item.email === "string" && Boolean(item.email.trim()) && item.verified === true);
386
+ const primary = verified.find((item) => item.primary);
387
+ return (primary ?? verified[0])?.email.trim() ?? null;
388
+ }
389
+
86
390
  class GitHubOAuthProvider {
87
391
  options;
88
392
  name = "github";
@@ -99,7 +403,7 @@ class GitHubOAuthProvider {
99
403
  return `https://github.com/login/oauth/authorize?${params.toString()}`;
100
404
  }
101
405
  async exchangeCode(code, redirectUri = this.options.redirectUri) {
102
- const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
406
+ const tokenResponse = await safeFetch("https://github.com/login/oauth/access_token", {
103
407
  method: "POST",
104
408
  headers: {
105
409
  accept: "application/json",
@@ -111,22 +415,26 @@ class GitHubOAuthProvider {
111
415
  code,
112
416
  redirect_uri: redirectUri
113
417
  })
114
- });
418
+ }, { timeoutMs: 1e4, maxRedirects: 0 });
115
419
  const tokenBody = await tokenResponse.json();
116
420
  if (!tokenBody.access_token) {
117
421
  throw new Error("GitHub OAuth token exchange failed.");
118
422
  }
119
- const profileResponse = await fetch("https://api.github.com/user", {
423
+ const profileResponse = await safeFetch("https://api.github.com/user", {
120
424
  headers: {
121
425
  authorization: `Bearer ${tokenBody.access_token}`,
122
426
  accept: "application/json",
123
427
  "user-agent": appUserAgent()
124
428
  }
125
- });
429
+ }, { timeoutMs: 1e4, maxRedirects: 0 });
126
430
  const profile = await profileResponse.json();
431
+ const email = await githubVerifiedEmail(tokenBody.access_token);
432
+ if (!email) {
433
+ throw new Error("GitHub OAuth profile did not include a verified email address.");
434
+ }
127
435
  return {
128
436
  providerUserId: String(profile.id),
129
- email: profile.email ?? `${profile.login}@users.noreply.github.com`,
437
+ email,
130
438
  name: profile.name ?? profile.login
131
439
  };
132
440
  }