@getstrata/core 1.0.9 → 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.
- package/CHANGELOG.md +48 -0
- package/dist/core/auth/authContext.d.ts +5 -2
- package/dist/core/auth/emailVerification.d.ts +1 -1
- package/dist/core/auth/guard.d.ts +5 -1
- package/dist/core/auth/jwtGuard.d.ts +6 -2
- package/dist/core/auth/oauth/oidcIdToken.d.ts +19 -0
- package/dist/core/auth/oauth/oidcProvider.d.ts +16 -4
- package/dist/core/auth/oauth/samlProvider.d.ts +7 -4
- package/dist/core/auth/oneTimeToken.d.ts +19 -0
- package/dist/core/auth/passwordLogin.d.ts +20 -0
- package/dist/core/auth/saml/samlAssertionReplay.d.ts +20 -0
- package/dist/core/auth/saml/samlServiceProvider.d.ts +51 -0
- package/dist/core/contracts/authUserDirectory.d.ts +2 -3
- package/dist/core/database/errors.d.ts +2 -1
- package/dist/core/http/response.d.ts +3 -1
- package/dist/core/mail/mailer.d.ts +6 -1
- package/dist/core/security/oauthState.d.ts +6 -2
- package/dist/core/security/safeFetch.d.ts +1 -0
- package/dist/core/security/safePath.d.ts +4 -0
- package/dist/core/security/safeUrl.d.ts +14 -1
- package/dist/core/security/totp.d.ts +1 -1
- package/dist/core/tenant/databaseTenantContext.d.ts +2 -1
- package/dist/core/tenant/enableTenantRls.d.ts +6 -0
- package/dist/core/tenant/postgresAppRole.d.ts +75 -0
- package/dist/entries/audit/exportAuditLogs.js +223 -40
- package/dist/entries/auth/accessControl.js +1 -0
- package/dist/entries/auth/basicAuthGuard.js +638 -17
- package/dist/entries/auth/emailVerification.js +1 -1
- package/dist/entries/auth/intendedUrlCookie.js +32 -2
- package/dist/entries/auth/jwt.js +1 -1
- package/dist/entries/auth/jwtGuard.js +192 -3
- package/dist/entries/auth/membershipMiddleware.js +1 -0
- package/dist/entries/auth/membershipScope.js +1 -0
- package/dist/entries/auth/membershipService.js +1 -0
- package/dist/entries/auth/oauth/oidcProvider.js +542 -20
- package/dist/entries/auth/oauth/providers.js +313 -5
- package/dist/entries/auth/oauth/samlProvider.js +552 -74
- package/dist/entries/auth/oneTimeToken.js +362 -0
- package/dist/entries/auth/password.js +1 -1
- package/dist/entries/auth/passwordLogin.js +548 -0
- package/dist/entries/auth/policy.js +1 -0
- package/dist/entries/auth/saml/samlServiceProvider.js +571 -0
- package/dist/entries/auth/scimAuthMiddleware.js +10 -3
- package/dist/entries/auth/sessionCookie.js +1 -1
- package/dist/entries/auth/sessionGuard.js +1 -1
- package/dist/entries/auth/tokenHash.js +2 -6
- package/dist/entries/crypto/fieldEncryption.js +1 -1
- package/dist/entries/crypto/mfaSecret.js +31 -5
- package/dist/entries/database/errors.js +29 -12
- package/dist/entries/database/model.js +9 -9
- package/dist/entries/facades.js +72 -9
- package/dist/entries/http/authMiddleware.js +7 -14
- package/dist/entries/http/corsMiddleware.js +69 -2
- package/dist/entries/http/csrfMiddleware.js +33 -43
- package/dist/entries/http/csrfToken.js +2 -2
- package/dist/entries/http/flashMiddleware.js +4 -2
- package/dist/entries/http/flashSession.js +4 -2
- package/dist/entries/http/formRequest.js +2 -2
- package/dist/entries/http/memoryThrottleMiddleware.js +63 -0
- package/dist/entries/http/pagination.js +2 -2
- package/dist/entries/http/requirePasswordConfirmMiddleware.js +36 -3
- package/dist/entries/http/requireWebAuthMiddleware.js +32 -2
- package/dist/entries/http/response.js +101 -45
- package/dist/entries/http/routeModelBinding.js +2 -2
- package/dist/entries/http/safeInternalPath.js +32 -2
- package/dist/entries/http/scimThrottleMiddleware.js +7 -1
- package/dist/entries/http/securedRouteModelBinding.js +2 -2
- package/dist/entries/http/securityHeadersMiddleware.js +3 -64
- package/dist/entries/http/signedUrl.js +6 -5
- package/dist/entries/http/throttleMiddleware.js +63 -0
- package/dist/entries/http/validation.js +2 -2
- package/dist/entries/http/webFormRequest.js +2 -2
- package/dist/entries/jobs/exportAuditLogsJob.js +223 -40
- package/dist/entries/mail/mailer.js +54 -8
- package/dist/entries/media/imageTransform.js +12 -1
- package/dist/entries/openapi/generator.js +0 -7
- package/dist/entries/security/oauthState.js +30 -39
- package/dist/entries/security/publicReads.js +1 -1
- package/dist/entries/security/recoveryCodes.js +2 -2
- package/dist/entries/security/safeFetch.js +195 -94
- package/dist/entries/security/safePath.js +25 -0
- package/dist/entries/security/scimTenantTokens.js +10 -3
- package/dist/entries/security/totp.js +16 -4
- package/dist/entries/storage/storage.js +24 -2
- package/dist/entries/tenant/databaseTenantContext.js +34 -12
- package/dist/entries/tenant/enableTenantRls.js +385 -0
- package/dist/entries/tracing/tracingMiddleware.js +308 -5
- package/dist/framework/public-api.d.ts +10 -3
- package/dist/index.js +2767 -1515
- 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 {
|
|
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:${
|
|
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 {
|
|
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:${
|
|
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
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
return
|
|
99
|
+
function parseCanonicalDottedDecimal(hostname) {
|
|
100
|
+
const parts = hostname.split(".");
|
|
101
|
+
if (parts.length !== 4) {
|
|
102
|
+
return null;
|
|
103
103
|
}
|
|
104
|
-
const octets =
|
|
105
|
-
|
|
106
|
-
|
|
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 ===
|
|
125
|
+
if (a === 169 && b === 254) {
|
|
113
126
|
return true;
|
|
114
127
|
}
|
|
115
|
-
if (a ===
|
|
128
|
+
if (a === 172 && b >= 16 && b <= 31) {
|
|
116
129
|
return true;
|
|
117
130
|
}
|
|
118
|
-
if (a ===
|
|
131
|
+
if (a === 192 && b === 168) {
|
|
119
132
|
return true;
|
|
120
133
|
}
|
|
121
|
-
if (a ===
|
|
134
|
+
if (a === 100 && b >= 64 && b <= 127) {
|
|
122
135
|
return true;
|
|
123
136
|
}
|
|
124
|
-
if (a ===
|
|
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
|
-
|
|
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:" && !(
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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 ??
|
|
185
|
-
const urlOptions = {
|
|
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
|
|
340
|
+
let current = await resolveSafeOutboundTarget(input, urlOptions);
|
|
190
341
|
let redirectCount = 0;
|
|
191
342
|
while (true) {
|
|
192
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
if (
|
|
269
|
-
return
|
|
429
|
+
function stringifyBypassIdentifier(identifier) {
|
|
430
|
+
if (typeof identifier === "number") {
|
|
431
|
+
return String(identifier);
|
|
270
432
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
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:<${
|
|
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(
|
|
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: ${
|
|
211
|
-
`To: ${
|
|
212
|
-
`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
|
-
|
|
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
|
|
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", "
|
|
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
|
|
100
|
-
const
|
|
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 [
|
|
134
|
-
if (!
|
|
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 (
|
|
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(
|
|
115
|
+
const expectedSignature = signOAuthState(nonce, issuedAt).split(".").at(-1) ?? "";
|
|
145
116
|
const expectedBuffer = Buffer.from(expectedSignature);
|
|
146
|
-
const actualBuffer = Buffer.from(
|
|
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 ?? "
|
|
4
|
+
return (process.env.FEATURE_PUBLIC_READS ?? "false") === "true";
|
|
5
5
|
}
|
|
6
6
|
function guestCanViewResource() {
|
|
7
7
|
return isPublicReadsEnabled();
|