@ts-cloud/core 0.7.108 → 0.7.109
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/dist/index.js +275 -7
- package/dist/runtime/adapter.ts +35 -2
- package/dist/serverless/index.d.ts +2 -0
- package/dist/serverless/runtime/adapter.d.ts +7 -0
- package/dist/serverless/runtime/auto-recursion.d.ts +100 -0
- package/dist/serverless/runtime/auto-recursion.test.d.ts +1 -0
- package/dist/serverless/runtime/recursion.d.ts +98 -0
- package/dist/serverless/runtime/recursion.test.d.ts +1 -0
- package/dist/types.d.ts +54 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -45382,8 +45382,9 @@ import { cpSync, mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "no
|
|
|
45382
45382
|
import { tmpdir } from "node:os";
|
|
45383
45383
|
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join7, resolve } from "node:path";
|
|
45384
45384
|
import { fileURLToPath } from "node:url";
|
|
45385
|
-
|
|
45386
|
-
|
|
45385
|
+
var RUNTIME_SOURCES = ["adapter.ts", "recursion.ts", "auto-recursion.ts"];
|
|
45386
|
+
function runtimeSourceDir() {
|
|
45387
|
+
return join7(dirname2(fileURLToPath(import.meta.url)), "runtime");
|
|
45387
45388
|
}
|
|
45388
45389
|
function runBuildHooks(hooks, cwd, onStep) {
|
|
45389
45390
|
for (const hook of hooks ?? []) {
|
|
@@ -45408,7 +45409,8 @@ async function packageServerlessApp(opts) {
|
|
|
45408
45409
|
const entryPath = isAbsolute2(entry) ? entry : join7(projectRoot, entry);
|
|
45409
45410
|
const stage = mkdtempSync(join7(tmpdir(), "tscloud-pkg-"));
|
|
45410
45411
|
try {
|
|
45411
|
-
|
|
45412
|
+
for (const source of RUNTIME_SOURCES)
|
|
45413
|
+
cpSync(join7(runtimeSourceDir(), source), join7(stage, source));
|
|
45412
45414
|
const bootstrapPath = join7(stage, "bootstrap.ts");
|
|
45413
45415
|
writeFileSync3(bootstrapPath, generateBootstrap({ entryImport: entryPath, adapterImport: "./adapter" }));
|
|
45414
45416
|
opts.onStep?.("bundling application");
|
|
@@ -46475,6 +46477,239 @@ function resolveLatestNode(major) {
|
|
|
46475
46477
|
throw new Error(`No Node release found for major version ${major}`);
|
|
46476
46478
|
return match.version.replace(/^v/, "");
|
|
46477
46479
|
}
|
|
46480
|
+
// src/serverless/runtime/auto-recursion.ts
|
|
46481
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
46482
|
+
|
|
46483
|
+
// src/serverless/runtime/recursion.ts
|
|
46484
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
46485
|
+
var DEPTH_HEADER = "x-ts-cloud-invoke-depth";
|
|
46486
|
+
var CHAIN_HEADER = "x-ts-cloud-invoke-chain";
|
|
46487
|
+
var TRACE_HEADER = "x-ts-cloud-trace-id";
|
|
46488
|
+
function functionFingerprint(functionId) {
|
|
46489
|
+
return createHash5("sha256").update(functionId).digest("hex").slice(0, 8);
|
|
46490
|
+
}
|
|
46491
|
+
var DEFAULT_RECURSION_LIMITS = {
|
|
46492
|
+
maxDepth: 10,
|
|
46493
|
+
maxRepeats: 3,
|
|
46494
|
+
maxInvocationsPerTrace: 100,
|
|
46495
|
+
traceWindowMs: 60000,
|
|
46496
|
+
breakerThreshold: 5,
|
|
46497
|
+
breakerCooldownMs: 60000
|
|
46498
|
+
};
|
|
46499
|
+
function readHeader(headers, name) {
|
|
46500
|
+
if (typeof headers.get === "function")
|
|
46501
|
+
return headers.get(name) ?? undefined;
|
|
46502
|
+
const record = headers;
|
|
46503
|
+
return record[name] ?? record[name.toLowerCase()] ?? record[name.toUpperCase()];
|
|
46504
|
+
}
|
|
46505
|
+
function parseChain(value, maxEntries = 64) {
|
|
46506
|
+
if (!value)
|
|
46507
|
+
return [];
|
|
46508
|
+
return value.split(".").map((entry) => entry.trim()).filter((entry) => /^[0-9a-f]{8}$/.test(entry)).slice(-maxEntries);
|
|
46509
|
+
}
|
|
46510
|
+
function inspectInvocation(context, limits = DEFAULT_RECURSION_LIMITS) {
|
|
46511
|
+
const fingerprint = functionFingerprint(context.functionId);
|
|
46512
|
+
const incoming = parseChain(readHeader(context.headers, CHAIN_HEADER));
|
|
46513
|
+
const traceId = context.traceId ?? readHeader(context.headers, TRACE_HEADER) ?? crypto.randomUUID();
|
|
46514
|
+
const chain = [...incoming, fingerprint];
|
|
46515
|
+
const repeats = incoming.filter((entry) => entry === fingerprint).length;
|
|
46516
|
+
const selfInvocation = incoming[incoming.length - 1] === fingerprint;
|
|
46517
|
+
const headerDepth = Number.parseInt(readHeader(context.headers, DEPTH_HEADER) ?? "", 10);
|
|
46518
|
+
const depth = Math.max(chain.length, Number.isFinite(headerDepth) ? headerDepth + 1 : 0);
|
|
46519
|
+
let reason = "ok";
|
|
46520
|
+
if (depth > limits.maxDepth)
|
|
46521
|
+
reason = "depth_exceeded";
|
|
46522
|
+
else if (repeats >= limits.maxRepeats)
|
|
46523
|
+
reason = "cycle_detected";
|
|
46524
|
+
else if (selfInvocation && limits.maxRepeats <= 1)
|
|
46525
|
+
reason = "self_invocation";
|
|
46526
|
+
return {
|
|
46527
|
+
reason,
|
|
46528
|
+
depth,
|
|
46529
|
+
chain,
|
|
46530
|
+
traceId,
|
|
46531
|
+
selfInvocation,
|
|
46532
|
+
repeats,
|
|
46533
|
+
propagate: {
|
|
46534
|
+
[DEPTH_HEADER]: String(depth),
|
|
46535
|
+
[CHAIN_HEADER]: chain.join("."),
|
|
46536
|
+
[TRACE_HEADER]: traceId
|
|
46537
|
+
}
|
|
46538
|
+
};
|
|
46539
|
+
}
|
|
46540
|
+
var MESSAGES = {
|
|
46541
|
+
ok: "",
|
|
46542
|
+
depth_exceeded: "Invocation chain is deeper than the configured limit; the call was refused to stop a runaway loop.",
|
|
46543
|
+
cycle_detected: "This function already appears in the invocation chain; the call was refused as a recursion loop.",
|
|
46544
|
+
self_invocation: "This function invoked itself directly; the call was refused.",
|
|
46545
|
+
trace_budget_exceeded: "This request has already made more invocations than its budget allows.",
|
|
46546
|
+
breaker_open: "Recursion protection is open for this function after repeated loop detections."
|
|
46547
|
+
};
|
|
46548
|
+
|
|
46549
|
+
class RecursionGuard {
|
|
46550
|
+
limits;
|
|
46551
|
+
clock;
|
|
46552
|
+
traces = new Map;
|
|
46553
|
+
breakers = new Map;
|
|
46554
|
+
constructor(limits = DEFAULT_RECURSION_LIMITS, clock = () => Date.now()) {
|
|
46555
|
+
this.limits = limits;
|
|
46556
|
+
this.clock = clock;
|
|
46557
|
+
}
|
|
46558
|
+
check(context) {
|
|
46559
|
+
const now = this.clock();
|
|
46560
|
+
this.expireTraces(now);
|
|
46561
|
+
const inspected = inspectInvocation(context, this.limits);
|
|
46562
|
+
const breaker = this.breakers.get(context.functionId);
|
|
46563
|
+
if (breaker?.openUntil != null && breaker.openUntil > now)
|
|
46564
|
+
return this.deny(context.functionId, inspected, "breaker_open", now, false);
|
|
46565
|
+
if (inspected.reason !== "ok")
|
|
46566
|
+
return this.deny(context.functionId, inspected, inspected.reason, now, true);
|
|
46567
|
+
const tally = this.traces.get(inspected.traceId) ?? { count: 0, firstAt: now };
|
|
46568
|
+
if (tally.count >= this.limits.maxInvocationsPerTrace) {
|
|
46569
|
+
this.traces.set(inspected.traceId, tally);
|
|
46570
|
+
return this.deny(context.functionId, inspected, "trace_budget_exceeded", now, true);
|
|
46571
|
+
}
|
|
46572
|
+
this.traces.set(inspected.traceId, { count: tally.count + 1, firstAt: tally.firstAt });
|
|
46573
|
+
if (breaker)
|
|
46574
|
+
this.breakers.set(context.functionId, { consecutiveBlocks: 0 });
|
|
46575
|
+
return { ...inspected, allowed: true, reason: "ok", propagate: inspected.propagate };
|
|
46576
|
+
}
|
|
46577
|
+
deny(functionId, inspected, reason, now, countTowardBreaker) {
|
|
46578
|
+
if (countTowardBreaker) {
|
|
46579
|
+
const state = this.breakers.get(functionId) ?? { consecutiveBlocks: 0 };
|
|
46580
|
+
const consecutiveBlocks = state.consecutiveBlocks + 1;
|
|
46581
|
+
this.breakers.set(functionId, {
|
|
46582
|
+
consecutiveBlocks,
|
|
46583
|
+
openUntil: consecutiveBlocks >= this.limits.breakerThreshold ? now + this.limits.breakerCooldownMs : state.openUntil
|
|
46584
|
+
});
|
|
46585
|
+
}
|
|
46586
|
+
return { ...inspected, allowed: false, reason, message: MESSAGES[reason] };
|
|
46587
|
+
}
|
|
46588
|
+
breakerOpen(functionId) {
|
|
46589
|
+
const state = this.breakers.get(functionId);
|
|
46590
|
+
return state?.openUntil != null && state.openUntil > this.clock();
|
|
46591
|
+
}
|
|
46592
|
+
reset(functionId) {
|
|
46593
|
+
if (functionId) {
|
|
46594
|
+
this.breakers.delete(functionId);
|
|
46595
|
+
return;
|
|
46596
|
+
}
|
|
46597
|
+
this.breakers.clear();
|
|
46598
|
+
this.traces.clear();
|
|
46599
|
+
}
|
|
46600
|
+
expireTraces(now) {
|
|
46601
|
+
if (this.traces.size === 0)
|
|
46602
|
+
return;
|
|
46603
|
+
for (const [traceId, tally] of this.traces)
|
|
46604
|
+
if (now - tally.firstAt > this.limits.traceWindowMs)
|
|
46605
|
+
this.traces.delete(traceId);
|
|
46606
|
+
}
|
|
46607
|
+
get trackedTraces() {
|
|
46608
|
+
return this.traces.size;
|
|
46609
|
+
}
|
|
46610
|
+
}
|
|
46611
|
+
function propagationHeaders(verdict) {
|
|
46612
|
+
return { ...verdict.propagate };
|
|
46613
|
+
}
|
|
46614
|
+
function recursionBlockedResponse(verdict) {
|
|
46615
|
+
return {
|
|
46616
|
+
status: 508,
|
|
46617
|
+
headers: { "content-type": "application/json", "x-ts-cloud-recursion-blocked": verdict.reason },
|
|
46618
|
+
body: {
|
|
46619
|
+
error: "recursion_blocked",
|
|
46620
|
+
reason: verdict.reason,
|
|
46621
|
+
message: verdict.message ?? MESSAGES[verdict.reason],
|
|
46622
|
+
depth: verdict.depth,
|
|
46623
|
+
traceId: verdict.traceId
|
|
46624
|
+
}
|
|
46625
|
+
};
|
|
46626
|
+
}
|
|
46627
|
+
|
|
46628
|
+
// src/serverless/runtime/auto-recursion.ts
|
|
46629
|
+
var invocationContext = new AsyncLocalStorage;
|
|
46630
|
+
function envFlag(name) {
|
|
46631
|
+
const raw = globalThis.process?.env?.[name];
|
|
46632
|
+
if (raw == null || raw === "")
|
|
46633
|
+
return;
|
|
46634
|
+
return raw !== "0" && raw.toLowerCase() !== "false";
|
|
46635
|
+
}
|
|
46636
|
+
function defaultFunctionId() {
|
|
46637
|
+
const env = globalThis.process?.env ?? {};
|
|
46638
|
+
return env.TS_CLOUD_FUNCTION_ID || env.AWS_LAMBDA_FUNCTION_NAME || env.FUNCTION_NAME || "function";
|
|
46639
|
+
}
|
|
46640
|
+
function resolveRecursionConfig(config2) {
|
|
46641
|
+
const explicit = config2 === false ? { enabled: false } : config2 ?? {};
|
|
46642
|
+
const envEnabled = envFlag("TS_CLOUD_RECURSION_PROTECTION");
|
|
46643
|
+
const envDetectionOnly = envFlag("TS_CLOUD_RECURSION_DETECTION_ONLY");
|
|
46644
|
+
return {
|
|
46645
|
+
enabled: envEnabled ?? explicit.enabled ?? true,
|
|
46646
|
+
detectionOnly: envDetectionOnly ?? explicit.detectionOnly ?? false,
|
|
46647
|
+
limits: { ...DEFAULT_RECURSION_LIMITS, ...explicit.limits },
|
|
46648
|
+
functionId: explicit.functionId ?? defaultFunctionId()
|
|
46649
|
+
};
|
|
46650
|
+
}
|
|
46651
|
+
var installedFetch;
|
|
46652
|
+
var originalFetch;
|
|
46653
|
+
function installRecursionFetch() {
|
|
46654
|
+
const current = globalThis.fetch;
|
|
46655
|
+
if (!current || installedFetch === current)
|
|
46656
|
+
return () => {};
|
|
46657
|
+
originalFetch = current;
|
|
46658
|
+
const wrapped = async (input, init) => {
|
|
46659
|
+
const verdict = invocationContext.getStore();
|
|
46660
|
+
if (!verdict)
|
|
46661
|
+
return originalFetch(input, init);
|
|
46662
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
46663
|
+
for (const [key, value] of Object.entries(propagationHeaders(verdict)))
|
|
46664
|
+
headers.set(key, value);
|
|
46665
|
+
if (input instanceof Request)
|
|
46666
|
+
return originalFetch(new Request(input, { headers }), init);
|
|
46667
|
+
return originalFetch(input, { ...init, headers });
|
|
46668
|
+
};
|
|
46669
|
+
Object.assign(wrapped, current);
|
|
46670
|
+
globalThis.fetch = wrapped;
|
|
46671
|
+
installedFetch = wrapped;
|
|
46672
|
+
return () => {
|
|
46673
|
+
if (globalThis.fetch === installedFetch && originalFetch)
|
|
46674
|
+
globalThis.fetch = originalFetch;
|
|
46675
|
+
installedFetch = undefined;
|
|
46676
|
+
};
|
|
46677
|
+
}
|
|
46678
|
+
var sharedGuard;
|
|
46679
|
+
var sharedLimits;
|
|
46680
|
+
function sharedRecursionGuard(limits) {
|
|
46681
|
+
if (!sharedGuard || JSON.stringify(sharedLimits) !== JSON.stringify(limits)) {
|
|
46682
|
+
sharedGuard = new RecursionGuard(limits);
|
|
46683
|
+
sharedLimits = limits;
|
|
46684
|
+
}
|
|
46685
|
+
return sharedGuard;
|
|
46686
|
+
}
|
|
46687
|
+
function resetRecursionRuntime() {
|
|
46688
|
+
sharedGuard = undefined;
|
|
46689
|
+
sharedLimits = undefined;
|
|
46690
|
+
if (installedFetch && originalFetch && globalThis.fetch === installedFetch)
|
|
46691
|
+
globalThis.fetch = originalFetch;
|
|
46692
|
+
installedFetch = undefined;
|
|
46693
|
+
originalFetch = undefined;
|
|
46694
|
+
}
|
|
46695
|
+
function checkInvocation(headers, config2) {
|
|
46696
|
+
const resolved = resolveRecursionConfig(config2);
|
|
46697
|
+
if (!resolved.enabled)
|
|
46698
|
+
return;
|
|
46699
|
+
const guard = sharedRecursionGuard(resolved.limits);
|
|
46700
|
+
const context = { functionId: resolved.functionId, headers };
|
|
46701
|
+
const verdict = guard.check(context);
|
|
46702
|
+
return {
|
|
46703
|
+
verdict,
|
|
46704
|
+
blocked: !verdict.allowed && !resolved.detectionOnly,
|
|
46705
|
+
observed: !verdict.allowed && resolved.detectionOnly
|
|
46706
|
+
};
|
|
46707
|
+
}
|
|
46708
|
+
function withInvocation(verdict, work) {
|
|
46709
|
+
installRecursionFetch();
|
|
46710
|
+
return invocationContext.run(verdict, work);
|
|
46711
|
+
}
|
|
46712
|
+
|
|
46478
46713
|
// src/serverless/runtime/adapter.ts
|
|
46479
46714
|
function resolveApp(mod) {
|
|
46480
46715
|
const m = mod;
|
|
@@ -46566,9 +46801,25 @@ function createHttpHandler(handler8, opts) {
|
|
|
46566
46801
|
};
|
|
46567
46802
|
}
|
|
46568
46803
|
}
|
|
46804
|
+
const recursion = checkInvocation(event.headers ?? {}, opts?.recursionProtection);
|
|
46805
|
+
if (recursion?.blocked) {
|
|
46806
|
+
const blocked = recursionBlockedResponse(recursion.verdict);
|
|
46807
|
+
return {
|
|
46808
|
+
statusCode: blocked.status,
|
|
46809
|
+
headers: { ...blocked.headers, "retry-after": "60" },
|
|
46810
|
+
body: JSON.stringify(blocked.body),
|
|
46811
|
+
isBase64Encoded: false
|
|
46812
|
+
};
|
|
46813
|
+
}
|
|
46569
46814
|
const request = eventToRequest(event);
|
|
46570
|
-
|
|
46571
|
-
|
|
46815
|
+
if (!recursion) {
|
|
46816
|
+
const response = await handler8(request);
|
|
46817
|
+
return responseToResult(response);
|
|
46818
|
+
}
|
|
46819
|
+
if (recursion.observed) {
|
|
46820
|
+
console.warn(`[ts-cloud] recursion detected (${recursion.verdict.reason}) at depth ${recursion.verdict.depth}; detection-only mode let it run`);
|
|
46821
|
+
}
|
|
46822
|
+
return withInvocation(recursion.verdict, async () => responseToResult(await handler8(request)));
|
|
46572
46823
|
};
|
|
46573
46824
|
}
|
|
46574
46825
|
function parseRecordBody(body) {
|
|
@@ -46735,7 +46986,7 @@ function buildPhpRuntimeLayerZip(options = {}) {
|
|
|
46735
46986
|
}
|
|
46736
46987
|
// src/serverless-php/package-php.ts
|
|
46737
46988
|
import { execSync as execSync2 } from "node:child_process";
|
|
46738
|
-
import { createHash as
|
|
46989
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
46739
46990
|
import { readdirSync as readdirSync6, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
|
|
46740
46991
|
import { join as join11, relative as relative4, resolve as resolve2 } from "node:path";
|
|
46741
46992
|
var PHP_DEFAULT_EXCLUDES = [
|
|
@@ -46800,7 +47051,7 @@ function packagePhpApp(opts) {
|
|
|
46800
47051
|
const handler8 = opts.app.handlers?.http ?? "public/index.php";
|
|
46801
47052
|
return {
|
|
46802
47053
|
zip: zip2,
|
|
46803
|
-
sha256:
|
|
47054
|
+
sha256: createHash6("sha256").update(zip2).digest("hex"),
|
|
46804
47055
|
handlers: {
|
|
46805
47056
|
http: handler8,
|
|
46806
47057
|
queue: opts.app.handlers?.queue ?? handler8,
|
|
@@ -47253,6 +47504,7 @@ export {
|
|
|
47253
47504
|
withSecurity,
|
|
47254
47505
|
withQueue,
|
|
47255
47506
|
withMonitoring,
|
|
47507
|
+
withInvocation,
|
|
47256
47508
|
withDatabase,
|
|
47257
47509
|
withCache,
|
|
47258
47510
|
withCDN,
|
|
@@ -47280,6 +47532,7 @@ export {
|
|
|
47280
47532
|
signRequestAsync,
|
|
47281
47533
|
signRequest,
|
|
47282
47534
|
sharedRuntimeLoop,
|
|
47535
|
+
sharedRecursionGuard,
|
|
47283
47536
|
sha256,
|
|
47284
47537
|
setStateDir,
|
|
47285
47538
|
serviceMeshManager,
|
|
@@ -47307,6 +47560,7 @@ export {
|
|
|
47307
47560
|
resolveServerlessArtifactBucketName,
|
|
47308
47561
|
resolveServerlessAppStackName,
|
|
47309
47562
|
resolveRegion,
|
|
47563
|
+
resolveRecursionConfig,
|
|
47310
47564
|
resolveQueues,
|
|
47311
47565
|
resolveQueueNames,
|
|
47312
47566
|
resolveProjectStackName,
|
|
@@ -47320,11 +47574,14 @@ export {
|
|
|
47320
47574
|
resolveCloudProvider,
|
|
47321
47575
|
resolveAppDatabase,
|
|
47322
47576
|
resolveApp,
|
|
47577
|
+
resetRecursionRuntime,
|
|
47323
47578
|
requiresReplacement,
|
|
47324
47579
|
replicaManager,
|
|
47325
47580
|
regionPairManager,
|
|
47581
|
+
recursionBlockedResponse,
|
|
47326
47582
|
quickHash,
|
|
47327
47583
|
queueManagementManager,
|
|
47584
|
+
propagationHeaders,
|
|
47328
47585
|
progressiveDeploymentManager,
|
|
47329
47586
|
processInChunks,
|
|
47330
47587
|
previewNotifications,
|
|
@@ -47335,6 +47592,7 @@ export {
|
|
|
47335
47592
|
performanceManager,
|
|
47336
47593
|
parseXMLResponse,
|
|
47337
47594
|
parseJSONResponse,
|
|
47595
|
+
parseChain,
|
|
47338
47596
|
parallelWithRetry,
|
|
47339
47597
|
parallelMap,
|
|
47340
47598
|
parallel,
|
|
@@ -47366,6 +47624,9 @@ export {
|
|
|
47366
47624
|
isManagementDashboardSiteName,
|
|
47367
47625
|
isLocalDevelopment,
|
|
47368
47626
|
isLikelyTypo,
|
|
47627
|
+
invocationContext,
|
|
47628
|
+
installRecursionFetch,
|
|
47629
|
+
inspectInvocation,
|
|
47369
47630
|
imageScanningManager,
|
|
47370
47631
|
healthCheckManager,
|
|
47371
47632
|
hashString,
|
|
@@ -47425,6 +47686,7 @@ export {
|
|
|
47425
47686
|
generateBootstrap,
|
|
47426
47687
|
generateApprovalConfig,
|
|
47427
47688
|
generateAppImageDockerfile,
|
|
47689
|
+
functionFingerprint,
|
|
47428
47690
|
fromWebIdentity,
|
|
47429
47691
|
fromSharedCredentials,
|
|
47430
47692
|
fromEnvironment,
|
|
@@ -47501,6 +47763,7 @@ export {
|
|
|
47501
47763
|
clearSigningKeyCache,
|
|
47502
47764
|
chunk,
|
|
47503
47765
|
checkServiceQuotas,
|
|
47766
|
+
checkInvocation,
|
|
47504
47767
|
checkIAMPermissions,
|
|
47505
47768
|
certificateManager,
|
|
47506
47769
|
categorizeChanges,
|
|
@@ -47526,6 +47789,7 @@ export {
|
|
|
47526
47789
|
TemplateCache,
|
|
47527
47790
|
TemplateBuilder,
|
|
47528
47791
|
TaskList,
|
|
47792
|
+
TRACE_HEADER,
|
|
47529
47793
|
SyntheticsManager,
|
|
47530
47794
|
StorageAdvancedManager,
|
|
47531
47795
|
Storage,
|
|
@@ -47556,6 +47820,7 @@ export {
|
|
|
47556
47820
|
Registry,
|
|
47557
47821
|
RegionPairManager,
|
|
47558
47822
|
Redirects,
|
|
47823
|
+
RecursionGuard,
|
|
47559
47824
|
RealtimePresets,
|
|
47560
47825
|
RateLimiter,
|
|
47561
47826
|
REPLContext,
|
|
@@ -47628,8 +47893,10 @@ export {
|
|
|
47628
47893
|
DNSSECManager,
|
|
47629
47894
|
DNS,
|
|
47630
47895
|
DLQMonitoringManager,
|
|
47896
|
+
DEPTH_HEADER,
|
|
47631
47897
|
DEFAULT_STATE_DIR,
|
|
47632
47898
|
DEFAULT_SERVICE_LIMITS,
|
|
47899
|
+
DEFAULT_RECURSION_LIMITS,
|
|
47633
47900
|
DASHBOARD_STATE_DIR,
|
|
47634
47901
|
DASHBOARD_PORT_SPAN,
|
|
47635
47902
|
DASHBOARD_PORT_BASE,
|
|
@@ -47650,6 +47917,7 @@ export {
|
|
|
47650
47917
|
CanaryManager,
|
|
47651
47918
|
Cache,
|
|
47652
47919
|
COMMON_CROSS_ACCOUNT_ROLES,
|
|
47920
|
+
CHAIN_HEADER,
|
|
47653
47921
|
CDN,
|
|
47654
47922
|
BuildOptimizationManager,
|
|
47655
47923
|
BounceComplaintHandler,
|
package/dist/runtime/adapter.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { RecursionProtectionConfig } from './auto-recursion'
|
|
2
|
+
import { checkInvocation, withInvocation } from './auto-recursion'
|
|
3
|
+
import { recursionBlockedResponse } from './recursion'
|
|
1
4
|
/**
|
|
2
5
|
* Serverless runtime adapter (Node/Bun).
|
|
3
6
|
*
|
|
@@ -120,6 +123,12 @@ function isTextContentType(contentType: string | null): boolean {
|
|
|
120
123
|
// ── HTTP ────────────────────────────────────────────────────────────────────
|
|
121
124
|
|
|
122
125
|
export interface HttpAdapterOptions {
|
|
126
|
+
/**
|
|
127
|
+
* Recursion protection. On by default; pass `false` or set
|
|
128
|
+
* `TS_CLOUD_RECURSION_PROTECTION=0` to disable, or `{ detectionOnly: true }`
|
|
129
|
+
* to watch what it would block before it blocks anything.
|
|
130
|
+
*/
|
|
131
|
+
recursionProtection?: RecursionProtectionConfig | false
|
|
123
132
|
/**
|
|
124
133
|
* Read maintenance state from the environment. When `MAINTENANCE_MODE` is
|
|
125
134
|
* truthy, requests get a 503 unless they carry the bypass secret in the
|
|
@@ -228,9 +237,33 @@ export function createHttpHandler(handler: FetchHandler | undefined, opts?: Http
|
|
|
228
237
|
}
|
|
229
238
|
}
|
|
230
239
|
|
|
240
|
+
// Recursion protection runs after maintenance (a parked app should answer
|
|
241
|
+
// 503 either way) and before the handler, so a loop costs one rejected
|
|
242
|
+
// invocation rather than a full execution plus everything it calls.
|
|
243
|
+
const recursion = checkInvocation(event.headers ?? {}, opts?.recursionProtection)
|
|
244
|
+
if (recursion?.blocked) {
|
|
245
|
+
const blocked = recursionBlockedResponse(recursion.verdict)
|
|
246
|
+
return {
|
|
247
|
+
statusCode: blocked.status,
|
|
248
|
+
headers: { ...blocked.headers, 'retry-after': '60' },
|
|
249
|
+
body: JSON.stringify(blocked.body),
|
|
250
|
+
isBase64Encoded: false,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
231
254
|
const request = eventToRequest(event)
|
|
232
|
-
|
|
233
|
-
|
|
255
|
+
if (!recursion) {
|
|
256
|
+
const response = await handler(request)
|
|
257
|
+
return responseToResult(response)
|
|
258
|
+
}
|
|
259
|
+
if (recursion.observed) {
|
|
260
|
+
console.warn(
|
|
261
|
+
`[ts-cloud] recursion detected (${recursion.verdict.reason}) at depth ${recursion.verdict.depth}; detection-only mode let it run`,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
// Run inside the invocation context so outbound fetch carries the chain.
|
|
265
|
+
// Without this the next hop starts a fresh chain and the loop is invisible.
|
|
266
|
+
return withInvocation(recursion.verdict, async () => responseToResult(await handler(request)))
|
|
234
267
|
}
|
|
235
268
|
}
|
|
236
269
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RecursionProtectionConfig } from './auto-recursion';
|
|
1
2
|
/**
|
|
2
3
|
* Serverless runtime adapter (Node/Bun).
|
|
3
4
|
*
|
|
@@ -83,6 +84,12 @@ export interface ServerlessApp {
|
|
|
83
84
|
*/
|
|
84
85
|
export declare function resolveApp(mod: unknown): ServerlessApp;
|
|
85
86
|
export interface HttpAdapterOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Recursion protection. On by default; pass `false` or set
|
|
89
|
+
* `TS_CLOUD_RECURSION_PROTECTION=0` to disable, or `{ detectionOnly: true }`
|
|
90
|
+
* to watch what it would block before it blocks anything.
|
|
91
|
+
*/
|
|
92
|
+
recursionProtection?: RecursionProtectionConfig | false;
|
|
86
93
|
/**
|
|
87
94
|
* Read maintenance state from the environment. When `MAINTENANCE_MODE` is
|
|
88
95
|
* truthy, requests get a 503 unless they carry the bypass secret in the
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making recursion protection automatic.
|
|
3
|
+
*
|
|
4
|
+
* `recursion.ts` can detect a loop given an invocation's headers, but detection
|
|
5
|
+
* only helps if something calls it on every invocation and propagates the chain
|
|
6
|
+
* on every outbound request. Asking application authors to do that by hand
|
|
7
|
+
* means it protects the code that already thought about the problem, which is
|
|
8
|
+
* never the code that loops.
|
|
9
|
+
*
|
|
10
|
+
* So the runtime does both:
|
|
11
|
+
*
|
|
12
|
+
* - Every inbound invocation is inspected before the handler runs.
|
|
13
|
+
* - `fetch` is wrapped once, per process, to attach the current invocation's
|
|
14
|
+
* chain to outbound requests.
|
|
15
|
+
*
|
|
16
|
+
* The chain rides in headers, so protection survives the hop between two
|
|
17
|
+
* separate functions and even between two separate deployments. A request that
|
|
18
|
+
* leaves the platform carries the headers too - they are namespaced and inert
|
|
19
|
+
* to anyone who does not read them.
|
|
20
|
+
*
|
|
21
|
+
* **Coverage, stated plainly.** This covers `fetch`, which is what the platform
|
|
22
|
+
* and modern application code use. A handler that reaches for `node:http`
|
|
23
|
+
* directly, or opens a raw socket, is not covered; the depth header still
|
|
24
|
+
* catches those when the receiving side is one of ours, but the chain does not.
|
|
25
|
+
*/
|
|
26
|
+
import type { RecursionLimits, RecursionVerdict } from './recursion';
|
|
27
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
28
|
+
import { RecursionGuard } from './recursion';
|
|
29
|
+
/**
|
|
30
|
+
* The invocation currently on the stack.
|
|
31
|
+
*
|
|
32
|
+
* `AsyncLocalStorage` rather than a module-level variable: a runtime handling
|
|
33
|
+
* concurrent invocations in one process would otherwise attribute one
|
|
34
|
+
* invocation's outbound calls to another's chain, which is both wrong and
|
|
35
|
+
* exactly the case where a loop is hardest to see.
|
|
36
|
+
*/
|
|
37
|
+
export declare const invocationContext: AsyncLocalStorage<RecursionVerdict>;
|
|
38
|
+
export interface RecursionProtectionConfig {
|
|
39
|
+
/** Off entirely. The escape hatch for a workload that genuinely re-enters. */
|
|
40
|
+
enabled?: boolean;
|
|
41
|
+
limits?: Partial<RecursionLimits>;
|
|
42
|
+
/**
|
|
43
|
+
* Detect and report, but let the invocation run.
|
|
44
|
+
*
|
|
45
|
+
* The way to roll this out over an existing workload: watch what it would
|
|
46
|
+
* have blocked for a week before it blocks anything.
|
|
47
|
+
*/
|
|
48
|
+
detectionOnly?: boolean;
|
|
49
|
+
/** Identifies this function in the chain. Defaults to the Lambda name. */
|
|
50
|
+
functionId?: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve configuration from options and the environment.
|
|
54
|
+
*
|
|
55
|
+
* The environment wins, because turning protection off is an operational
|
|
56
|
+
* decision made under pressure - during an incident, from the console, without
|
|
57
|
+
* a redeploy. A config value that could not be overridden that way would be a
|
|
58
|
+
* config value someone works around by deleting the function.
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveRecursionConfig(config?: RecursionProtectionConfig | false): {
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
detectionOnly: boolean;
|
|
63
|
+
limits: RecursionLimits;
|
|
64
|
+
functionId: string;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Wrap `fetch` so outbound calls carry the current chain.
|
|
68
|
+
*
|
|
69
|
+
* Idempotent: a runtime that creates handlers more than once must not stack
|
|
70
|
+
* wrappers, or a chain entry would be appended once per wrapper and a
|
|
71
|
+
* three-hop request would look like a nine-hop loop.
|
|
72
|
+
*/
|
|
73
|
+
export declare function installRecursionFetch(): () => void;
|
|
74
|
+
/**
|
|
75
|
+
* One guard per process.
|
|
76
|
+
*
|
|
77
|
+
* The trace budget and the circuit breaker only mean anything if successive
|
|
78
|
+
* invocations in the same container share them - a fresh guard per invocation
|
|
79
|
+
* would reset the counters the loop is being counted with.
|
|
80
|
+
*/
|
|
81
|
+
export declare function sharedRecursionGuard(limits: RecursionLimits): RecursionGuard;
|
|
82
|
+
/** Reset process state. Tests need it; nothing in production should call it. */
|
|
83
|
+
export declare function resetRecursionRuntime(): void;
|
|
84
|
+
export interface RecursionCheck {
|
|
85
|
+
verdict: RecursionVerdict;
|
|
86
|
+
/** True when the invocation should be refused. */
|
|
87
|
+
blocked: boolean;
|
|
88
|
+
/** True when a loop was detected but detection-only let it through. */
|
|
89
|
+
observed: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Inspect an invocation and prepare its context.
|
|
93
|
+
*
|
|
94
|
+
* Returns rather than throws, so the caller decides the response shape - a
|
|
95
|
+
* Lambda HTTP handler answers with a 508 payload while a queue handler needs to
|
|
96
|
+
* fail the record instead.
|
|
97
|
+
*/
|
|
98
|
+
export declare function checkInvocation(headers: Record<string, string | undefined> | Headers, config?: RecursionProtectionConfig | false): RecursionCheck | undefined;
|
|
99
|
+
/** Run `work` with the invocation's chain attached to any outbound fetch. */
|
|
100
|
+
export declare function withInvocation<T>(verdict: RecursionVerdict, work: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Depth of the current invocation, as an integer string. */
|
|
2
|
+
export declare const DEPTH_HEADER = "x-ts-cloud-invoke-depth";
|
|
3
|
+
/** Dot-separated short hashes of every function in the chain, oldest first. */
|
|
4
|
+
export declare const CHAIN_HEADER = "x-ts-cloud-invoke-chain";
|
|
5
|
+
/** Correlates every hop of one logical request. */
|
|
6
|
+
export declare const TRACE_HEADER = "x-ts-cloud-trace-id";
|
|
7
|
+
/** Chain entries are 8 hex chars: collision-safe enough for a bounded chain, cheap in a header. */
|
|
8
|
+
export declare function functionFingerprint(functionId: string): string;
|
|
9
|
+
export interface RecursionLimits {
|
|
10
|
+
/** Hard ceiling on chain length. Beyond this, block regardless of cycles. */
|
|
11
|
+
maxDepth: number;
|
|
12
|
+
/**
|
|
13
|
+
* How many times one function may appear in a single chain.
|
|
14
|
+
*
|
|
15
|
+
* Not 1: legitimate fan-out patterns re-enter the same handler (a recursive
|
|
16
|
+
* directory walk, a paginated crawl). Two repeats is a pattern; five is a loop.
|
|
17
|
+
*/
|
|
18
|
+
maxRepeats: number;
|
|
19
|
+
/** Ceiling on invocations sharing one trace id, across all functions. */
|
|
20
|
+
maxInvocationsPerTrace: number;
|
|
21
|
+
/** How long a trace's tally is remembered. */
|
|
22
|
+
traceWindowMs: number;
|
|
23
|
+
/** Consecutive blocks before the breaker opens for a function. */
|
|
24
|
+
breakerThreshold: number;
|
|
25
|
+
/** How long the breaker stays open. */
|
|
26
|
+
breakerCooldownMs: number;
|
|
27
|
+
}
|
|
28
|
+
export declare const DEFAULT_RECURSION_LIMITS: RecursionLimits;
|
|
29
|
+
export type RecursionReason = 'ok' | 'depth_exceeded' | 'cycle_detected' | 'self_invocation' | 'trace_budget_exceeded' | 'breaker_open';
|
|
30
|
+
export interface InvocationContext {
|
|
31
|
+
functionId: string;
|
|
32
|
+
headers: Record<string, string | undefined> | Headers;
|
|
33
|
+
/** Overrides the header value. Useful when the caller already has a trace. */
|
|
34
|
+
traceId?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface RecursionVerdict {
|
|
37
|
+
allowed: boolean;
|
|
38
|
+
reason: RecursionReason;
|
|
39
|
+
depth: number;
|
|
40
|
+
/** The chain including this invocation. */
|
|
41
|
+
chain: string[];
|
|
42
|
+
traceId: string;
|
|
43
|
+
/** True when the immediate caller was this same function. */
|
|
44
|
+
selfInvocation: boolean;
|
|
45
|
+
/** Times this function already appears in the incoming chain. */
|
|
46
|
+
repeats: number;
|
|
47
|
+
message?: string;
|
|
48
|
+
/** Headers to attach to any call this invocation makes. */
|
|
49
|
+
propagate: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
/** Parse the chain header. Bounded and sanitized: a header is attacker-controllable. */
|
|
52
|
+
export declare function parseChain(value: string | undefined, maxEntries?: number): string[];
|
|
53
|
+
/**
|
|
54
|
+
* Inspect an invocation without recording anything.
|
|
55
|
+
*
|
|
56
|
+
* Pure, so a caller can reason about a chain in a test or a dry-run without a
|
|
57
|
+
* guard instance. {@link RecursionGuard.check} adds the stateful backstops.
|
|
58
|
+
*/
|
|
59
|
+
export declare function inspectInvocation(context: InvocationContext, limits?: RecursionLimits): Omit<RecursionVerdict, 'allowed' | 'reason' | 'message'> & {
|
|
60
|
+
reason: RecursionReason;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Stateful recursion guard.
|
|
64
|
+
*
|
|
65
|
+
* In-memory on purpose. A loop runs in seconds, so the state that matters is
|
|
66
|
+
* seconds old; paying a database round trip per invocation to protect against
|
|
67
|
+
* a cost problem would be its own cost problem. Each instance protects the
|
|
68
|
+
* process it runs in, and the header chain carries protection across processes.
|
|
69
|
+
*/
|
|
70
|
+
export declare class RecursionGuard {
|
|
71
|
+
private readonly limits;
|
|
72
|
+
private readonly clock;
|
|
73
|
+
private readonly traces;
|
|
74
|
+
private readonly breakers;
|
|
75
|
+
constructor(limits?: RecursionLimits, clock?: () => number);
|
|
76
|
+
/** Decide whether an invocation may proceed, and record it if so. */
|
|
77
|
+
check(context: InvocationContext): RecursionVerdict;
|
|
78
|
+
private deny;
|
|
79
|
+
/** Whether the breaker is currently open for a function. */
|
|
80
|
+
breakerOpen(functionId: string): boolean;
|
|
81
|
+
/** Close a breaker manually, e.g. after an operator fixes the loop. */
|
|
82
|
+
reset(functionId?: string): void;
|
|
83
|
+
private expireTraces;
|
|
84
|
+
get trackedTraces(): number;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Headers for an outbound call made from inside a function.
|
|
88
|
+
*
|
|
89
|
+
* Every HTTP client used by platform code should merge these in. Without
|
|
90
|
+
* propagation the chain resets at each hop and the loop becomes invisible.
|
|
91
|
+
*/
|
|
92
|
+
export declare function propagationHeaders(verdict: RecursionVerdict): Record<string, string>;
|
|
93
|
+
/** The response a blocked invocation should return: a 508, as the RFC intends. */
|
|
94
|
+
export declare function recursionBlockedResponse(verdict: RecursionVerdict): {
|
|
95
|
+
status: number;
|
|
96
|
+
headers: Record<string, string>;
|
|
97
|
+
body: Record<string, unknown>;
|
|
98
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/types.d.ts
CHANGED
|
@@ -2543,6 +2543,17 @@ export interface ComputeConfig {
|
|
|
2543
2543
|
* primary firewall. @default { enabled: true } for PHP boxes
|
|
2544
2544
|
*/
|
|
2545
2545
|
firewall?: ComputeFirewallConfig;
|
|
2546
|
+
/**
|
|
2547
|
+
* Kernel-level flood mitigation (nftables + sysctl). On by default; `false`
|
|
2548
|
+
* disables it. UFW decides which ports are open, this decides what happens to
|
|
2549
|
+
* the traffic arriving on them.
|
|
2550
|
+
*/
|
|
2551
|
+
ddos?: boolean | ComputeDdosConfig;
|
|
2552
|
+
/**
|
|
2553
|
+
* Web application firewall (zig-waf). On by default in detection-only mode,
|
|
2554
|
+
* so it scores and logs without refusing anything until you promote it.
|
|
2555
|
+
*/
|
|
2556
|
+
waf?: boolean | ComputeWafConfig;
|
|
2546
2557
|
/**
|
|
2547
2558
|
* Automatic unattended security/system updates (Forge's "maintenance"). When
|
|
2548
2559
|
* enabled, installs `unattended-upgrades` and enables daily auto-updates.
|
|
@@ -2666,6 +2677,49 @@ export interface ComputeFirewallConfig {
|
|
|
2666
2677
|
/** TCP ports to allow in addition to SSH/80/443 (always allowed). */
|
|
2667
2678
|
allowedPorts?: number[];
|
|
2668
2679
|
}
|
|
2680
|
+
/**
|
|
2681
|
+
* Kernel-level flood mitigation (nftables + sysctl).
|
|
2682
|
+
*
|
|
2683
|
+
* On by default. UFW decides which ports are open; this decides what happens to
|
|
2684
|
+
* the traffic arriving on them - SYN floods, connection exhaustion, slow-loris,
|
|
2685
|
+
* and single-source hammering. Set `false` to skip it entirely.
|
|
2686
|
+
*/
|
|
2687
|
+
export interface ComputeDdosConfig {
|
|
2688
|
+
enabled?: boolean;
|
|
2689
|
+
/** Ports the ruleset protects. @default [80, 443] */
|
|
2690
|
+
ports?: number[];
|
|
2691
|
+
/** CIDRs that bypass every limit: monitoring, office IPs, a load balancer. */
|
|
2692
|
+
allowlist?: string[];
|
|
2693
|
+
/** CIDRs dropped outright. */
|
|
2694
|
+
blocklist?: string[];
|
|
2695
|
+
/** Count what would be dropped without dropping it. */
|
|
2696
|
+
monitorOnly?: boolean;
|
|
2697
|
+
thresholds?: {
|
|
2698
|
+
newConnectionsPerSecond?: number;
|
|
2699
|
+
concurrentPerSource?: number;
|
|
2700
|
+
synPerSecond?: number;
|
|
2701
|
+
burst?: number;
|
|
2702
|
+
icmpPerSecond?: number;
|
|
2703
|
+
banSeconds?: number;
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Web application firewall (zig-waf), configured at provision time.
|
|
2708
|
+
*
|
|
2709
|
+
* Defaults to `detection`: rules evaluate and matches are logged and scored,
|
|
2710
|
+
* nothing is blocked. Promote to `blocking` once you have read your own
|
|
2711
|
+
* detection log - a ruleset nobody has checked against real traffic will refuse
|
|
2712
|
+
* some of it.
|
|
2713
|
+
*/
|
|
2714
|
+
export interface ComputeWafConfig {
|
|
2715
|
+
mode?: 'off' | 'detection' | 'blocking';
|
|
2716
|
+
/** OWASP CRS paranoia level. 1 is the only level safe unattended. */
|
|
2717
|
+
paranoiaLevel?: 1 | 2 | 3 | 4;
|
|
2718
|
+
/** Inbound anomaly score at which a request is blocked in `blocking` mode. */
|
|
2719
|
+
inboundThreshold?: number;
|
|
2720
|
+
/** Paths never inspected. Each one is an unguarded route. */
|
|
2721
|
+
bypassPaths?: string[];
|
|
2722
|
+
}
|
|
2669
2723
|
/** Scheduled database backup configuration. See {@link ComputeConfig.backups}. */
|
|
2670
2724
|
export interface ComputeBackupConfig {
|
|
2671
2725
|
/** Enable scheduled backups. @default false */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ts-cloud/core",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.109",
|
|
5
5
|
"description": "Core CloudFormation generation library for ts-cloud",
|
|
6
6
|
"author": "Chris Breuer <chris@stacksjs.com>",
|
|
7
7
|
"license": "MIT",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ts-cloud/aws-types": "0.7.
|
|
34
|
+
"@ts-cloud/aws-types": "0.7.109"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"typescript": "^7.0.2"
|