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/cbor — pickRequestSerializer (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.
Component:
src/serialization/CborCodec.tsSeverity (assessment): MEDIUM
CWE: CWE-1321
CborDecoder.readValue()builds map values without[key] = valueon a plain{}. When the wire supplies the key__proto__with a map value, the assignment invokesObject.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.JsonSerializerfixed 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) withContent-Type: application/cbor—pickRequestSerializer(src/http/Marshalling.ts:15) then hands the body toCborSerializer.fromBinarywith no opt-in. The 21-byte bodyA1 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'}}}yieldsout2.cmd.role === 'admin'withObject.keys(out2.cmd) === []. This is an authorisation-bypass primitive for the extremely commonif (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 readsdata[key]and testskey 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:199Why 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 ofisForbiddenConfigKeyfrom 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 rawcontent-typeheader with no allowlist andentity()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 5loop:if (key === '__proto__') { Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true }); } else { out[key] = value; }— or build the map withconst 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 assertingObject.getPrototypeOf(decoded) === Object.prototypeandhasOwnProperty('__proto__')for both a top-level and a nested map, and a Marshalling test that drives it throughentity()withContent-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;onconst out: Record<string, unknown> = {}(line 191), with the only key check beingtypeof key !== 'string'(line 196) — which"__proto__"passes. The sibling JsonSerializer.ts:92-95 has the explicitObject.definePropertyguard 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 mapsapplication/cbortoCborSerializerandentity()(Marshalling.ts:52-54) feeds it the raw request body;entityis publicly exported at src/http/index.ts:95. I ran the audit's 21-byte payload through the realCborDecoder: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 === undefinedafter decoding —Object.prototypeis 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 reachObject.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 commonif (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.