fix: upgrade Node.js to 20.20.0 to address async_hooks DoS vulnerability (#2890)

## Summary

- Upgrades Node.js from 20.19.0 to 20.20.0 (and 22.12.0 to 22.22.0 for
supervisor) to address the async_hooks stack overflow DoS vulnerability
- Adds `maxDepth` parameter (default 128) to `flattenAttributes` and
`unflattenAttributes` to prevent stack overflow on maliciously deep
nested structures

## Details

The vulnerability (patched in Node.js 20.20.0, 22.22.0, 24.13.0, 25.3.0)
causes unrecoverable crashes (exit code 7) when stack overflow occurs
during async_hooks callbacks. Since the webapp uses `AsyncLocalStorage`,
it was theoretically vulnerable.

### Changes

**Node.js version updates:**
- `docker/Dockerfile`: 20.11.1 → 20.20.0
- `apps/supervisor/Containerfile`: 22-alpine → 22.22.0-alpine
- `.nvmrc`: 20.19.0 → 20.20.0
- `apps/supervisor/.nvmrc`: 22.12.0 → 22.22.0
- `references/prisma-7/.nvmrc`: 20.19.0 → 20.20.0
- All GitHub workflows: 20.19.0 → 20.20.0

**Defense in depth:**
- Added `maxDepth` parameter to `flattenAttributes()` and
`unflattenAttributes()` in `packages/core` to prevent stack overflow on
deeply nested user input

## Test plan

- [x] All existing `flattenAttributes` tests pass (50 tests)
- [x] New tests for depth limiting added
- [x] Verify Docker builds work with new base images
This commit is contained in:
Eric Allam
2026-01-15 10:47:44 +00:00
committed by GitHub
parent b1e21cf03b
commit 936bddf198
18 changed files with 142 additions and 34 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add a maxDepth to flatten/unflattenAttributes to prevent possible issues
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
cache: "pnpm"
- name: Install dependencies
@@ -83,7 +83,7 @@ jobs:
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
- name: Install and update lockfile
run: pnpm install --no-frozen-lockfile
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
cache: "pnpm"
- name: 📥 Download deps
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
- name: 📥 Download deps
run: pnpm install --frozen-lockfile --filter trigger.dev...
+2 -2
View File
@@ -86,7 +86,7 @@ jobs:
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
cache: "pnpm"
# npm v11.5.1 or newer is required for OIDC support
@@ -154,7 +154,7 @@ jobs:
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
cache: "pnpm"
# npm v11.5.1 or newer is required for OIDC support
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
cache: "pnpm"
- name: 📥 Download deps
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -127,7 +127,7 @@ jobs:
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -127,7 +127,7 @@ jobs:
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -135,7 +135,7 @@ jobs:
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
node-version: 20.20.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+1 -1
View File
@@ -1 +1 @@
v20.19.0
v20.20.0
+1 -1
View File
@@ -13,7 +13,7 @@ This repository is a pnpm monorepo managed with Turbo. It contains multiple apps
See `ai/references/repo.md` for a more complete explanation of the workspaces.
## Development setup
1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.11.1` are required).
1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.20.0` are required).
2. Copy `.env.example` to `.env` and generate a random 16 byte hex string for `ENCRYPTION_KEY` (`openssl rand -hex 16`). Update other secrets if needed.
3. Start the local services with Docker:
```bash
+2 -2
View File
@@ -14,7 +14,7 @@ branch are tagged into a release periodically.
### Prerequisites
- [Node.js](https://nodejs.org/en) version 20.11.1
- [Node.js](https://nodejs.org/en) version 20.20.0
- [pnpm package manager](https://pnpm.io/installation) version 10.23.0
- [Docker](https://www.docker.com/get-started/)
- [protobuf](https://github.com/protocolbuffers/protobuf)
@@ -34,7 +34,7 @@ branch are tagged into a release periodically.
```
cd trigger.dev
```
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
3. Ensure you are on the correct version of Node.js (20.20.0). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
4. Run `corepack enable` to use the correct version of pnpm (`10.23.0`) as specified in the root `package.json` file.
+1 -1
View File
@@ -1 +1 @@
v22.12.0
v22.22.0
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine@sha256:9bef0ef1e268f60627da9ba7d7605e8831d5b56ad07487d24d1aa386336d1944 AS node-22-alpine
FROM node:22.22.0-alpine@sha256:bcccf7410b77ca7447d292f616c7b0a89deff87e335fe91352ea04ce8babf50f AS node-22-alpine
WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
ARG NODE_IMAGE=node:20.11.1-bullseye-slim@sha256:5a5a92b3a8d392691c983719dbdc65d9f30085d6dcd65376e7a32e6fe9bf4cbe
ARG NODE_IMAGE=node:20.20.0-bullseye-slim@sha256:f52726bba3d47831859be141b4a57d3f7b93323f8fddfbd8375386e2c3b72319
FROM golang:1.23-alpine AS goose_builder
RUN go install github.com/pressly/goose/v3/cmd/goose@latest
+30 -13
View File
@@ -3,13 +3,16 @@ import { Attributes } from "@opentelemetry/api";
export const NULL_SENTINEL = "$@null((";
export const CIRCULAR_REFERENCE_SENTINEL = "$@circular((";
const DEFAULT_MAX_DEPTH = 128;
export function flattenAttributes(
obj: unknown,
prefix?: string,
maxAttributeCount?: number
maxAttributeCount?: number,
maxDepth: number = DEFAULT_MAX_DEPTH
): Attributes {
const flattener = new AttributeFlattener(maxAttributeCount);
flattener.doFlatten(obj, prefix);
const flattener = new AttributeFlattener(maxAttributeCount, maxDepth);
flattener.doFlatten(obj, prefix, 0);
return flattener.attributes;
}
@@ -18,7 +21,10 @@ class AttributeFlattener {
private attributeCounter: number = 0;
private result: Attributes = {};
constructor(private maxAttributeCount?: number) {}
constructor(
private maxAttributeCount?: number,
private maxDepth: number = DEFAULT_MAX_DEPTH
) {}
get attributes(): Attributes {
return this.result;
@@ -37,11 +43,16 @@ class AttributeFlattener {
return true;
}
doFlatten(obj: unknown, prefix?: string) {
doFlatten(obj: unknown, prefix?: string, depth: number = 0) {
if (!this.canAddMoreAttributes()) {
return;
}
// Check depth limit to prevent stack overflow
if (depth > this.maxDepth) {
return;
}
// Check if obj is null or undefined
if (obj === undefined) {
return;
@@ -94,7 +105,7 @@ class AttributeFlattener {
let index = 0;
for (const item of obj) {
if (!this.canAddMoreAttributes()) break;
this.#processValue(item, `${prefix || "set"}.[${index}]`);
this.#processValue(item, `${prefix || "set"}.[${index}]`, depth);
index++;
}
return;
@@ -106,7 +117,7 @@ class AttributeFlattener {
if (!this.canAddMoreAttributes()) break;
// Use the key directly if it's a string, otherwise convert it
const keyStr = typeof key === "string" ? key : String(key);
this.#processValue(value, `${prefix || "map"}.${keyStr}`);
this.#processValue(value, `${prefix || "map"}.${keyStr}`, depth);
}
return;
}
@@ -196,15 +207,15 @@ class AttributeFlattener {
if (!this.canAddMoreAttributes()) {
break;
}
this.#processValue(value[i], `${newPrefix}.[${i}]`);
this.#processValue(value[i], `${newPrefix}.[${i}]`, depth);
}
} else {
this.#processValue(value, newPrefix);
this.#processValue(value, newPrefix, depth);
}
}
}
#processValue(value: unknown, prefix: string) {
#processValue(value: unknown, prefix: string, depth: number) {
if (!this.canAddMoreAttributes()) {
return;
}
@@ -224,9 +235,9 @@ class AttributeFlattener {
return;
}
// Handle non-primitive values by recursing
// Handle non-primitive values by recursing (increment depth)
if (typeof value === "object" || typeof value === "function") {
this.doFlatten(value as any, prefix);
this.doFlatten(value as any, prefix, depth + 1);
} else {
// Convert other types to strings (bigint, symbol, etc.)
this.addAttribute(prefix, String(value));
@@ -240,7 +251,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
export function unflattenAttributes(
obj: Attributes,
filteredKeys?: string[]
filteredKeys?: string[],
maxDepth: number = DEFAULT_MAX_DEPTH
): Record<string, unknown> | string | number | boolean | null | undefined {
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
return obj;
@@ -285,6 +297,11 @@ export function unflattenAttributes(
[] as (string | number)[]
);
// Skip keys that exceed max depth to prevent memory exhaustion
if (parts.length > maxDepth) {
continue;
}
let current: any = result;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
@@ -500,6 +500,53 @@ describe("flattenAttributes", () => {
expect(result["bigint"]).toBe("999");
expect(typeof result["symbol"]).toBe("string");
});
it("respects maxDepth limit", () => {
const obj = {
a: {
b: {
c: {
d: {
e: {
f: "deep",
},
},
},
},
},
};
// With maxDepth of 3, should not include keys deeper than 3 levels
const result = flattenAttributes(obj, undefined, undefined, 3);
// a.b.c should exist (depth 3)
expect(result["a.b.c"]).toBeUndefined(); // c is an object, not a leaf
// a.b.c.d should not exist (would require going to depth 4)
expect(result["a.b.c.d"]).toBeUndefined();
expect(result["a.b.c.d.e.f"]).toBeUndefined();
});
it("does not crash with deeply nested objects", () => {
// Create object iteratively with 500 levels of nesting
let deepObj: any = { value: "leaf" };
for (let i = 0; i < 500; i++) {
deepObj = { n: deepObj };
}
// Should complete without stack overflow
expect(() => flattenAttributes(deepObj)).not.toThrow();
});
it("does not crash with deeply nested arrays", () => {
// Create deeply nested array structure iteratively
let deepArray: any = ["leaf"];
for (let i = 0; i < 500; i++) {
deepArray = [deepArray];
}
// Should complete without stack overflow
expect(() => flattenAttributes({ arr: deepArray })).not.toThrow();
});
});
describe("unflattenAttributes", () => {
@@ -581,4 +628,43 @@ describe("unflattenAttributes", () => {
blogPosts: [{ title: "Post 1", author: "[Circular Reference]" }],
});
});
it("respects maxDepth limit and skips overly deep keys", () => {
// Create a flattened object with keys at various depths
const flattened = {
"a.b.c": "shallow", // depth 3 - should be included
"a.b.c.d.e.f.g": "deep", // depth 7 - should be skipped with maxDepth=5
"x.y": "also shallow", // depth 2 - should be included
};
const result = unflattenAttributes(flattened, undefined, 5);
// Shallow keys should be included
expect(result).toHaveProperty("a.b.c", "shallow");
expect(result).toHaveProperty("x.y", "also shallow");
// Deep key should be skipped (not create the nested structure)
expect((result as any)?.a?.b?.c?.d?.e?.f?.g).toBeUndefined();
});
it("uses default maxDepth of 128", () => {
// Create a key with 129 parts - should be skipped
const deepKey = Array(129).fill("x").join(".");
const flattened = {
[deepKey]: "too deep",
"a.b": "shallow",
};
const result = unflattenAttributes(flattened);
// Shallow key should work
expect(result).toHaveProperty("a.b", "shallow");
// Deep key should be skipped
let current: any = result;
for (let i = 0; i < 129 && current; i++) {
current = current?.x;
}
expect(current).toBeUndefined();
});
});
+1 -1
View File
@@ -1 +1 @@
20.19.0
20.20.0