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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add a maxDepth to flatten/unflattenAttributes to prevent possible issues
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
v22.12.0
|
||||
v22.22.0
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
20.19.0
|
||||
20.20.0
|
||||
Reference in New Issue
Block a user