From f57c5213523dfd61b74a62843d6cddc4d1430fee Mon Sep 17 00:00:00 2001 From: Shiva Date: Thu, 5 Nov 2026 10:38:28 -0700 Subject: [PATCH 1/3] fix: serialize object-valued extension attributes in binary HTTP headers The current implementation copies binary object and values into the header map as-is. This would produce incorrectly encoded HTTP headers, and the current behaviour relies on object coercion in axios. Instead we are explicitly serializing incompatible types to String. The CloudEvents type system has no object type, so accepting objects as extension values is an SDK convenience rather than spec behavior. In the long run, we should pivot to a spec compliant option, and throw an error for this case, and instead as the client to correctly set headers. Split from #646. Signed-off-by: Shiva Co-authored-by: Kevin Mathew Sunny --- README.md | 26 +++++++++++++++++++++ src/event/interfaces.ts | 14 +++++++++++- src/message/http/headers.ts | 29 +++++++++++++++++++++++- test/integration/message_test.ts | 39 ++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f0256adb..2a2979d8 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,32 @@ const event3 = new CloudEvent({ }); ``` +### Extension Attributes + +Any property passed to the `CloudEvent` constructor that is not a core attribute becomes an +[extension attribute](https://github.com/cloudevents/spec/blob/v1.0/spec.md#extension-context-attributes). +In binary mode each extension is sent as its own header, named after the attribute with the +transport's prefix: an extension `traceid` becomes the `ce-traceid` HTTP header. Extension names +must be lowercase letters and digits only. + +Extension values should be one of the CloudEvents types: string, boolean, integer, binary, URI or +timestamp. The spec has no object type, so encode structured data as a string yourself: + +```js +const ce = new CloudEvent({ + source: "/orders", + type: "order.placed", + traceinfo: JSON.stringify({ span: "abc", sampled: true }), +}); +``` + +`Date` values are sent as RFC 3339 timestamps and binary values (`Buffer`, typed arrays) as base64, which +are the string forms the spec requires. + +As a convenience the SDK also accepts a plain object or array as an extension value and serializes it as JSON +when the event is sent in binary mode. This is not spec-conformant: a receiver gets a plain string +and has to know to parse it, and other SDKs may reject the event. Prefer the explicit form above. + ### A Note About Big Integers When parsing JSON data, if a JSON field value is a number, and that number diff --git a/src/event/interfaces.ts b/src/event/interfaces.ts index f73c6d8f..72d3843b 100644 --- a/src/event/interfaces.ts +++ b/src/event/interfaces.ts @@ -136,7 +136,19 @@ export interface CloudEventV1OptionalAttributes { data_base64?: string; /** - * [OPTIONAL] CloudEvents extension attributes. + * [OPTIONAL] CloudEvents extension attributes. Any property that is not a + * core attribute is treated as an extension. Values should be one of the + * CloudEvents types: string, boolean, integer, binary, URI or timestamp. + * In binary mode a Date is sent as RFC 3339 and binary values as base64. + * + * Object values are accepted as a convenience and for compatibility reasons. + * They are serialized as JSON in binary mode, but this is not spec-conformant; + * prefer encoding the value as a string yourself. + * + * @deprecated automatic serialization of object values is not spec-conformant + * and may be removed in future versions of the SDK. + * + * @see https://github.com/cloudevents/spec/blob/v1.0/spec.md#extension-context-attributes */ [key: string]: unknown; } diff --git a/src/message/http/headers.ts b/src/message/http/headers.ts index b0cc519b..5bcff9fa 100644 --- a/src/message/http/headers.ts +++ b/src/message/http/headers.ts @@ -8,6 +8,7 @@ import { CloudEventV1 } from "../.."; import { Headers } from "../"; import { V1 } from "../../event/cloudevent"; import CONSTANTS from "../../constants"; +import { asBase64, isBinary, isDate, TypeArray } from "../../event/validation"; export const allowedContentTypes = [CONSTANTS.DEFAULT_CONTENT_TYPE, CONSTANTS.MIME_JSON, CONSTANTS.MIME_OCTET_STREAM]; export const requiredHeaders = [ @@ -41,7 +42,7 @@ export function headersFor(event: CloudEventV1): Headers { if (map) { headers[map.name] = map.parser.parse(value as string) as string; } else if (property !== CONSTANTS.DATA_ATTRIBUTE && property !== `${CONSTANTS.DATA_ATTRIBUTE}_base64`) { - headers[`${CONSTANTS.EXTENSIONS_PREFIX}${property}`] = value as string; + headers[`${CONSTANTS.EXTENSIONS_PREFIX}${property}`] = extensionHeaderValue(value); } } }); @@ -52,6 +53,32 @@ export function headersFor(event: CloudEventV1): Headers { return headers; } +/** + * Converts an extension attribute value to its binary-mode header value. + * + * The CloudEvents type system has no object type: extension values are strings, + * booleans, integers, binary, URIs or timestamps. Timestamps are sent as RFC 3339 + * and binary values as base64, the spec's canonical string forms. As a convenience + * the SDK also accepts plain objects and arrays and serializes them as JSON rather + * than letting the HTTP client coerce them to "[object Object]"; prefer encoding + * such values as strings yourself. Anything else (Error, Map, ...) is not a + * CloudEvents type and is left for the HTTP client to coerce with String(), as before. + * @param {unknown} value the extension attribute value + * @returns {string} the header value + */ +function extensionHeaderValue(value: unknown): string { + if (isDate(value)) { + return value.toISOString(); + } + if (isBinary(value)) { + return asBase64(value as TypeArray); + } + if (value?.constructor === Object || Array.isArray(value)) { + return JSON.stringify(value); + } + return value as string; +} + /** * Sanitizes incoming headers by lowercasing them and potentially removing * encoding from the content-type header. diff --git a/test/integration/message_test.ts b/test/integration/message_test.ts index 9ddb292d..12f97733 100644 --- a/test/integration/message_test.ts +++ b/test/integration/message_test.ts @@ -65,6 +65,45 @@ describe("HTTP transport", () => { expect(message.headers["ce-extboolean"]).to.equal(false); }); + it("Serializes object-valued extensions as JSON in binary mode", () => { + const extobject = { some: "object", nested: { answer: 42 } }; + const evt = new CloudEvent({ source: "test", type: "test", extobject }); + const message = HTTP.binary(evt); + // HTTP headers can only carry strings; a raw object reaches the wire as "[object Object]" + expect(message.headers["ce-extobject"]).to.equal(JSON.stringify(extobject)); + }); + + it("Round-trips an object-valued extension through binary mode as a JSON string", () => { + const extobject = { some: "object", nested: { answer: 42 } }; + const evt = new CloudEvent({ source: "test", type: "test", extobject }); + const received = HTTP.toEvent(HTTP.binary(evt)) as CloudEvent; + // the receiver sees the serialized form and is responsible for parsing it + expect(received.extobject).to.equal(JSON.stringify(extobject)); + expect(JSON.parse(received.extobject as string)).to.deep.equal(extobject); + }); + + it("Serializes Date-valued extensions as RFC 3339 timestamps in binary mode", () => { + const extdate = new Date("2018-04-05T03:56:24Z"); + const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", extdate })); + expect(message.headers["ce-extdate"]).to.equal("2018-04-05T03:56:24.000Z"); + }); + + it("Serializes binary extensions as base64 in binary mode", () => { + const extbytes = new Uint8Array([104, 105]); + const extbuffer = Buffer.from("hi"); + const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", extbytes, extbuffer })); + expect(message.headers["ce-extbytes"]).to.equal("aGk="); + expect(message.headers["ce-extbuffer"]).to.equal("aGk="); + }); + + it("Leaves non-CloudEvents values in binary-mode extensions untouched", () => { + const exterror = new Error("useful message"); + const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", exterror })); + // JSON.stringify would turn the Error into "{}"; the HTTP client's String() keeps the message + expect(message.headers["ce-exterror"]).to.equal(exterror); + expect(String(message.headers["ce-exterror"])).to.equal("Error: useful message"); + }); + it("Includes extensions in structured when type is 'boolean' with a false value", () => { const evt = new CloudEvent({ source: "test", type: "test", extboolean: false }); expect(evt.hasOwnProperty("extboolean")).to.equal(true); From dafa87fa0ab71f397f63e1bceb48d018ad8f107c Mon Sep 17 00:00:00 2001 From: Shiva Date: Fri, 6 Nov 2026 09:34:47 -0700 Subject: [PATCH 2/3] fix: enhance plain object detection for binary-mode serialization Signed-off-by: Shiva --- src/message/http/headers.ts | 16 +++++++++++++++- test/integration/message_test.ts | 9 +++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/message/http/headers.ts b/src/message/http/headers.ts index 5bcff9fa..ca36a06d 100644 --- a/src/message/http/headers.ts +++ b/src/message/http/headers.ts @@ -53,6 +53,20 @@ export function headersFor(event: CloudEventV1): Headers { return headers; } +/** + * True for objects created by a literal, `new Object()` or `Object.create(null)`. + * The check looks at the prototype, so it is not fooled by a shadowed `constructor` + * property and it does not depend on `Object.prototype.toString`, which a + * Symbol.toStringTag can spoof. Class instances (Error, Map, Date, ...) are not plain. + * @param {unknown} value the value to test + * @returns {boolean} true if the value is a plain object + */ +const isPlainObject = (value: unknown): value is Record => + value !== null && + typeof value === "object" && + (Object.getPrototypeOf(value) === Object.prototype || + (Object.getPrototypeOf(value) === null && Object.prototype.toString.call(value) === "[object Object]")); + /** * Converts an extension attribute value to its binary-mode header value. * @@ -73,7 +87,7 @@ function extensionHeaderValue(value: unknown): string { if (isBinary(value)) { return asBase64(value as TypeArray); } - if (value?.constructor === Object || Array.isArray(value)) { + if (isPlainObject(value) || Array.isArray(value)) { return JSON.stringify(value); } return value as string; diff --git a/test/integration/message_test.ts b/test/integration/message_test.ts index 12f97733..8ac2200f 100644 --- a/test/integration/message_test.ts +++ b/test/integration/message_test.ts @@ -96,6 +96,15 @@ describe("HTTP transport", () => { expect(message.headers["ce-extbuffer"]).to.equal("aGk="); }); + it("Serializes plain objects without an Object constructor as JSON in binary mode", () => { + // a shadowed `constructor` property or a null prototype must not defeat plain-object detection + const extshadowed = { constructor: "shadowed", some: "object" }; + const extnullproto = Object.assign(Object.create(null), { some: "object" }); + const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", extshadowed, extnullproto })); + expect(message.headers["ce-extshadowed"]).to.equal(JSON.stringify(extshadowed)); + expect(message.headers["ce-extnullproto"]).to.equal(JSON.stringify(extnullproto)); + }); + it("Leaves non-CloudEvents values in binary-mode extensions untouched", () => { const exterror = new Error("useful message"); const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", exterror })); From 359c2eb2cde49b65b97c60a0040b4c02ba8e2e15 Mon Sep 17 00:00:00 2001 From: Shiva Date: Fri, 6 Nov 2026 09:55:07 -0700 Subject: [PATCH 3/3] fix: rename extensionHeaderValue to stringifyExtensionValue and update documentation for clarity Signed-off-by: Shiva --- src/message/http/headers.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/message/http/headers.ts b/src/message/http/headers.ts index ca36a06d..15fb15e4 100644 --- a/src/message/http/headers.ts +++ b/src/message/http/headers.ts @@ -42,7 +42,7 @@ export function headersFor(event: CloudEventV1): Headers { if (map) { headers[map.name] = map.parser.parse(value as string) as string; } else if (property !== CONSTANTS.DATA_ATTRIBUTE && property !== `${CONSTANTS.DATA_ATTRIBUTE}_base64`) { - headers[`${CONSTANTS.EXTENSIONS_PREFIX}${property}`] = extensionHeaderValue(value); + headers[`${CONSTANTS.EXTENSIONS_PREFIX}${property}`] = stringifyExtensionValue(value); } } }); @@ -71,16 +71,25 @@ const isPlainObject = (value: unknown): value is Record => * Converts an extension attribute value to its binary-mode header value. * * The CloudEvents type system has no object type: extension values are strings, - * booleans, integers, binary, URIs or timestamps. Timestamps are sent as RFC 3339 - * and binary values as base64, the spec's canonical string forms. As a convenience - * the SDK also accepts plain objects and arrays and serializes them as JSON rather - * than letting the HTTP client coerce them to "[object Object]"; prefer encoding - * such values as strings yourself. Anything else (Error, Map, ...) is not a - * CloudEvents type and is left for the HTTP client to coerce with String(), as before. + * booleans, integers, binary, URIs or timestamps. Values that are not already + * strings are converted as follows; the branches are disjoint, so their order + * does not matter. The last column is what the HTTP client's String() coercion + * used to put on the wire. + * + * | value | header value | before | + * |--------------------------|-------------------------|---------------------------------| + * | Date | RFC 3339 (toISOString) | local-time string | + * | Buffer, typed array | base64 | UTF-8 text, or "104,105" | + * | plain object, array | JSON (convenience) | "[object Object]", or "1,2" | + * | anything else | unchanged | unchanged | + * + * JSON for plain objects and arrays is an SDK convenience, not spec behavior; + * prefer encoding such values as strings yourself. Anything else (Error, Map, + * null, ...) is not a CloudEvents type and is left for the HTTP client, as before. * @param {unknown} value the extension attribute value * @returns {string} the header value */ -function extensionHeaderValue(value: unknown): string { +function stringifyExtensionValue(value: unknown): string { if (isDate(value)) { return value.toISOString(); }