Skip to content

[Security] CborDecoder map decode assigns a hostile "__proto__" key through the prototype setter, letting a 21-byte payload pick the decoded object's prototype #581

Description

@pathosDev

Component: src/serialization/CborCodec.ts
Severity (assessment): MEDIUM
CWE: CWE-1321

CborDecoder.readValue() builds map values with out[key] = value on a plain {}. When the wire supplies the key __proto__ with a map value, the assignment invokes Object.prototype.__proto__'s setter instead of creating a data property, so the attacker chooses the prototype of the decoded object and every property lookup on it resolves against attacker-controlled data. JsonSerializer fixed exactly this (#146/#126, with a regression test); the CBOR decoder never got the same guard.

Exploit walkthrough

A hostile HTTP client POSTs to any route that calls entity() (the documented body decoder, src/http/index.ts:95) with Content-Type: application/cborpickRequestSerializer (src/http/Marshalling.ts:15) then hands the body to CborSerializer.fromBinary with no opt-in. The 21-byte body A1 69 5F 5F 70 72 6F 74 6F 5F 5F A1 67 69 73 41 64 6D 69 6E F5 (map{ "proto": map{ "isAdmin": true } }) decodes to an object with ZERO own keys whose prototype is {isAdmin:true}. I ran it against the real decoder: own keys: [], out.isAdmin = true, proto is Object.prototype? false, proto = {"isAdmin":true}. Nesting works too — {cmd:{__proto__:{role:'admin'}}} yields out2.cmd.role === 'admin' with Object.keys(out2.cmd) === []. This is an authorisation-bypass primitive for the extremely common if (body.isAdmin) / if (order.role === 'admin') check. The docs' recommended mitigation (layer zod on top, docs/src/content/docs/http/marshalling.mdx) does NOT help: zod object parsing reads data[key] and tests key in data, both of which walk the prototype chain, so the injected field validates as a genuine string/boolean and is copied into the parsed output.

Evidence — src/serialization/CborCodec.ts:199

src/serialization/CborCodec.ts:190-202
      case 5: {
        const out: Record<string, unknown> = {};
        const count = Number(len);
        for (let i = 0; i < count; i++) {
          const key = this.readValue();
          const value = this.readValue();
          if (typeof key !== 'string') {
            throw new CborDecodeError('Only string keys are supported in maps');
          }
          out[key] = value;
        }
        return out;
      }

Contrast with the guarded sibling, src/serialization/JsonSerializer.ts:92-98:
    if (key === '__proto__') {
      Object.defineProperty(out, key, {
        value: decodeTree(value), enumerable: true, writable: true, configurable: true,
      });
    } else {
      out[key] = decodeTree(value);
    }

Why the existing guard does not cover it

I looked for (a) a key filter in CborCodec — the only key check is typeof key !== 'string' at line 196, which a string "__proto__" passes; (b) grep -rn "max|limit|cap" src/serialization/*.ts — returns nothing, there is no key allowlist/denylist anywhere in the directory; (c) reuse of isForbiddenConfigKey from the HOCON hardening (src/config/HoconParser.ts) — CborCodec.ts imports nothing; (d) a guard between the HTTP backend and the decoder — pickRequestSerializer (Marshalling.ts:20-24) matches the raw content-type header with no allowlist and entity() only wraps the decode in a try/catch that converts throws to 400; (e) a regression test — grep -rn "__proto__" tests/ hits only tests/unit/config/HoconParser.test.ts and tests/unit/serialization/JsonSerializer.proto.test.ts. tests/unit/serialization/CborCodec.test.ts has no prototype test at all. Note this is NOT the already-fixed #146/#126: that fix is in JsonSerializer.ts and is verified complete (see surface_notes); this is the unpatched CBOR path.

Suggested fix

Apply the JsonSerializer treatment in the case 5 loop: if (key === '__proto__') { Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true }); } else { out[key] = value; } — or build the map with const out = Object.create(null) (cheaper, and a null-prototype object has no __proto__ setter to trip). Add a CBOR twin of tests/unit/serialization/JsonSerializer.proto.test.ts asserting Object.getPrototypeOf(decoded) === Object.prototype and hasOwnProperty('__proto__') for both a top-level and a nested map, and a Marshalling test that drives it through entity() with Content-Type: application/cbor.

Verification status

Found in the whole-framework security audit of 2026-08-01 (v0.12.0), then adjudicated by an independent verifier instructed to refute it.

Verifier note

src/serialization/CborCodec.ts:199 is literally out[key] = value; on const out: Record<string, unknown> = {} (line 191), with the only key check being typeof key !== 'string' (line 196) — which "__proto__" passes. The sibling JsonSerializer.ts:92-95 has the explicit Object.defineProperty guard with a comment citing 'security audit #9', and CHANGELOG.md:1618-1619 confirms that fix covered only the JSON serializer. The path is untrusted-reachable without opt-in: src/http/Marshalling.ts:15 maps application/cbor to CborSerializer and entity() (Marshalling.ts:52-54) feeds it the raw request body; entity is publicly exported at src/http/index.ts:95. I ran the audit's 21-byte payload through the real CborDecoder: own keys: [], out.isAdmin = true, Object.getPrototypeOf(out) === Object.prototype -> false, proto = {"isAdmin":true}.

Correction applied: The title/CWE framing overstates the blast radius. I verified ({} as any).isAdmin === undefined after decoding — Object.prototype is NOT polluted, because the assignment sets the decoded object's own [[Prototype]], not a property on the shared prototype. So this is per-object prototype substitution scoped to the one decoded value, not process-wide prototype pollution (unlike the HOCON bug #406, CHANGELOG.md:1601-1607, which really did reach Object.prototype). Exploitability beyond simply sending the field as a normal own key is limited to applications that allowlist/sanitize by own keys (Object.keys, spread, mass-assignment filters) and then read properties — for such an app the injected field is invisible to the filter but visible to the read. That is a real primitive but narrower than 'authorisation bypass for the extremely common if (body.isAdmin) check', since that check is equally satisfied by a plain {"isAdmin":true} body. Medium, not high. Not a duplicate: #9 and #406 fixed different files.

Independently reported by more than one audit surface (serialization, http-core) — merged into this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: mediumUseful, not urgentproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: mediumModerate impact or requires specific conditions

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions