@getstrata/core 2.2.39 → 2.2.41
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 +12 -0
- package/dist/entries/audit/exportAuditLogs.js +133 -167
- package/dist/entries/auth/membershipMiddleware.js +1 -113
- package/dist/entries/auth/membershipScope.js +0 -36
- package/dist/entries/auth/membershipService.js +1 -43
- package/dist/entries/auth/oauth/oidcProvider.js +21 -260
- package/dist/entries/auth/oauth/providers.js +1 -238
- package/dist/entries/http/memoryThrottleMiddleware.js +3 -1
- package/dist/entries/http/response.js +4 -299
- package/dist/entries/http/scimThrottleMiddleware.js +3 -1
- package/dist/entries/http/throttleMiddleware.js +3 -1
- package/dist/entries/jobs/exportAuditLogsJob.js +133 -167
- package/dist/entries/security/safeFetch.js +1 -260
- package/dist/index.js +8 -6
- package/package.json +1 -1
|
@@ -121,7 +121,8 @@ var init_transactionControl = __esm(() => {
|
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
// ../../src/core/http/response.ts
|
|
124
|
-
import { toHttpError as
|
|
124
|
+
import { toHttpError as toHttpError2 } from "@getstrata/core/errors/http";
|
|
125
|
+
import { logServerError, webErrorResponse } from "@getstrata/core/http/webErrorResponse";
|
|
125
126
|
|
|
126
127
|
// ../../src/core/database/errors.ts
|
|
127
128
|
import {
|
|
@@ -258,302 +259,6 @@ function currentRequestMeta() {
|
|
|
258
259
|
};
|
|
259
260
|
}
|
|
260
261
|
|
|
261
|
-
// ../../src/core/http/webErrorResponse.ts
|
|
262
|
-
import { toHttpError as toHttpError2, ValidationError } from "@getstrata/core/errors/http";
|
|
263
|
-
import { appLogger } from "@getstrata/core/logging/logger";
|
|
264
|
-
|
|
265
|
-
// ../../src/core/runtime/appEnv.ts
|
|
266
|
-
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
267
|
-
function normalizeEnvValue(value) {
|
|
268
|
-
return (value ?? "").trim().toLowerCase();
|
|
269
|
-
}
|
|
270
|
-
function isProductionEnv(env = process.env) {
|
|
271
|
-
const appEnv = normalizeEnvValue(env.APP_ENV);
|
|
272
|
-
const nodeEnv = normalizeEnvValue(env.NODE_ENV);
|
|
273
|
-
if (appEnv === "production" || nodeEnv === "production") {
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
if (appEnv === "") {
|
|
277
|
-
return false;
|
|
278
|
-
}
|
|
279
|
-
return !NON_PRODUCTION_APP_ENVS.has(appEnv);
|
|
280
|
-
}
|
|
281
|
-
function envFlagEnabled(value) {
|
|
282
|
-
return value === "true";
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// ../../src/core/runtime/frontendMode.ts
|
|
286
|
-
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
287
|
-
var DEFAULT_SPA_PREFIX = "/app";
|
|
288
|
-
var FRONTEND_MODE_PATTERN = new RegExp(`^(${FRONTEND_MODES.join("|")})$`);
|
|
289
|
-
function parseFrontendMode(value) {
|
|
290
|
-
const mode = (value ?? "api").trim();
|
|
291
|
-
if (mode === "server-htmx" || mode === "spa-react" || mode === "hybrid") {
|
|
292
|
-
return mode;
|
|
293
|
-
}
|
|
294
|
-
return "api";
|
|
295
|
-
}
|
|
296
|
-
function readFrontendMode() {
|
|
297
|
-
return parseFrontendMode(process.env.FRONTEND_MODE);
|
|
298
|
-
}
|
|
299
|
-
function isViewsMode(mode) {
|
|
300
|
-
return mode === "server-htmx" || mode === "hybrid";
|
|
301
|
-
}
|
|
302
|
-
function isSpaMode(mode) {
|
|
303
|
-
return mode === "spa-react" || mode === "hybrid";
|
|
304
|
-
}
|
|
305
|
-
function isViewsEnabled() {
|
|
306
|
-
return isViewsMode(readFrontendMode());
|
|
307
|
-
}
|
|
308
|
-
function isSpaEnabled() {
|
|
309
|
-
return isSpaMode(readFrontendMode());
|
|
310
|
-
}
|
|
311
|
-
function normalizeSpaPrefix(value) {
|
|
312
|
-
const raw = (value ?? DEFAULT_SPA_PREFIX).trim() || DEFAULT_SPA_PREFIX;
|
|
313
|
-
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
314
|
-
const trimmed = withSlash.replace(/\/+$/, "");
|
|
315
|
-
if (trimmed.length === 0 || trimmed === "/") {
|
|
316
|
-
return DEFAULT_SPA_PREFIX;
|
|
317
|
-
}
|
|
318
|
-
return trimmed;
|
|
319
|
-
}
|
|
320
|
-
function readSpaPrefix() {
|
|
321
|
-
return normalizeSpaPrefix(process.env.SPA_PREFIX);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// ../../src/core/view/webErrorView.ts
|
|
325
|
-
import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
|
|
326
|
-
|
|
327
|
-
// ../../src/core/http/safeInternalPath.ts
|
|
328
|
-
var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
|
|
329
|
-
function looksLikeExternalTarget(value) {
|
|
330
|
-
const trimmed = value.trim();
|
|
331
|
-
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
332
|
-
return true;
|
|
333
|
-
}
|
|
334
|
-
if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
|
|
335
|
-
return true;
|
|
336
|
-
}
|
|
337
|
-
try {
|
|
338
|
-
const decoded = decodeURIComponent(trimmed);
|
|
339
|
-
if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
|
|
340
|
-
return true;
|
|
341
|
-
}
|
|
342
|
-
} catch {
|
|
343
|
-
return true;
|
|
344
|
-
}
|
|
345
|
-
return false;
|
|
346
|
-
}
|
|
347
|
-
function sanitizeNestedQuery(search) {
|
|
348
|
-
if (!search) {
|
|
349
|
-
return "";
|
|
350
|
-
}
|
|
351
|
-
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
352
|
-
const next = new URLSearchParams;
|
|
353
|
-
for (const [key, value] of params.entries()) {
|
|
354
|
-
if (NESTED_REDIRECT_KEYS.has(key)) {
|
|
355
|
-
const sanitized = sanitizeInternalPath(value, "/");
|
|
356
|
-
next.set(key, sanitized);
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
if (looksLikeExternalTarget(value) && value.includes("://")) {
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
next.set(key, value);
|
|
363
|
-
}
|
|
364
|
-
const serialized = next.toString();
|
|
365
|
-
return serialized ? `?${serialized}` : "";
|
|
366
|
-
}
|
|
367
|
-
function sanitizeInternalPath(raw, fallback = "/") {
|
|
368
|
-
const trimmed = raw.trim();
|
|
369
|
-
if (looksLikeExternalTarget(trimmed)) {
|
|
370
|
-
return fallback;
|
|
371
|
-
}
|
|
372
|
-
const queryIndex = trimmed.indexOf("?");
|
|
373
|
-
const hashIndex = trimmed.indexOf("#");
|
|
374
|
-
const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
|
|
375
|
-
const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
|
|
376
|
-
if (looksLikeExternalTarget(pathname)) {
|
|
377
|
-
return fallback;
|
|
378
|
-
}
|
|
379
|
-
const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
|
|
380
|
-
return `${pathname}${sanitizeNestedQuery(search)}`;
|
|
381
|
-
}
|
|
382
|
-
function safeInternalRedirectPath(request, fallback = "/") {
|
|
383
|
-
const url = new URL(request.url);
|
|
384
|
-
return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
|
|
385
|
-
}
|
|
386
|
-
function loginRedirectLocation(request) {
|
|
387
|
-
return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// ../../src/core/view/htmlResponse.ts
|
|
391
|
-
function htmlResponse(html, init = {}) {
|
|
392
|
-
return new Response(html, {
|
|
393
|
-
status: init.status ?? 200,
|
|
394
|
-
statusText: init.statusText,
|
|
395
|
-
headers: {
|
|
396
|
-
"Content-Type": "text/html; charset=utf-8"
|
|
397
|
-
}
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// ../../src/core/view/webErrorView.ts
|
|
402
|
-
var configuredErrorView = {};
|
|
403
|
-
function escapeHtml(value) {
|
|
404
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
405
|
-
}
|
|
406
|
-
function errorReturnPath(request) {
|
|
407
|
-
if (!request)
|
|
408
|
-
return "/";
|
|
409
|
-
try {
|
|
410
|
-
const current = new URL(request.url);
|
|
411
|
-
const previous = new URL(request.headers.get("referer") ?? "");
|
|
412
|
-
if (previous.origin !== current.origin || (request.method === "GET" || request.method === "HEAD") && previous.pathname === current.pathname)
|
|
413
|
-
return "/";
|
|
414
|
-
return sanitizeInternalPath(previous.pathname, "/");
|
|
415
|
-
} catch {
|
|
416
|
-
return "/";
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
function renderKernelErrorChrome(input) {
|
|
420
|
-
const title = escapeHtml(input.title);
|
|
421
|
-
const message = escapeHtml(input.message);
|
|
422
|
-
const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
|
|
423
|
-
const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
|
|
424
|
-
const goBack = input.status === 422 ? `<p><a href="${escapeHtml(errorReturnPath(input.request ?? currentRequestMeta2().request))}">Go back</a></p>` : "";
|
|
425
|
-
return `<!doctype html>
|
|
426
|
-
<html lang="en">
|
|
427
|
-
<head>
|
|
428
|
-
<meta charset="UTF-8" />
|
|
429
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
430
|
-
<title>${title}</title>
|
|
431
|
-
<link rel="stylesheet" href="/assets/app.css" />
|
|
432
|
-
</head>
|
|
433
|
-
<body>
|
|
434
|
-
<header class="site-header">
|
|
435
|
-
<a class="brand" href="/">Home</a>
|
|
436
|
-
</header>
|
|
437
|
-
<main class="site-main">
|
|
438
|
-
<section class="page-header">
|
|
439
|
-
<h1>${title}</h1>
|
|
440
|
-
<p>${message}</p>
|
|
441
|
-
${details}
|
|
442
|
-
${goBack}
|
|
443
|
-
</section>
|
|
444
|
-
</main>
|
|
445
|
-
</body>
|
|
446
|
-
</html>
|
|
447
|
-
`;
|
|
448
|
-
}
|
|
449
|
-
async function renderWebErrorHtml(input) {
|
|
450
|
-
const render = configuredErrorView.render;
|
|
451
|
-
if (!render) {
|
|
452
|
-
return renderKernelErrorChrome(input);
|
|
453
|
-
}
|
|
454
|
-
try {
|
|
455
|
-
return await render({
|
|
456
|
-
...input,
|
|
457
|
-
request: input.request ?? currentRequestMeta2().request
|
|
458
|
-
});
|
|
459
|
-
} catch {
|
|
460
|
-
return renderKernelErrorChrome(input);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
async function htmlErrorResponse(input) {
|
|
464
|
-
return htmlResponse(await renderWebErrorHtml(input), { status: input.status });
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// ../../src/core/http/contentNegotiation.ts
|
|
468
|
-
function requestPrefersJson(request) {
|
|
469
|
-
if (!request) {
|
|
470
|
-
return true;
|
|
471
|
-
}
|
|
472
|
-
if (request.headers.get("HX-Request") === "true") {
|
|
473
|
-
return false;
|
|
474
|
-
}
|
|
475
|
-
const pathname = new URL(request.url).pathname;
|
|
476
|
-
if (pathname.startsWith("/api/")) {
|
|
477
|
-
return true;
|
|
478
|
-
}
|
|
479
|
-
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
480
|
-
if (accept.includes("text/html")) {
|
|
481
|
-
return false;
|
|
482
|
-
}
|
|
483
|
-
if (accept.includes("application/json")) {
|
|
484
|
-
return true;
|
|
485
|
-
}
|
|
486
|
-
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
487
|
-
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
488
|
-
return false;
|
|
489
|
-
}
|
|
490
|
-
return false;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// ../../src/core/http/webErrorResponse.ts
|
|
494
|
-
function normalizeFieldErrors(details) {
|
|
495
|
-
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
496
|
-
return {};
|
|
497
|
-
}
|
|
498
|
-
const errors = {};
|
|
499
|
-
for (const [field, messages] of Object.entries(details)) {
|
|
500
|
-
if (Array.isArray(messages)) {
|
|
501
|
-
errors[field] = messages.map(String);
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
if (typeof messages === "string") {
|
|
505
|
-
errors[field] = [messages];
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
return errors;
|
|
509
|
-
}
|
|
510
|
-
function errorPageTitle(status, message) {
|
|
511
|
-
if (status === 404) {
|
|
512
|
-
return "Not Found";
|
|
513
|
-
}
|
|
514
|
-
if (status === 403) {
|
|
515
|
-
return "Forbidden";
|
|
516
|
-
}
|
|
517
|
-
if (status >= 500) {
|
|
518
|
-
return "Server Error";
|
|
519
|
-
}
|
|
520
|
-
return message;
|
|
521
|
-
}
|
|
522
|
-
function publicErrorMessage(status, message) {
|
|
523
|
-
if (status >= 500 && isProductionEnv()) {
|
|
524
|
-
return "Something went wrong.";
|
|
525
|
-
}
|
|
526
|
-
return message;
|
|
527
|
-
}
|
|
528
|
-
function logServerError(error, mappedError) {
|
|
529
|
-
if (mappedError.status < 500) {
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
appLogger.error("Unhandled request error", {
|
|
533
|
-
status: mappedError.status,
|
|
534
|
-
error: error instanceof Error ? error.message : String(error),
|
|
535
|
-
stack: error instanceof Error ? error.stack : undefined
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
async function webErrorResponse(error, request) {
|
|
539
|
-
if (currentRequestMeta().errorFormat === "json" || !request || !isViewsEnabled() || requestPrefersJson(request)) {
|
|
540
|
-
return null;
|
|
541
|
-
}
|
|
542
|
-
const mappedError = toHttpError2(error) ?? mapDatabaseError(error);
|
|
543
|
-
logServerError(error, mappedError);
|
|
544
|
-
if (mappedError.status === 401) {
|
|
545
|
-
return Response.redirect(loginRedirectLocation(request), 302);
|
|
546
|
-
}
|
|
547
|
-
const errors = mappedError instanceof ValidationError || mappedError.name === "ValidationError" ? normalizeFieldErrors(mappedError.details) : undefined;
|
|
548
|
-
return htmlErrorResponse({
|
|
549
|
-
status: mappedError.status,
|
|
550
|
-
title: errorPageTitle(mappedError.status, mappedError.message),
|
|
551
|
-
message: publicErrorMessage(mappedError.status, mappedError.message),
|
|
552
|
-
errors,
|
|
553
|
-
request
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
|
|
557
262
|
// ../../src/core/http/response.ts
|
|
558
263
|
function jsonResponse(data, init = {}) {
|
|
559
264
|
return Response.json(data, {
|
|
@@ -568,12 +273,12 @@ function noContentResponse() {
|
|
|
568
273
|
return new Response(null, { status: 204 });
|
|
569
274
|
}
|
|
570
275
|
function errorResponse(error) {
|
|
571
|
-
const mappedError =
|
|
276
|
+
const mappedError = toHttpError2(error) ?? mapDatabaseError(error);
|
|
572
277
|
logServerError(error, mappedError);
|
|
573
278
|
return Response.json({
|
|
574
279
|
error: mappedError.message,
|
|
575
280
|
...mappedError.details === undefined ? {} : { details: mappedError.details }
|
|
576
|
-
}, { status: mappedError.status });
|
|
281
|
+
}, { status: mappedError.status, headers: { "Cache-Control": "no-store" } });
|
|
577
282
|
}
|
|
578
283
|
function withErrorHandling(handler) {
|
|
579
284
|
return async (...args) => {
|
|
@@ -457,7 +457,9 @@ async function renderWebErrorHtml(input) {
|
|
|
457
457
|
}
|
|
458
458
|
}
|
|
459
459
|
async function htmlErrorResponse(input) {
|
|
460
|
-
|
|
460
|
+
const response = htmlResponse(await renderWebErrorHtml(input), { status: input.status });
|
|
461
|
+
response.headers.set("Cache-Control", "no-store");
|
|
462
|
+
return response;
|
|
461
463
|
}
|
|
462
464
|
|
|
463
465
|
// ../../src/core/http/contentNegotiation.ts
|
|
@@ -342,7 +342,9 @@ async function renderWebErrorHtml(input) {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
async function htmlErrorResponse(input) {
|
|
345
|
-
|
|
345
|
+
const response = htmlResponse(await renderWebErrorHtml(input), { status: input.status });
|
|
346
|
+
response.headers.set("Cache-Control", "no-store");
|
|
347
|
+
return response;
|
|
346
348
|
}
|
|
347
349
|
|
|
348
350
|
// ../../src/core/http/contentNegotiation.ts
|
|
@@ -995,10 +995,141 @@ function sdkClientClassName() {
|
|
|
995
995
|
return fromName ? `${fromName}Client` : "AppClient";
|
|
996
996
|
}
|
|
997
997
|
|
|
998
|
+
// ../../src/core/security/safeFetch.ts
|
|
999
|
+
import { pinUrlToAddress, resolveSafeOutboundTarget } from "@getstrata/core/security/safeUrl";
|
|
1000
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
|
|
1001
|
+
function untilAborted(work, signal) {
|
|
1002
|
+
signal.throwIfAborted();
|
|
1003
|
+
return new Promise((resolve, reject) => {
|
|
1004
|
+
const onAbort = () => reject(signal.reason);
|
|
1005
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1006
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
function preserveResponseMetadata(response, original) {
|
|
1010
|
+
Object.defineProperties(response, {
|
|
1011
|
+
url: { value: original.url },
|
|
1012
|
+
redirected: { value: original.redirected },
|
|
1013
|
+
type: { value: original.type }
|
|
1014
|
+
});
|
|
1015
|
+
const clone = response.clone.bind(response);
|
|
1016
|
+
response.clone = () => preserveResponseMetadata(clone(), original);
|
|
1017
|
+
return response;
|
|
1018
|
+
}
|
|
1019
|
+
function withBodyDeadline(response, signal, release) {
|
|
1020
|
+
if (!response.body) {
|
|
1021
|
+
release();
|
|
1022
|
+
return response;
|
|
1023
|
+
}
|
|
1024
|
+
const reader = response.body.getReader();
|
|
1025
|
+
let stopped = false;
|
|
1026
|
+
let bodyController;
|
|
1027
|
+
const stop = (reason, errorBody) => {
|
|
1028
|
+
if (stopped)
|
|
1029
|
+
return;
|
|
1030
|
+
stopped = true;
|
|
1031
|
+
release();
|
|
1032
|
+
signal.removeEventListener("abort", onAbort);
|
|
1033
|
+
if (errorBody)
|
|
1034
|
+
bodyController.error(reason);
|
|
1035
|
+
reader.cancel(reason).catch(() => {}).finally(() => reader.releaseLock());
|
|
1036
|
+
};
|
|
1037
|
+
const onAbort = () => stop(signal.reason, true);
|
|
1038
|
+
const body = new ReadableStream({
|
|
1039
|
+
start(controller) {
|
|
1040
|
+
bodyController = controller;
|
|
1041
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1042
|
+
if (signal.aborted)
|
|
1043
|
+
onAbort();
|
|
1044
|
+
},
|
|
1045
|
+
async pull(controller) {
|
|
1046
|
+
try {
|
|
1047
|
+
const result = await untilAborted(reader.read(), signal);
|
|
1048
|
+
if (stopped)
|
|
1049
|
+
return;
|
|
1050
|
+
if (result.done) {
|
|
1051
|
+
stopped = true;
|
|
1052
|
+
release();
|
|
1053
|
+
signal.removeEventListener("abort", onAbort);
|
|
1054
|
+
reader.releaseLock();
|
|
1055
|
+
controller.close();
|
|
1056
|
+
} else {
|
|
1057
|
+
controller.enqueue(result.value);
|
|
1058
|
+
}
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
stop(error, true);
|
|
1061
|
+
}
|
|
1062
|
+
},
|
|
1063
|
+
cancel(reason) {
|
|
1064
|
+
stop(reason, false);
|
|
1065
|
+
}
|
|
1066
|
+
}, { highWaterMark: 0 });
|
|
1067
|
+
return preserveResponseMetadata(new Response(body, {
|
|
1068
|
+
status: response.status,
|
|
1069
|
+
statusText: response.statusText,
|
|
1070
|
+
headers: response.headers
|
|
1071
|
+
}), response);
|
|
1072
|
+
}
|
|
1073
|
+
async function safeFetch(input, init = {}, options = {}) {
|
|
1074
|
+
init.signal?.throwIfAborted();
|
|
1075
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
1076
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
1077
|
+
throw new RangeError("safeFetch timeoutMs must be a positive finite number.");
|
|
1078
|
+
}
|
|
1079
|
+
const maxRedirects = options.maxRedirects ?? 0;
|
|
1080
|
+
const urlOptions = {
|
|
1081
|
+
allowHttp: options.allowHttp,
|
|
1082
|
+
resolveDns: options.resolveDns ?? true,
|
|
1083
|
+
allowPrivate: options.allowPrivate
|
|
1084
|
+
};
|
|
1085
|
+
const controller = new AbortController;
|
|
1086
|
+
const forwardAbort = () => controller.abort(init.signal?.reason);
|
|
1087
|
+
init.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
1088
|
+
const timeout = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
|
|
1089
|
+
const release = () => {
|
|
1090
|
+
clearTimeout(timeout);
|
|
1091
|
+
init.signal?.removeEventListener("abort", forwardAbort);
|
|
1092
|
+
};
|
|
1093
|
+
try {
|
|
1094
|
+
let current = await untilAborted(resolveSafeOutboundTarget(input, urlOptions), controller.signal);
|
|
1095
|
+
let redirectCount = 0;
|
|
1096
|
+
while (true) {
|
|
1097
|
+
controller.signal.throwIfAborted();
|
|
1098
|
+
const address = current.addresses[0];
|
|
1099
|
+
const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
|
|
1100
|
+
const headers = new Headers(init.headers);
|
|
1101
|
+
if (address && !headers.has("host"))
|
|
1102
|
+
headers.set("Host", current.url.host);
|
|
1103
|
+
const fetchInit = {
|
|
1104
|
+
...init,
|
|
1105
|
+
headers,
|
|
1106
|
+
signal: controller.signal,
|
|
1107
|
+
redirect: "manual"
|
|
1108
|
+
};
|
|
1109
|
+
if (address)
|
|
1110
|
+
fetchInit.tls = { serverName: current.url.hostname };
|
|
1111
|
+
const response = await untilAborted(fetch(fetchUrl, fetchInit), controller.signal);
|
|
1112
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1113
|
+
const location = response.headers.get("location");
|
|
1114
|
+
if (location && redirectCount < maxRedirects) {
|
|
1115
|
+
if (response.body)
|
|
1116
|
+
await untilAborted(response.body.cancel(), controller.signal);
|
|
1117
|
+
current = await untilAborted(resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions), controller.signal);
|
|
1118
|
+
redirectCount++;
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return withBodyDeadline(response, controller.signal, release);
|
|
1123
|
+
}
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
controller.abort(error);
|
|
1126
|
+
release();
|
|
1127
|
+
throw error;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
998
1131
|
// ../../src/core/security/safeUrl.ts
|
|
999
|
-
import { lookup as dnsLookupImpl } from "dns/promises";
|
|
1000
1132
|
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
1001
|
-
var dnsLookup = dnsLookupImpl;
|
|
1002
1133
|
var BLOCKED_HOSTNAMES = new Set([
|
|
1003
1134
|
"localhost",
|
|
1004
1135
|
"127.0.0.1",
|
|
@@ -1199,171 +1330,6 @@ function assertSafeOutboundUrl(rawUrl, options = {}) {
|
|
|
1199
1330
|
}
|
|
1200
1331
|
return parsed;
|
|
1201
1332
|
}
|
|
1202
|
-
function isBlockedIpAddress(address) {
|
|
1203
|
-
return isBlockedHostname(address.trim().toLowerCase());
|
|
1204
|
-
}
|
|
1205
|
-
function isLiteralIpHostname(hostname) {
|
|
1206
|
-
return parseCanonicalDottedDecimal(hostname) !== null || expandIpv6(hostname) !== null;
|
|
1207
|
-
}
|
|
1208
|
-
function pinUrlToAddress(url, address) {
|
|
1209
|
-
const pinned = new URL(url.toString());
|
|
1210
|
-
const ipv6 = expandIpv6(address.replace(/^\[|\]$/gu, ""));
|
|
1211
|
-
pinned.hostname = ipv6 ? `[${address.replace(/^\[|\]$/gu, "")}]` : address;
|
|
1212
|
-
return pinned;
|
|
1213
|
-
}
|
|
1214
|
-
async function resolveSafeOutboundTarget(rawUrl, options = {}) {
|
|
1215
|
-
const resolvedOptions = productionSafeOutboundOptions(options);
|
|
1216
|
-
const parsed = assertSafeOutboundUrl(rawUrl, resolvedOptions);
|
|
1217
|
-
if (resolvedOptions.resolveDns === false || resolvedOptions.allowPrivate) {
|
|
1218
|
-
return { url: parsed, addresses: [] };
|
|
1219
|
-
}
|
|
1220
|
-
const hostname = parsed.hostname.trim().toLowerCase();
|
|
1221
|
-
if (isLiteralIpHostname(hostname)) {
|
|
1222
|
-
return { url: parsed, addresses: [hostname] };
|
|
1223
|
-
}
|
|
1224
|
-
let results;
|
|
1225
|
-
try {
|
|
1226
|
-
results = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
1227
|
-
} catch {
|
|
1228
|
-
throw new BadRequestError("Webhook URL targets a blocked host.");
|
|
1229
|
-
}
|
|
1230
|
-
if (results.length === 0 || results.some((result) => isBlockedIpAddress(result.address))) {
|
|
1231
|
-
throw new BadRequestError("Webhook URL targets a blocked host.");
|
|
1232
|
-
}
|
|
1233
|
-
return { url: parsed, addresses: results.map((result) => result.address) };
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
// ../../src/core/security/safeFetch.ts
|
|
1237
|
-
var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
|
|
1238
|
-
function untilAborted(work, signal) {
|
|
1239
|
-
signal.throwIfAborted();
|
|
1240
|
-
return new Promise((resolve, reject) => {
|
|
1241
|
-
const onAbort = () => reject(signal.reason);
|
|
1242
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
1243
|
-
work.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
1244
|
-
});
|
|
1245
|
-
}
|
|
1246
|
-
function preserveResponseMetadata(response, original) {
|
|
1247
|
-
Object.defineProperties(response, {
|
|
1248
|
-
url: { value: original.url },
|
|
1249
|
-
redirected: { value: original.redirected },
|
|
1250
|
-
type: { value: original.type }
|
|
1251
|
-
});
|
|
1252
|
-
const clone = response.clone.bind(response);
|
|
1253
|
-
response.clone = () => preserveResponseMetadata(clone(), original);
|
|
1254
|
-
return response;
|
|
1255
|
-
}
|
|
1256
|
-
function withBodyDeadline(response, signal, release) {
|
|
1257
|
-
if (!response.body) {
|
|
1258
|
-
release();
|
|
1259
|
-
return response;
|
|
1260
|
-
}
|
|
1261
|
-
const reader = response.body.getReader();
|
|
1262
|
-
let stopped = false;
|
|
1263
|
-
let bodyController;
|
|
1264
|
-
const stop = (reason, errorBody) => {
|
|
1265
|
-
if (stopped)
|
|
1266
|
-
return;
|
|
1267
|
-
stopped = true;
|
|
1268
|
-
release();
|
|
1269
|
-
signal.removeEventListener("abort", onAbort);
|
|
1270
|
-
if (errorBody)
|
|
1271
|
-
bodyController.error(reason);
|
|
1272
|
-
reader.cancel(reason).catch(() => {}).finally(() => reader.releaseLock());
|
|
1273
|
-
};
|
|
1274
|
-
const onAbort = () => stop(signal.reason, true);
|
|
1275
|
-
const body = new ReadableStream({
|
|
1276
|
-
start(controller) {
|
|
1277
|
-
bodyController = controller;
|
|
1278
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
1279
|
-
if (signal.aborted)
|
|
1280
|
-
onAbort();
|
|
1281
|
-
},
|
|
1282
|
-
async pull(controller) {
|
|
1283
|
-
try {
|
|
1284
|
-
const result = await untilAborted(reader.read(), signal);
|
|
1285
|
-
if (stopped)
|
|
1286
|
-
return;
|
|
1287
|
-
if (result.done) {
|
|
1288
|
-
stopped = true;
|
|
1289
|
-
release();
|
|
1290
|
-
signal.removeEventListener("abort", onAbort);
|
|
1291
|
-
reader.releaseLock();
|
|
1292
|
-
controller.close();
|
|
1293
|
-
} else {
|
|
1294
|
-
controller.enqueue(result.value);
|
|
1295
|
-
}
|
|
1296
|
-
} catch (error) {
|
|
1297
|
-
stop(error, true);
|
|
1298
|
-
}
|
|
1299
|
-
},
|
|
1300
|
-
cancel(reason) {
|
|
1301
|
-
stop(reason, false);
|
|
1302
|
-
}
|
|
1303
|
-
}, { highWaterMark: 0 });
|
|
1304
|
-
return preserveResponseMetadata(new Response(body, {
|
|
1305
|
-
status: response.status,
|
|
1306
|
-
statusText: response.statusText,
|
|
1307
|
-
headers: response.headers
|
|
1308
|
-
}), response);
|
|
1309
|
-
}
|
|
1310
|
-
async function safeFetch(input, init = {}, options = {}) {
|
|
1311
|
-
init.signal?.throwIfAborted();
|
|
1312
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
1313
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
1314
|
-
throw new RangeError("safeFetch timeoutMs must be a positive finite number.");
|
|
1315
|
-
}
|
|
1316
|
-
const maxRedirects = options.maxRedirects ?? 0;
|
|
1317
|
-
const urlOptions = {
|
|
1318
|
-
allowHttp: options.allowHttp,
|
|
1319
|
-
resolveDns: options.resolveDns ?? true,
|
|
1320
|
-
allowPrivate: options.allowPrivate
|
|
1321
|
-
};
|
|
1322
|
-
const controller = new AbortController;
|
|
1323
|
-
const forwardAbort = () => controller.abort(init.signal?.reason);
|
|
1324
|
-
init.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
1325
|
-
const timeout = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
|
|
1326
|
-
const release = () => {
|
|
1327
|
-
clearTimeout(timeout);
|
|
1328
|
-
init.signal?.removeEventListener("abort", forwardAbort);
|
|
1329
|
-
};
|
|
1330
|
-
try {
|
|
1331
|
-
let current = await untilAborted(resolveSafeOutboundTarget(input, urlOptions), controller.signal);
|
|
1332
|
-
let redirectCount = 0;
|
|
1333
|
-
while (true) {
|
|
1334
|
-
controller.signal.throwIfAborted();
|
|
1335
|
-
const address = current.addresses[0];
|
|
1336
|
-
const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
|
|
1337
|
-
const headers = new Headers(init.headers);
|
|
1338
|
-
if (address && !headers.has("host"))
|
|
1339
|
-
headers.set("Host", current.url.host);
|
|
1340
|
-
const fetchInit = {
|
|
1341
|
-
...init,
|
|
1342
|
-
headers,
|
|
1343
|
-
signal: controller.signal,
|
|
1344
|
-
redirect: "manual"
|
|
1345
|
-
};
|
|
1346
|
-
if (address)
|
|
1347
|
-
fetchInit.tls = { serverName: current.url.hostname };
|
|
1348
|
-
const response = await untilAborted(fetch(fetchUrl, fetchInit), controller.signal);
|
|
1349
|
-
if (response.status >= 300 && response.status < 400) {
|
|
1350
|
-
const location = response.headers.get("location");
|
|
1351
|
-
if (location && redirectCount < maxRedirects) {
|
|
1352
|
-
if (response.body)
|
|
1353
|
-
await untilAborted(response.body.cancel(), controller.signal);
|
|
1354
|
-
current = await untilAborted(resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions), controller.signal);
|
|
1355
|
-
redirectCount++;
|
|
1356
|
-
continue;
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
return withBodyDeadline(response, controller.signal, release);
|
|
1360
|
-
}
|
|
1361
|
-
} catch (error) {
|
|
1362
|
-
controller.abort(error);
|
|
1363
|
-
release();
|
|
1364
|
-
throw error;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
1333
|
|
|
1368
1334
|
// ../../src/core/audit/exportAuditLogs.ts
|
|
1369
1335
|
init_databaseTenantContext();
|