Skip to content

Commit 0c2d9ce

Browse files
authored
fix(core): block prototype-pollution via run metadata operation keys (#65)
* fix(core): block prototype-pollution via run metadata operation keys * chore: remove test, add changeset * respond to devin comments * fix(core): block prototype pollution in unflattenAttributes via dangerous key segments * fix(core): preserve safe constructor attributes * format * chore(core): combine prototype pollution changeset
1 parent ee4f190 commit 0c2d9ce

4 files changed

Lines changed: 50 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Prevent prototype pollution when applying run metadata operations or reconstructing nested telemetry attributes, while preserving legitimate `constructor` and `prototype` fields.

packages/core/src/v3/runMetadata/operations.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { JSONHeroPath } from "@jsonhero/path";
2-
import type { RunMetadataChangeOperation } from "../schemas/common.js";
2+
import { isSafeMetadataKey, type RunMetadataChangeOperation } from "../schemas/common.js";
33
import { dequal } from "dequal";
44

55
export type ApplyOperationResult = {
@@ -16,6 +16,13 @@ export function applyMetadataOperations(
1616
let newMetadata: Record<string, unknown> = structuredClone(currentMetadata);
1717

1818
for (const operation of Array.isArray(operations) ? operations : [operations]) {
19+
// Prevent unsafe JSON paths and direct __proto__ assignments from changing Object.prototype.
20+
// ("update" carries no key.)
21+
if (operation.type !== "update" && !isSafeMetadataKey(operation.key)) {
22+
unappliedOperations.push(operation);
23+
continue;
24+
}
25+
1926
switch (operation.type) {
2027
case "set": {
2128
if (operation.key.startsWith("$.")) {

packages/core/src/v3/schemas/common.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ import type { RuntimeEnvironmentType as DBRuntimeEnvironmentType } from "@trigge
44

55
export type Enum<T extends string> = { [K in T]: K };
66

7+
const DANGEROUS_METADATA_KEY_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
8+
9+
/**
10+
* Prototype-pollution guard for run metadata operation keys. JSON paths are applied via
11+
* JSONHeroPath, so dangerous path segments must be rejected. Literal keys are assigned directly,
12+
* where only __proto__ can change the target object's prototype.
13+
*/
14+
export function isSafeMetadataKey(key: string): boolean {
15+
if (!key.startsWith("$.")) {
16+
return key !== "__proto__";
17+
}
18+
19+
return !key.split(/[.[\]'"]+/).some((segment) => DANGEROUS_METADATA_KEY_SEGMENTS.has(segment));
20+
}
21+
22+
const MetadataOperationKey = z.string().refine(isSafeMetadataKey, {
23+
message: "Metadata key may not reference __proto__, constructor, or prototype",
24+
});
25+
726
export const RunMetadataUpdateOperation = z.object({
827
type: z.literal("update"),
928
value: z.record(z.unknown()),
@@ -13,38 +32,38 @@ export type RunMetadataUpdateOperation = z.infer<typeof RunMetadataUpdateOperati
1332

1433
export const RunMetadataSetKeyOperation = z.object({
1534
type: z.literal("set"),
16-
key: z.string(),
35+
key: MetadataOperationKey,
1736
value: DeserializedJsonSchema,
1837
});
1938

2039
export type RunMetadataSetKeyOperation = z.infer<typeof RunMetadataSetKeyOperation>;
2140

2241
export const RunMetadataDeleteKeyOperation = z.object({
2342
type: z.literal("delete"),
24-
key: z.string(),
43+
key: MetadataOperationKey,
2544
});
2645

2746
export type RunMetadataDeleteKeyOperation = z.infer<typeof RunMetadataDeleteKeyOperation>;
2847

2948
export const RunMetadataAppendKeyOperation = z.object({
3049
type: z.literal("append"),
31-
key: z.string(),
50+
key: MetadataOperationKey,
3251
value: DeserializedJsonSchema,
3352
});
3453

3554
export type RunMetadataAppendKeyOperation = z.infer<typeof RunMetadataAppendKeyOperation>;
3655

3756
export const RunMetadataRemoveFromKeyOperation = z.object({
3857
type: z.literal("remove"),
39-
key: z.string(),
58+
key: MetadataOperationKey,
4059
value: DeserializedJsonSchema,
4160
});
4261

4362
export type RunMetadataRemoveFromKeyOperation = z.infer<typeof RunMetadataRemoveFromKeyOperation>;
4463

4564
export const RunMetadataIncrementKeyOperation = z.object({
4665
type: z.literal("increment"),
47-
key: z.string(),
66+
key: MetadataOperationKey,
4867
value: z.number(),
4968
});
5069

packages/core/src/v3/utils/flattenAttributes.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export const CIRCULAR_REFERENCE_SENTINEL = "$@circular((";
55

66
const DEFAULT_MAX_DEPTH = 128;
77

8+
// This property name would let a crafted key walk into Object.prototype during
9+
// reconstruction and pollute the shared process.
10+
const PROTOTYPE_POLLUTION_KEY = "__proto__";
11+
812
export function flattenAttributes(
913
obj: unknown,
1014
prefix?: string,
@@ -297,6 +301,11 @@ export function unflattenAttributes(
297301
continue;
298302
}
299303

304+
// Skip any key whose path could walk into Object.prototype.
305+
if (parts.includes(PROTOTYPE_POLLUTION_KEY)) {
306+
continue;
307+
}
308+
300309
let current: any = result;
301310
for (let i = 0; i < parts.length - 1; i++) {
302311
const part = parts[i];
@@ -330,8 +339,10 @@ export function unflattenAttributes(
330339
}
331340
}
332341

333-
// Convert the result to an array if all top-level keys are numeric indices
334-
if (Object.keys(result).every((k) => /^\d+$/.test(k))) {
342+
// Convert the result to an array if all top-level keys are numeric indices.
343+
// Guard against an empty result (e.g. every key was skipped as unsafe), which
344+
// would otherwise produce Array(-Infinity) and throw.
345+
if (Object.keys(result).length > 0 && Object.keys(result).every((k) => /^\d+$/.test(k))) {
335346
const maxIndex = Math.max(...Object.keys(result).map((k) => parseInt(k)));
336347
const arrayResult = Array(maxIndex + 1);
337348
for (const key in result) {

0 commit comments

Comments
 (0)