Skip to content

Commit dce5048

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(core): treat backslashes as metadata key segment boundaries
Run metadata operations now reject JSON-path keys that use escaped dots to reach `__proto__`, `constructor` or `prototype`. Mono-RevId: 4f3837f181c84d71543994d234e52c52ca225f1d
1 parent bd0b56d commit dce5048

3 files changed

Lines changed: 54 additions & 3 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+
Run metadata operations now reject JSON-path keys that use escaped dots to reach `__proto__`, `constructor` or `prototype`.

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@ const DANGEROUS_METADATA_KEY_SEGMENTS = new Set(["__proto__", "constructor", "pr
99

1010
/**
1111
* Prototype-pollution guard for run metadata operation keys. JSON paths are applied via
12-
* JSONHeroPath, so dangerous path segments must be rejected. Literal keys are assigned directly,
13-
* where only __proto__ can change the target object's prototype.
12+
* JSONHeroPath, so dangerous path segments must be rejected. JSONHeroPath unescapes `\.` when it
13+
* parses a path and re-splits on it when it writes, so backslashes are segment boundaries too.
14+
* Literal keys are assigned directly, where only __proto__ can change the target object's prototype.
1415
*/
1516
export function isSafeMetadataKey(key: string): boolean {
1617
if (!key.startsWith("$.")) {
1718
return key !== "__proto__";
1819
}
1920

20-
return !key.split(/[.[\]'"]+/).some((segment) => DANGEROUS_METADATA_KEY_SEGMENTS.has(segment));
21+
return !key.split(/[.[\]'"\\]+/).some((segment) => DANGEROUS_METADATA_KEY_SEGMENTS.has(segment));
2122
}
2223

2324
const MetadataOperationKey = z.string().refine(isSafeMetadataKey, {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { afterEach, describe, expect, test } from "vitest";
2+
import { applyMetadataOperations } from "../src/v3/runMetadata/operations.js";
3+
import { isSafeMetadataKey, UpdateMetadataRequestBody } from "../src/v3/schemas/index.js";
4+
import type { RunMetadataChangeOperation } from "../src/v3/schemas/index.js";
5+
6+
const unsafeKeys = [
7+
"$.__proto__.polluted",
8+
"$.__proto__\\.polluted",
9+
"$.a\\.__proto__\\.polluted",
10+
"$.a.__proto__\\.polluted",
11+
"$.constructor\\.prototype\\.polluted",
12+
"$.a\\\\.__proto__.polluted",
13+
];
14+
15+
describe("run metadata operation keys", () => {
16+
afterEach(() => {
17+
delete (Object.prototype as Record<string, unknown>).polluted;
18+
});
19+
20+
test.each(unsafeKeys)("rejects %s", (key) => {
21+
expect(isSafeMetadataKey(key)).toBe(false);
22+
expect(
23+
UpdateMetadataRequestBody.safeParse({ operations: [{ type: "set", key, value: 41 }] }).success
24+
).toBe(false);
25+
});
26+
27+
test.each(unsafeKeys)("does not change Object.prototype through %s", (key) => {
28+
const operations: RunMetadataChangeOperation[] = [
29+
{ type: "set", key, value: 41 },
30+
{ type: "append", key, value: 41 },
31+
{ type: "increment", key, value: 41 },
32+
{ type: "remove", key, value: 41 },
33+
];
34+
35+
const { unappliedOperations } = applyMetadataOperations({ a: {} }, operations);
36+
37+
expect(unappliedOperations).toHaveLength(operations.length);
38+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
39+
});
40+
41+
test("allows escaped dots around safe segments", () => {
42+
expect(isSafeMetadataKey("$.a\\.b")).toBe(true);
43+
expect(isSafeMetadataKey("$.proto\\.constructors")).toBe(true);
44+
});
45+
});

0 commit comments

Comments
 (0)