Skip to content

fix(openapi): prevent prototype pollution through object values in bracket notation - #2024

Merged
dinwwwh merged 3 commits into
middleapi:mainfrom
dinwwwh:claude/prototype-injection-bracket-notation-e86b37
Sep 15, 2026
Merged

dinwwwh merged 3 commits into
middleapi:mainfrom
dinwwwh:claude/prototype-injection-bracket-notation-e86b37

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 14, 2026

Copy link
Copy Markdown
Member

BracketNotationSerializer.deserialize could write to the global Object.prototype when an entry's value was a plain object and a later path walked through it with __proto__. It now reads and writes own properties only, so dangerous segments land as plain data instead of touching a real prototype.

serializer.deserialize([['a', {}], ['a[__proto__][POLLUTED]', 'pwned']])
;({}).POLLUTED // 'pwned', before this change

Containers the deserializer builds itself were already safe. The hole was containers it adopts: a value from the input that passes isPlainObject became one, and reading __proto__ off it resolved through the prototype chain to Object.prototype, which isPlainObject accepts because its own prototype is null.

Fixes

  • Deserialized input can no longer reach a prototype. Dangerous keys are stored as own data properties, matching how the deserializer already treated __proto__ on the containers it creates.
  • Not reachable through oRPC's own request pipeline, which only ever feeds URLSearchParams and FormData entries, so values are string | File and get replaced before traversal. This hardens the public API surface, which accepts [string, unknown][] and backs parseFormData.
  • setOwn is now exported from @orpc/shared. It already existed there, module private, backing set() and clone().

Behavior

One intended change: a path like a[toString] against an object value no longer resolves the inherited method and merges into [fn, value], it simply assigns. Array index handling, append style [] entries, duplicate key merging and the index cap are all unchanged.

The walk is also simpler and does less work: the array or object rule now lives in one decision instead of five, a redundant wrapper object is gone, and containers are only written back when they actually changed, so re-walked paths and repeated __proto__ segments no longer re-store on every entry.

Testing

Regression tests for object values reached via __proto__ and constructor[prototype], plus direct tests for the newly exported setOwn. The pollution test fails on main, where the leak also breaks two unrelated tests later in the same process.

packages/openapi, packages/shared and packages/next pass (826 tests), along with pnpm lint and pnpm type:check. Both changed source files are at 100% statement, branch, function and line coverage from their own unit tests.

…acket notation

The deserializer creates its own containers as NullProtoObj or arrays, but a
value coming from the input that passes isPlainObject was kept and traversed.
A `__proto__` segment on such a value resolved to Object.prototype through the
prototype chain, and isPlainObject accepts it (its own proto is null), so the
final write landed on the global prototype.

Read and write own properties only: getOwn for reads, setOwn for writes, and
Object.hasOwn instead of `in`. Export setOwn from @orpc/shared, which already
used it internally for set() and clone().
@pkg-pr-new

pkg-pr-new Bot commented Sep 14, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/middleapi/orpc/@orpc/ai-sdk@2024

@orpc/arktype

npm i https://pkg.pr.new/middleapi/orpc/@orpc/arktype@2024

@orpc/bun

npm i https://pkg.pr.new/middleapi/orpc/@orpc/bun@2024

@orpc/client

npm i https://pkg.pr.new/middleapi/orpc/@orpc/client@2024

@orpc/cloudflare

npm i https://pkg.pr.new/middleapi/orpc/@orpc/cloudflare@2024

@orpc/contract

npm i https://pkg.pr.new/middleapi/orpc/@orpc/contract@2024

@orpc/experimental-effect

npm i https://pkg.pr.new/middleapi/orpc/@orpc/experimental-effect@2024

@orpc/evlog

npm i https://pkg.pr.new/middleapi/orpc/@orpc/evlog@2024

@orpc/hibernation

npm i https://pkg.pr.new/middleapi/orpc/@orpc/hibernation@2024

@orpc/json-schema

npm i https://pkg.pr.new/middleapi/orpc/@orpc/json-schema@2024

@orpc/experimental-lock

npm i https://pkg.pr.new/middleapi/orpc/@orpc/experimental-lock@2024

@orpc/experimental-msw

npm i https://pkg.pr.new/middleapi/orpc/@orpc/experimental-msw@2024

@orpc/nest

npm i https://pkg.pr.new/middleapi/orpc/@orpc/nest@2024

@orpc/next

npm i https://pkg.pr.new/middleapi/orpc/@orpc/next@2024

@orpc/node

npm i https://pkg.pr.new/middleapi/orpc/@orpc/node@2024

@orpc/openapi

npm i https://pkg.pr.new/middleapi/orpc/@orpc/openapi@2024

@orpc/opentelemetry

npm i https://pkg.pr.new/middleapi/orpc/@orpc/opentelemetry@2024

@orpc/pinia-colada

npm i https://pkg.pr.new/middleapi/orpc/@orpc/pinia-colada@2024

@orpc/pino

npm i https://pkg.pr.new/middleapi/orpc/@orpc/pino@2024

@orpc/publisher

npm i https://pkg.pr.new/middleapi/orpc/@orpc/publisher@2024

@orpc/ratelimit

npm i https://pkg.pr.new/middleapi/orpc/@orpc/ratelimit@2024

@orpc/server

npm i https://pkg.pr.new/middleapi/orpc/@orpc/server@2024

@orpc/shared

npm i https://pkg.pr.new/middleapi/orpc/@orpc/shared@2024

@orpc/swr

npm i https://pkg.pr.new/middleapi/orpc/@orpc/swr@2024

@orpc/tanstack-query

npm i https://pkg.pr.new/middleapi/orpc/@orpc/tanstack-query@2024

@orpc/trpc

npm i https://pkg.pr.new/middleapi/orpc/@orpc/trpc@2024

@orpc/valibot

npm i https://pkg.pr.new/middleapi/orpc/@orpc/valibot@2024

@orpc/zod

npm i https://pkg.pr.new/middleapi/orpc/@orpc/zod@2024

commit: 79dad15

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing dinwwwh:claude/prototype-injection-bracket-notation-e86b37 (79dad15) with main (8fc6d01)

Open in CodSpeed

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Own-property-only traversal in deserializegetOwn/setOwn/Object.hasOwn replace direct currentRef[nextSegment] reads, direct assignment, and in checks, so a __proto__ or constructor segment on an input-provided object value can no longer resolve into Object.prototype. The container is now built in a local child and written back once per step.
  • setOwn exported from @orpc/shared — promoted from module-private (packages/shared/src/object.ts:124); already re-exported via export * from './object' and used by set()/clone().
  • Regression test — bracket-notation.test.ts:414 covers __proto__ and constructor[prototype] reached through object values and asserts global prototypes stay clean.

I verified the new test is not a tautology: reverting the fix reproduces Object.prototype.polluted === '1' and fails the test. pnpm vitest run packages/openapi (456 tests) plus tsc -b for @orpc/shared and @orpc/openapi all pass. The documented behavior change (a[toString] on an object value now assigns rather than merging the inherited method) is intentional and does not affect NullProtoObj containers.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Collapse the four array-conversion branches into a single decision, so the
array-or-object rule and its two converters live in one place instead of five.

Drop the `ref: { value }` wrapper: the root is always a NullProtoObj, so the
first loop iteration could only ever write the root back onto itself. The walk
now starts at the first segment and returns the root directly.

Write the container back only when it changed, which keeps re-walked paths
free of pointless stores and stops a literal `__proto__` segment from re-running
Object.defineProperty on every entry.
setOwn is now exported, so test it directly: ordinary writes, symbol and number
keys, array indexes, and the __proto__ case, including that the defined property
stays writable, enumerable and configurable. Enumerability is what lets a
deserialized __proto__ key survive a for...in round trip.

Also feed findDeepMatches a value that is neither a match, an array nor a plain
object, bringing object.ts to full branch coverage.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Reviewed the delta since the prior pullfrog review (790eb17a): the deserializer walk rewrite in a326989b plus the new tests in 79dad15c.

  • Reworked the deserialize traversalroot is now the real NullProtoObj container; nextSegment starts at segments[0] and the loop runs from i = 1, dropping the { value } wrapper and the empty-input early return.
  • Collapsed array / push-style conversion — the split i !== last / i === last branches are replaced by a single canStayArray predicate with identical semantics, including the push-style marker lifecycle.
  • Conditional write-backsetOwn now runs only when the child reference changes, so an input-provided object value already at that key is no longer rewritten on every step. This also retires the frozen/non-writable-object concern noted in the previous review.
  • setOwn unit tests — new describe('setOwn') block in @orpc/shared; the findDeepMatches fixture gains a non-matching property to exercise the skip branch.

Verification: bracket-notation.test.ts (36) and object.test.ts (62) pass, and a 50,000-case differential fuzz of the new deserialize against the previously reviewed implementation (790eb17a) produced identical output and identical error behavior on every case.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit b0b52fa into middleapi:main Sep 15, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant