@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
@@ -4,7 +4,7 @@ import { randomBytes } from "crypto";
4
4
  import { hashApiToken } from "@getstrata/core/auth/tokenHash";
5
5
  import { timingSafeCompareString } from "@getstrata/core/security/timingSafeCompare";
6
6
  var DEFAULT_RECOVERY_CODE_COUNT = 8;
7
- var RECOVERY_CODE_BYTES = 4;
7
+ var RECOVERY_CODE_BYTES = 16;
8
8
  function normalizeRecoveryCode(code) {
9
9
  return code.replace(/[^a-z0-9]/giu, "").toLowerCase();
10
10
  }
@@ -12,7 +12,7 @@ function formatRecoveryCode(normalized) {
12
12
  if (normalized.length !== RECOVERY_CODE_BYTES * 2) {
13
13
  return normalized;
14
14
  }
15
- return `${normalized.slice(0, 4)}-${normalized.slice(4)}`;
15
+ return normalized.match(/.{1,4}/gu)?.join("-") ?? normalized;
16
16
  }
17
17
  function generateRecoveryCodes(count = DEFAULT_RECOVERY_CODE_COUNT) {
18
18
  const size = Number.isInteger(count) && count > 0 ? count : DEFAULT_RECOVERY_CODE_COUNT;
@@ -1,4 +1,8 @@
1
1
  // @bun
2
+ // ../../src/core/security/safeUrl.ts
3
+ import { lookup as dnsLookupImpl } from "dns/promises";
4
+ import { BadRequestError } from "@getstrata/core/errors/http";
5
+
2
6
  // ../../src/core/runtime/appEnv.ts
3
7
  var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
4
8
  function normalizeEnvValue(value) {
@@ -19,72 +23,7 @@ function envFlagEnabled(value) {
19
23
  return value === "true";
20
24
  }
21
25
 
22
- // ../../src/core/runtime/appKeyPrefix.ts
23
- function appKeyPrefix() {
24
- return process.env.APP_KEY_PREFIX?.trim() || "strata";
25
- }
26
- function appCookieName(kind) {
27
- return `${appKeyPrefix()}_${kind}`;
28
- }
29
- function appDevSecret(kind) {
30
- return `${appKeyPrefix()}-dev-${kind}`;
31
- }
32
- function requireConfiguredSecret(names, devKind, env = process.env) {
33
- for (const name of names) {
34
- const value = env[name]?.trim();
35
- if (value) {
36
- return value;
37
- }
38
- }
39
- if (isProductionEnv(env)) {
40
- throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
41
- }
42
- return appDevSecret(devKind);
43
- }
44
- function namespacedRedisKey(kind) {
45
- return `${appKeyPrefix()}:${kind}`;
46
- }
47
- function smtpEhloHost() {
48
- const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
49
- const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
50
- return safe || "strata.local";
51
- }
52
- function siemEventType() {
53
- return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
54
- }
55
- function appUserAgent() {
56
- return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
57
- }
58
- function otelServiceName() {
59
- return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
60
- }
61
- function appDisplayName() {
62
- return process.env.APP_NAME?.trim() || "Strata";
63
- }
64
- function appEnv() {
65
- return process.env.APP_ENV?.trim() || "local";
66
- }
67
- function appUrl() {
68
- return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
69
- }
70
- function apiPrefix() {
71
- const raw = process.env.API_PREFIX?.trim() || "/api/v1";
72
- const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
73
- const trimmed = withSlash.replace(/\/+$/, "");
74
- return trimmed || "/api/v1";
75
- }
76
- function sdkClientClassName() {
77
- const override = process.env.APP_SDK_CLASS?.trim();
78
- if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
79
- return override;
80
- }
81
- const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
82
- return fromName ? `${fromName}Client` : "AppClient";
83
- }
84
-
85
26
  // ../../src/core/security/safeUrl.ts
86
- import { lookup as dnsLookupImpl } from "dns/promises";
87
- import { BadRequestError } from "@getstrata/core/errors/http";
88
27
  var dnsLookup = dnsLookupImpl;
89
28
  var BLOCKED_HOSTNAMES = new Set([
90
29
  "localhost",
@@ -93,34 +32,144 @@ var BLOCKED_HOSTNAMES = new Set([
93
32
  "::1",
94
33
  "metadata.google.internal"
95
34
  ]);
96
- function isPrivateIpv4(hostname) {
97
- const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
98
- if (!match) {
99
- return false;
35
+ function parseCanonicalDottedDecimal(hostname) {
36
+ const parts = hostname.split(".");
37
+ if (parts.length !== 4) {
38
+ return null;
100
39
  }
101
- const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
102
- if (octets.some((octet) => octet < 0 || octet > 255)) {
103
- return true;
40
+ const octets = [];
41
+ for (const part of parts) {
42
+ if (!/^(0|[1-9]\d{0,2})$/u.test(part)) {
43
+ return null;
44
+ }
45
+ const value = Number.parseInt(part, 10);
46
+ if (!Number.isInteger(value) || value < 0 || value > 255) {
47
+ return null;
48
+ }
49
+ octets.push(value);
104
50
  }
51
+ return octets;
52
+ }
53
+ function parseDottedIpv4(hostname) {
54
+ return parseCanonicalDottedDecimal(hostname);
55
+ }
56
+ function isBlockedIpv4Octets(octets) {
105
57
  const [a = 0, b = 0] = octets;
106
- if (a === 10) {
58
+ if (a === 10 || a === 127 || a === 0) {
107
59
  return true;
108
60
  }
109
- if (a === 127) {
61
+ if (a === 169 && b === 254) {
110
62
  return true;
111
63
  }
112
- if (a === 0) {
64
+ if (a === 172 && b >= 16 && b <= 31) {
113
65
  return true;
114
66
  }
115
- if (a === 169 && b === 254) {
67
+ if (a === 192 && b === 168) {
116
68
  return true;
117
69
  }
118
- if (a === 172 && b >= 16 && b <= 31) {
70
+ if (a === 100 && b >= 64 && b <= 127) {
119
71
  return true;
120
72
  }
121
- if (a === 192 && b === 168) {
73
+ if (a === 198 && (b === 18 || b === 19)) {
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ function expandIpv6(hostname) {
79
+ const trimmed = hostname.trim().toLowerCase();
80
+ const unbracketed = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
81
+ if (unbracketed.includes("%")) {
82
+ return null;
83
+ }
84
+ const mapped = unbracketed.match(/^::ffff:([0-9a-fx.]+)$/iu);
85
+ if (mapped?.[1]) {
86
+ const octets = parseDottedIpv4(mapped[1]);
87
+ if (!octets) {
88
+ return null;
89
+ }
90
+ return [
91
+ 0,
92
+ 0,
93
+ 0,
94
+ 0,
95
+ 0,
96
+ 65535,
97
+ octets[0] << 8 | octets[1],
98
+ octets[2] << 8 | octets[3]
99
+ ];
100
+ }
101
+ const halves = unbracketed.split("::");
102
+ if (halves.length > 2) {
103
+ return null;
104
+ }
105
+ const parseGroup = (part) => {
106
+ if (!/^[0-9a-f]{1,4}$/u.test(part)) {
107
+ return null;
108
+ }
109
+ return Number.parseInt(part, 16);
110
+ };
111
+ if (halves.length === 1) {
112
+ const groups = unbracketed.split(":");
113
+ if (groups.length !== 8) {
114
+ return null;
115
+ }
116
+ const values = groups.map(parseGroup);
117
+ return values.every((value) => value !== null) ? values : null;
118
+ }
119
+ const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
120
+ const tail = halves[1] ? halves[1].split(":").filter(Boolean) : [];
121
+ if (head.length + tail.length > 8) {
122
+ return null;
123
+ }
124
+ const values = [];
125
+ for (const part of head) {
126
+ const value = parseGroup(part);
127
+ if (value === null) {
128
+ return null;
129
+ }
130
+ values.push(value);
131
+ }
132
+ while (values.length < 8 - tail.length) {
133
+ values.push(0);
134
+ }
135
+ for (const part of tail) {
136
+ const value = parseGroup(part);
137
+ if (value === null) {
138
+ return null;
139
+ }
140
+ values.push(value);
141
+ }
142
+ return values.length === 8 ? values : null;
143
+ }
144
+ function isBlockedIpv6(hostname) {
145
+ const groups = expandIpv6(hostname);
146
+ if (!groups) {
147
+ return hostname.includes(":");
148
+ }
149
+ const first = groups[0] ?? 0;
150
+ const second = groups[1] ?? 0;
151
+ if (groups.every((group) => group === 0)) {
152
+ return true;
153
+ }
154
+ if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) {
155
+ return true;
156
+ }
157
+ if ((first & 65472) === 65152) {
158
+ return true;
159
+ }
160
+ if ((first & 65024) === 64512) {
122
161
  return true;
123
162
  }
163
+ if ((first & 65280) === 65280) {
164
+ return true;
165
+ }
166
+ if (first === 0 && second === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) {
167
+ const a = (groups[6] ?? 0) >> 8;
168
+ const b = (groups[6] ?? 0) & 255;
169
+ const c = (groups[7] ?? 0) >> 8;
170
+ const d = (groups[7] ?? 0) & 255;
171
+ return isBlockedIpv4Octets([a, b, c, d]);
172
+ }
124
173
  return false;
125
174
  }
126
175
  function isBlockedHostname(hostname) {
@@ -135,24 +184,43 @@ function isBlockedHostname(hostname) {
135
184
  return true;
136
185
  }
137
186
  if (normalized.includes(":")) {
187
+ return isBlockedIpv6(normalized);
188
+ }
189
+ if (/^\d+$/u.test(normalized)) {
138
190
  return true;
139
191
  }
140
- return isPrivateIpv4(normalized);
192
+ if (normalized.includes(".")) {
193
+ const canonical = parseCanonicalDottedDecimal(normalized);
194
+ if (canonical) {
195
+ return isBlockedIpv4Octets(canonical);
196
+ }
197
+ if (/^[0-9a-fx.]+$/iu.test(normalized)) {
198
+ return true;
199
+ }
200
+ }
201
+ return false;
202
+ }
203
+ function productionSafeOutboundOptions(options) {
204
+ if (!isProductionEnv()) {
205
+ return options;
206
+ }
207
+ return { ...options, allowPrivate: false, resolveDns: true };
141
208
  }
142
209
  function assertSafeOutboundUrl(rawUrl, options = {}) {
210
+ const resolved = productionSafeOutboundOptions(options);
143
211
  let parsed;
144
212
  try {
145
213
  parsed = new URL(rawUrl);
146
214
  } catch {
147
215
  throw new BadRequestError("Webhook URL is invalid.");
148
216
  }
149
- if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
217
+ if (parsed.protocol !== "https:" && !(resolved.allowHttp && parsed.protocol === "http:")) {
150
218
  throw new BadRequestError("Webhook URL must use HTTPS.");
151
219
  }
152
220
  if (parsed.username || parsed.password) {
153
221
  throw new BadRequestError("Webhook URL must not include credentials.");
154
222
  }
155
- if (isBlockedHostname(parsed.hostname)) {
223
+ if (!resolved.allowPrivate && isBlockedHostname(parsed.hostname)) {
156
224
  throw new BadRequestError("Webhook URL targets a blocked host.");
157
225
  }
158
226
  return parsed;
@@ -160,17 +228,35 @@ function assertSafeOutboundUrl(rawUrl, options = {}) {
160
228
  function isBlockedIpAddress(address) {
161
229
  return isBlockedHostname(address.trim().toLowerCase());
162
230
  }
163
- async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
164
- const parsed = assertSafeOutboundUrl(rawUrl, options);
165
- if (options.resolveDns === false) {
166
- return parsed;
231
+ function isLiteralIpHostname(hostname) {
232
+ return parseCanonicalDottedDecimal(hostname) !== null || expandIpv6(hostname) !== null;
233
+ }
234
+ function pinUrlToAddress(url, address) {
235
+ const pinned = new URL(url.toString());
236
+ const ipv6 = expandIpv6(address.replace(/^\[|\]$/gu, ""));
237
+ pinned.hostname = ipv6 ? `[${address.replace(/^\[|\]$/gu, "")}]` : address;
238
+ return pinned;
239
+ }
240
+ async function resolveSafeOutboundTarget(rawUrl, options = {}) {
241
+ const resolvedOptions = productionSafeOutboundOptions(options);
242
+ const parsed = assertSafeOutboundUrl(rawUrl, resolvedOptions);
243
+ if (resolvedOptions.resolveDns === false || resolvedOptions.allowPrivate) {
244
+ return { url: parsed, addresses: [] };
167
245
  }
168
246
  const hostname = parsed.hostname.trim().toLowerCase();
169
- const results = await dnsLookup(hostname, { all: true, verbatim: true });
170
- if (results.some((result) => isBlockedIpAddress(result.address))) {
247
+ if (isLiteralIpHostname(hostname)) {
248
+ return { url: parsed, addresses: [hostname] };
249
+ }
250
+ let results;
251
+ try {
252
+ results = await dnsLookup(hostname, { all: true, verbatim: true });
253
+ } catch {
171
254
  throw new BadRequestError("Webhook URL targets a blocked host.");
172
255
  }
173
- return parsed;
256
+ if (results.length === 0 || results.some((result) => isBlockedIpAddress(result.address))) {
257
+ throw new BadRequestError("Webhook URL targets a blocked host.");
258
+ }
259
+ return { url: parsed, addresses: results.map((result) => result.address) };
174
260
  }
175
261
 
176
262
  // ../../src/core/security/safeFetch.ts
@@ -178,25 +264,40 @@ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
178
264
  async function safeFetch(input, init = {}, options = {}) {
179
265
  const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
180
266
  const maxRedirects = options.maxRedirects ?? 0;
181
- const resolveDns = options.resolveDns ?? appEnv() === "production";
182
- const urlOptions = { allowHttp: options.allowHttp, resolveDns };
267
+ const resolveDns = options.resolveDns ?? true;
268
+ const urlOptions = {
269
+ allowHttp: options.allowHttp,
270
+ resolveDns,
271
+ allowPrivate: options.allowPrivate
272
+ };
183
273
  const controller = new AbortController;
184
274
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
185
275
  try {
186
- let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
276
+ let current = await resolveSafeOutboundTarget(input, urlOptions);
187
277
  let redirectCount = 0;
188
278
  while (true) {
189
- const response = await fetch(currentUrl, {
279
+ const address = current.addresses[0];
280
+ const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
281
+ const headers = new Headers(init.headers);
282
+ if (address && !headers.has("host")) {
283
+ headers.set("Host", current.url.host);
284
+ }
285
+ const fetchInit = {
190
286
  ...init,
287
+ headers,
191
288
  signal: controller.signal,
192
289
  redirect: "manual"
193
- });
290
+ };
291
+ if (address) {
292
+ fetchInit.tls = { serverName: current.url.hostname };
293
+ }
294
+ const response = await fetch(fetchUrl, fetchInit);
194
295
  if (response.status >= 300 && response.status < 400) {
195
296
  const location = response.headers.get("location");
196
297
  if (!location || redirectCount >= maxRedirects) {
197
298
  return response;
198
299
  }
199
- currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
300
+ current = await resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions);
200
301
  redirectCount += 1;
201
302
  continue;
202
303
  }
@@ -0,0 +1,25 @@
1
+ // @bun
2
+ // ../../src/core/security/safePath.ts
3
+ import { isAbsolute, resolve, sep } from "path";
4
+ function assertPathUnderRoot(rootDirectory, userPath) {
5
+ const trimmed = userPath.trim();
6
+ if (trimmed.length === 0) {
7
+ throw new Error("Path must not be empty.");
8
+ }
9
+ if (trimmed.includes("\x00")) {
10
+ throw new Error("Path must not contain a null byte.");
11
+ }
12
+ const root = resolve(rootDirectory);
13
+ const candidate = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, trimmed.replace(/^\/+/u, ""));
14
+ if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
15
+ return candidate;
16
+ }
17
+ throw new Error("Path escapes the storage root.");
18
+ }
19
+ function assertUrlPathUnderRoot(rootDirectory, urlPathname) {
20
+ return assertPathUnderRoot(rootDirectory, urlPathname.replace(/^\/+/u, ""));
21
+ }
22
+ export {
23
+ assertPathUnderRoot,
24
+ assertUrlPathUnderRoot
25
+ };
@@ -30,14 +30,21 @@ function parseScimTenantTokens(raw) {
30
30
  return tokens;
31
31
  }
32
32
  function resolveScimTenantFromToken(token) {
33
+ const presented = token.trim();
34
+ if (presented.length === 0) {
35
+ return null;
36
+ }
33
37
  const tenantTokens = parseScimTenantTokens(process.env.SCIM_TENANT_TOKENS);
34
38
  for (const [tenantId, expectedToken] of tenantTokens) {
35
- if (timingSafeCompareString(token, expectedToken)) {
39
+ if (timingSafeCompareString(presented, expectedToken)) {
36
40
  return tenantId;
37
41
  }
38
42
  }
39
- const fallbackToken = process.env.SCIM_BEARER_TOKEN ?? "";
40
- if (timingSafeCompareString(token, fallbackToken)) {
43
+ const fallbackToken = process.env.SCIM_BEARER_TOKEN?.trim() ?? "";
44
+ if (fallbackToken.length === 0) {
45
+ return null;
46
+ }
47
+ if (timingSafeCompareString(presented, fallbackToken)) {
41
48
  return 1;
42
49
  }
43
50
  return null;
@@ -85,6 +85,17 @@ function sdkClientClassName() {
85
85
  return fromName ? `${fromName}Client` : "AppClient";
86
86
  }
87
87
 
88
+ // ../../src/core/security/timingSafeCompare.ts
89
+ import { timingSafeEqual } from "crypto";
90
+ function timingSafeCompareString(left, right) {
91
+ const leftBuffer = Buffer.from(left);
92
+ const rightBuffer = Buffer.from(right);
93
+ if (leftBuffer.length !== rightBuffer.length) {
94
+ return false;
95
+ }
96
+ return timingSafeEqual(leftBuffer, rightBuffer);
97
+ }
98
+
88
99
  // ../../src/core/security/totp.ts
89
100
  function decodeBase32(input) {
90
101
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
@@ -131,7 +142,7 @@ function buildOtpauthUrl(options) {
131
142
  });
132
143
  return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
133
144
  }
134
- function generateTotp(secret, counter, digits = 6) {
145
+ function generateTotp(secret, counter = Math.floor(Date.now() / 30000), digits = 6) {
135
146
  const key = decodeBase32(secret);
136
147
  const buffer = Buffer.alloc(8);
137
148
  buffer.writeBigUInt64BE(BigInt(counter));
@@ -151,12 +162,13 @@ function verifyTotp(secret, token, window = 1) {
151
162
  return false;
152
163
  }
153
164
  const timestep = Math.floor(Date.now() / 30000);
165
+ let matched = false;
154
166
  for (let offset = -window;offset <= window; offset += 1) {
155
- if (generateTotp(secret, timestep + offset) === normalized) {
156
- return true;
167
+ if (timingSafeCompareString(generateTotp(secret, timestep + offset), normalized)) {
168
+ matched = true;
157
169
  }
158
170
  }
159
- return false;
171
+ return matched;
160
172
  }
161
173
  export {
162
174
  buildOtpauthUrl,
@@ -1,9 +1,31 @@
1
1
  // @bun
2
2
  // ../../src/core/storage/storage.ts
3
3
  import { mkdir, readFile, unlink, writeFile } from "fs/promises";
4
- import { dirname, join } from "path";
4
+ import { dirname } from "path";
5
5
  var {S3Client } = globalThis.Bun;
6
6
 
7
+ // ../../src/core/security/safePath.ts
8
+ import { isAbsolute, resolve, sep } from "path";
9
+ function assertPathUnderRoot(rootDirectory, userPath) {
10
+ const trimmed = userPath.trim();
11
+ if (trimmed.length === 0) {
12
+ throw new Error("Path must not be empty.");
13
+ }
14
+ if (trimmed.includes("\x00")) {
15
+ throw new Error("Path must not contain a null byte.");
16
+ }
17
+ const root = resolve(rootDirectory);
18
+ const candidate = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, trimmed.replace(/^\/+/u, ""));
19
+ if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
20
+ return candidate;
21
+ }
22
+ throw new Error("Path escapes the storage root.");
23
+ }
24
+ function assertUrlPathUnderRoot(rootDirectory, urlPathname) {
25
+ return assertPathUnderRoot(rootDirectory, urlPathname.replace(/^\/+/u, ""));
26
+ }
27
+
28
+ // ../../src/core/storage/storage.ts
7
29
  class LocalStorageDriver {
8
30
  rootDirectory;
9
31
  constructor(rootDirectory) {
@@ -13,7 +35,7 @@ class LocalStorageDriver {
13
35
  return this.rootDirectory ?? process.env.STORAGE_PATH ?? "storage";
14
36
  }
15
37
  resolvePath(path) {
16
- return join(this.resolveRootDirectory(), path.replace(/^\/+/, ""));
38
+ return assertPathUnderRoot(this.resolveRootDirectory(), path);
17
39
  }
18
40
  async put(path, contents) {
19
41
  const absolutePath = this.resolvePath(path);
@@ -53,28 +53,50 @@ async function applyBypassToTransaction(transaction, bypass) {
53
53
  bypass ? "true" : "false"
54
54
  ]);
55
55
  }
56
- async function runWithMigrationBypass(callback) {
57
- if (!isRlsTenancy()) {
58
- return await callback();
56
+ function stringifyBypassIdentifier(identifier) {
57
+ if (typeof identifier === "number") {
58
+ return String(identifier);
59
59
  }
60
- if (hasActiveDatabaseConnection()) {
61
- const activeConnection = getActiveDatabaseConnection(getDefaultDatabasePool());
62
- await applyBypassToTransaction(activeConnection, true);
63
- try {
64
- return await callback();
65
- } finally {
66
- await applyBypassToTransaction(activeConnection, false);
60
+ return identifier.trim();
61
+ }
62
+ async function applyIdentifierToTransaction(transaction, identifier) {
63
+ await transaction.unsafe(`SELECT set_config('app.bypass_identifier', $1, true)`, [
64
+ stringifyBypassIdentifier(identifier)
65
+ ]);
66
+ }
67
+ function assertBypassIdentifier(identifier) {
68
+ if (typeof identifier === "number") {
69
+ if (!Number.isInteger(identifier) || identifier <= 0) {
70
+ throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
67
71
  }
72
+ return;
73
+ }
74
+ if (typeof identifier === "string" && identifier.trim() !== "") {
75
+ return;
76
+ }
77
+ throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
78
+ }
79
+ async function runWithScopedTenantTransaction(apply, callback) {
80
+ if (!isRlsTenancy()) {
81
+ return await callback();
68
82
  }
69
83
  const pool = getDefaultDatabasePool();
70
84
  if (typeof pool.begin !== "function") {
71
85
  throw new Error("RLS migration bypass requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
72
86
  }
73
87
  return await pool.begin(async (transaction) => {
74
- await applyBypassToTransaction(transaction, true);
88
+ await apply(transaction);
75
89
  return await runWithDatabaseConnection(transaction, callback);
76
90
  });
77
91
  }
92
+ async function runWithMigrationBypass(callback) {
93
+ return await runWithScopedTenantTransaction((transaction) => applyBypassToTransaction(transaction, true), callback);
94
+ }
95
+ async function runWithMigrationBypassForIdentifier(identifier, callback) {
96
+ assertBypassIdentifier(identifier);
97
+ return await runWithScopedTenantTransaction((transaction) => applyIdentifierToTransaction(transaction, identifier), callback);
98
+ }
78
99
  export {
79
- runWithMigrationBypass
100
+ runWithMigrationBypass,
101
+ runWithMigrationBypassForIdentifier
80
102
  };