@getstrata/core 1.0.9 → 1.1.1

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 +51 -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
@@ -2,7 +2,7 @@
2
2
  // ../../src/core/http/validation.ts
3
3
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
4
4
  import { BadRequestError } from "@getstrata/core/errors/http";
5
- import { currentTenantId } from "@getstrata/core/tenant/tenantContext";
5
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
6
6
  function buildRequestCacheKey(fallbackPath, request) {
7
7
  if (!request) {
8
8
  return fallbackPath;
@@ -10,7 +10,7 @@ function buildRequestCacheKey(fallbackPath, request) {
10
10
  const url = new URL(request.url);
11
11
  const user = currentAuthUser();
12
12
  const authScope = user ? `u:${user.id}` : "guest";
13
- const tenantScope = `t:${currentTenantId()}`;
13
+ const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
14
14
  return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
15
15
  }
16
16
  function getQueryParams(request) {
@@ -50,7 +50,7 @@ function formDataToRecord(formData) {
50
50
  // ../../src/core/http/validation.ts
51
51
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
52
52
  import { BadRequestError as BadRequestError2 } from "@getstrata/core/errors/http";
53
- import { currentTenantId } from "@getstrata/core/tenant/tenantContext";
53
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
54
54
  function buildRequestCacheKey(fallbackPath, request) {
55
55
  if (!request) {
56
56
  return fallbackPath;
@@ -58,7 +58,7 @@ function buildRequestCacheKey(fallbackPath, request) {
58
58
  const url = new URL(request.url);
59
59
  const user = currentAuthUser();
60
60
  const authScope = user ? `u:${user.id}` : "guest";
61
- const tenantScope = `t:${currentTenantId()}`;
61
+ const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
62
62
  return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
63
63
  }
64
64
  function getQueryParams(request) {
@@ -96,36 +96,146 @@ 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)) {
125
138
  return true;
126
139
  }
127
140
  return false;
128
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) {
228
+ return true;
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
+ }
237
+ return false;
238
+ }
129
239
  function isBlockedHostname(hostname) {
130
240
  const normalized = hostname.trim().toLowerCase();
131
241
  if (normalized.length === 0) {
@@ -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) {
@@ -105,6 +105,28 @@ function resolveSmtpConfig() {
105
105
  ...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
106
106
  };
107
107
  }
108
+ var EMAIL_SHAPE = /^[^\s@\r\n]+@[^\s@\r\n]+\.[^\s@\r\n]+$/u;
109
+ function assertSafeSmtpField(value, field) {
110
+ if (/[\r\n]/u.test(value)) {
111
+ throw new Error(`Mail ${field} must not contain CR or LF.`);
112
+ }
113
+ return value;
114
+ }
115
+ function smtpEnvelopeAddress(value, field) {
116
+ const trimmed = assertSafeSmtpField(value, field).trim();
117
+ const address = trimmed.includes("<") ? trimmed.match(/<([^>]+)>/u)?.[1] ?? trimmed : trimmed;
118
+ if (!EMAIL_SHAPE.test(address)) {
119
+ throw new Error(`Mail ${field} must be a valid email address.`);
120
+ }
121
+ return address;
122
+ }
123
+ function assertSafeSmtpAddress(value, field) {
124
+ smtpEnvelopeAddress(value, field);
125
+ return assertSafeSmtpField(value, field).trim();
126
+ }
127
+ function redactMailBody(body) {
128
+ return body.replace(/https?:\/\/[^\s]+/giu, "[redacted-url]").replace(/[?&](token|signature|expires|code)=[^&\s]+/giu, "[redacted-query]");
129
+ }
108
130
  function encodeBase64(value) {
109
131
  return Buffer.from(value, "utf8").toString("base64");
110
132
  }
@@ -169,6 +191,12 @@ async function openSmtpConnection(config) {
169
191
  return { socket, readResponse };
170
192
  }
171
193
  async function defaultSmtpTransport(config, message) {
194
+ if (config.username) {
195
+ assertSafeSmtpField(config.username, "username");
196
+ }
197
+ if (config.password) {
198
+ assertSafeSmtpField(config.password, "password");
199
+ }
172
200
  const { socket, readResponse } = await openSmtpConnection(config);
173
201
  try {
174
202
  await waitForSmtpResponse(readResponse, ["220"]);
@@ -186,16 +214,24 @@ async function defaultSmtpTransport(config, message) {
186
214
  `);
187
215
  await waitForSmtpResponse(readResponse, ["235"]);
188
216
  }
189
- await socket.write(`MAIL FROM:<${config.from}>\r
217
+ const fromHeader = assertSafeSmtpAddress(config.from, "from");
218
+ const toHeader = assertSafeSmtpAddress(message.to, "to");
219
+ const from = smtpEnvelopeAddress(config.from, "from");
220
+ const to = smtpEnvelopeAddress(message.to, "to");
221
+ await socket.write(`MAIL FROM:<${from}>\r
190
222
  `);
191
223
  await waitForSmtpResponse(readResponse, ["250"]);
192
- await socket.write(`RCPT TO:<${message.to}>\r
224
+ await socket.write(`RCPT TO:<${to}>\r
193
225
  `);
194
226
  await waitForSmtpResponse(readResponse, ["250", "251"]);
195
227
  await socket.write(`DATA\r
196
228
  `);
197
229
  await waitForSmtpResponse(readResponse, ["354"]);
198
- const payload = buildSmtpPayload(config.from, message);
230
+ const payload = buildSmtpPayload(fromHeader, {
231
+ ...message,
232
+ to: toHeader,
233
+ subject: assertSafeSmtpField(message.subject, "subject")
234
+ });
199
235
  await socket.write(payload);
200
236
  await waitForSmtpResponse(readResponse, ["250"]);
201
237
  await socket.write(`QUIT\r
@@ -206,10 +242,13 @@ async function defaultSmtpTransport(config, message) {
206
242
  }
207
243
  }
208
244
  function buildSmtpPayload(from, message) {
245
+ const safeFrom = assertSafeSmtpAddress(from, "from");
246
+ const safeTo = assertSafeSmtpAddress(message.to, "to");
247
+ const safeSubject = assertSafeSmtpField(message.subject, "subject");
209
248
  const headers = [
210
- `From: ${from}`,
211
- `To: ${message.to}`,
212
- `Subject: ${message.subject}`,
249
+ `From: ${safeFrom}`,
250
+ `To: ${safeTo}`,
251
+ `Subject: ${safeSubject}`,
213
252
  "MIME-Version: 1.0"
214
253
  ];
215
254
  if (message.html) {
@@ -242,7 +281,7 @@ class LogMailDriver {
242
281
  channel: "mail",
243
282
  to: message.to,
244
283
  subject: message.subject,
245
- body: message.body,
284
+ body: redactMailBody(message.body),
246
285
  ...message.html ? { htmlBytes: Buffer.byteLength(message.html, "utf8") } : {}
247
286
  }));
248
287
  }
@@ -266,6 +305,8 @@ class Mailer {
266
305
  this.driver = driver;
267
306
  }
268
307
  send(message) {
308
+ assertSafeSmtpAddress(message.to, "to");
309
+ assertSafeSmtpField(message.subject, "subject");
269
310
  return this.driver.send(message);
270
311
  }
271
312
  }
@@ -284,8 +325,13 @@ export {
284
325
  LogMailDriver,
285
326
  Mailer,
286
327
  SmtpMailDriver,
328
+ assertSafeSmtpAddress,
329
+ assertSafeSmtpField,
287
330
  buildSmtpPayload,
288
331
  createMailDriver,
332
+ defaultSmtpTransport,
289
333
  mailer,
290
- resolveSmtpConfig
334
+ redactMailBody,
335
+ resolveSmtpConfig,
336
+ smtpEnvelopeAddress
291
337
  };
@@ -1,6 +1,8 @@
1
1
  // @bun
2
2
  // ../../src/core/media/imageTransform.ts
3
3
  var IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
4
+ var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
5
+ var MAX_IMAGE_PIXELS = 4096 * 4096;
4
6
  function normalizeMimeType(mimeType) {
5
7
  return mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
6
8
  }
@@ -21,8 +23,17 @@ function parseThumbnailWidth(raw, options = {}) {
21
23
  return Math.min(maxWidth, Math.max(minWidth, parsed));
22
24
  }
23
25
  async function resizeImageContents(contents, width, mimeType) {
26
+ if (contents.byteLength > MAX_IMAGE_BYTES) {
27
+ throw new Error("Image exceeds the maximum allowed size.");
28
+ }
24
29
  const normalized = normalizeMimeType(mimeType);
25
- const pipeline = new Bun.Image(contents).resize(width);
30
+ const source = new Bun.Image(contents);
31
+ const metadata = await source.metadata();
32
+ const pixels = Number(metadata.width) * Number(metadata.height);
33
+ if (pixels > MAX_IMAGE_PIXELS) {
34
+ throw new Error("Image exceeds the maximum allowed dimensions.");
35
+ }
36
+ const pipeline = source.resize(width);
26
37
  if (normalized === "image/png") {
27
38
  return { body: await pipeline.png().bytes(), contentType: "image/png" };
28
39
  }
@@ -119,14 +119,10 @@ var PUBLIC_ROUTE_DESCRIPTIONS = {
119
119
  "POST /tasks": "Create task",
120
120
  "GET /search": "Full-text search tasks and comments",
121
121
  "GET /audit-logs": "List audit log entries",
122
- "GET /webhooks": "List webhooks",
123
- "POST /webhooks": "Create webhook",
124
122
  "GET /reports/summary": "Cross-module summary report",
125
123
  "GET /admin/stats": "Platform statistics",
126
124
  "GET /admin/tenants": "List tenants",
127
125
  "GET /admin/features": "Runtime feature flags",
128
- "GET /billing/subscription": "Current tenant subscription",
129
- "POST /billing/webhooks/stripe": "Stripe webhook receiver (stub)",
130
126
  "GET /scim/v2/Users": "SCIM list users",
131
127
  "POST /scim/v2/Users": "SCIM create user",
132
128
  "GET /health": "Liveness probe",
@@ -165,10 +161,7 @@ var PUBLIC_ROUTE_DESCRIPTIONS = {
165
161
  "POST /api/applications": "Submit an application",
166
162
  "GET /api/positions": "List hiring positions",
167
163
  "GET /api/departments": "List departments",
168
- "GET /api/webhooks": "List hiring webhooks",
169
- "POST /api/webhooks": "Create a hiring webhook",
170
164
  "GET /api/audit-logs": "List hiring audit log entries",
171
- "GET /api/billing/subscription": "Current tenant subscription",
172
165
  "GET /scim/v2/Groups": "SCIM list groups (departments)"
173
166
  };
174
167
  function toOpenApiPath(path) {
@@ -89,72 +89,63 @@ function sdkClientClassName() {
89
89
  var OAUTH_STATE_COOKIE = "oauth_state";
90
90
  var OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
91
91
  function resolveOAuthStateSecret() {
92
- return requireConfiguredSecret(["OAUTH_STATE_SECRET", "ADMIN_API_TOKEN"], "oauth-state-secret");
92
+ return requireConfiguredSecret(["OAUTH_STATE_SECRET", "SESSION_SECRET"], "oauth-state-secret");
93
93
  }
94
94
  function signOAuthState(state, issuedAt) {
95
95
  const payload = `${state}.${issuedAt}`;
96
96
  const signature = createHmac("sha256", resolveOAuthStateSecret()).update(payload).digest("hex");
97
97
  return `${payload}.${signature}`;
98
98
  }
99
- function createOAuthStateCookie() {
100
- const state = randomBytes(24).toString("hex");
101
- const issuedAt = Date.now();
102
- const value = signOAuthState(state, issuedAt);
103
- return {
104
- state,
105
- cookie: `${OAUTH_STATE_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`
106
- };
107
- }
108
- function readOAuthStateCookie(request) {
109
- const cookieHeader = request.headers.get("cookie");
110
- if (!cookieHeader) {
111
- return null;
112
- }
113
- for (const part of cookieHeader.split(";")) {
114
- const [name, ...rest] = part.trim().split("=");
115
- if (name === OAUTH_STATE_COOKIE) {
116
- return decodeURIComponent(rest.join("="));
117
- }
118
- }
119
- return null;
120
- }
121
- function verifyOAuthState(request, returnedState) {
122
- if (!returnedState || returnedState.trim().length === 0) {
123
- return false;
124
- }
125
- const cookieValue = readOAuthStateCookie(request);
126
- if (!cookieValue) {
127
- return false;
128
- }
129
- const parts = cookieValue.split(".");
99
+ function verifySignedOAuthStateValue(value) {
100
+ const parts = value.split(".");
130
101
  if (parts.length !== 3) {
131
102
  return false;
132
103
  }
133
- const [cookieState, issuedAtRaw, cookieSignature] = parts;
134
- if (!cookieState || !issuedAtRaw || !cookieSignature) {
104
+ const [nonce, issuedAtRaw, signature] = parts;
105
+ if (!nonce || !issuedAtRaw || !signature) {
135
106
  return false;
136
107
  }
137
108
  const issuedAt = Number.parseInt(issuedAtRaw, 10);
138
- if (cookieState !== returnedState || !Number.isFinite(issuedAt)) {
109
+ if (!Number.isFinite(issuedAt)) {
139
110
  return false;
140
111
  }
141
112
  if (Date.now() - issuedAt > OAUTH_STATE_TTL_MS) {
142
113
  return false;
143
114
  }
144
- const expectedSignature = signOAuthState(cookieState, issuedAt).split(".").pop();
115
+ const expectedSignature = signOAuthState(nonce, issuedAt).split(".").at(-1) ?? "";
145
116
  const expectedBuffer = Buffer.from(expectedSignature);
146
- const actualBuffer = Buffer.from(cookieSignature);
117
+ const actualBuffer = Buffer.from(signature);
147
118
  if (expectedBuffer.length !== actualBuffer.length) {
148
119
  return false;
149
120
  }
150
121
  return timingSafeEqual(expectedBuffer, actualBuffer);
151
122
  }
123
+ function createOAuthState() {
124
+ const nonce = randomBytes(24).toString("hex");
125
+ const issuedAt = Date.now();
126
+ return { state: signOAuthState(nonce, issuedAt) };
127
+ }
128
+ function createOAuthStateCookie() {
129
+ const { state } = createOAuthState();
130
+ return {
131
+ state,
132
+ cookie: `${OAUTH_STATE_COOKIE}=${encodeURIComponent(state)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600${isProductionEnv() ? "; Secure" : ""}`
133
+ };
134
+ }
135
+ function verifyOAuthState(_request, returnedState) {
136
+ if (!returnedState || returnedState.trim().length === 0) {
137
+ return false;
138
+ }
139
+ return verifySignedOAuthStateValue(returnedState.trim());
140
+ }
152
141
  function clearOAuthStateCookie() {
153
- return `${OAUTH_STATE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
142
+ return `${OAUTH_STATE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${isProductionEnv() ? "; Secure" : ""}`;
154
143
  }
155
144
  export {
156
145
  OAUTH_STATE_COOKIE,
157
146
  clearOAuthStateCookie,
147
+ createOAuthState,
158
148
  createOAuthStateCookie,
159
- verifyOAuthState
149
+ verifyOAuthState,
150
+ verifySignedOAuthStateValue
160
151
  };
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  // ../../src/core/security/publicReads.ts
3
3
  function isPublicReadsEnabled() {
4
- return (process.env.FEATURE_PUBLIC_READS ?? "true") !== "false";
4
+ return (process.env.FEATURE_PUBLIC_READS ?? "false") === "true";
5
5
  }
6
6
  function guestCanViewResource() {
7
7
  return isPublicReadsEnabled();