web_core's DataModel accepts an arbitrarily large list index, such as /items/999999999. The write itself is cheap, because JavaScript arrays are sparse. The cost appears later, when that array is turned into JSON.
Measured
Against the built web_core DataModel:
set('/items/1000000', 'x')
heap growth: ~14 KB
array length: 1000001
serialized length: 5,000,002 chars
JSON.stringify expands every hole to null. At index 10⁹ that is roughly 5 GB of JSON from a 14 KB write.
Why it matters
The index does not have to come from the page's own code. It can come from the agent:
- The agent creates a surface with
sendDataModel: true. That opts the surface into sending its data model along with anything the client sends back, so the agent can see form state.
- The agent sends
updateDataModel with path: "/items/999999999". It looks ordinary and web_core accepts it. Cost so far: ~14 KB.
- Later the user does something that the client reports, for example tapping a
Button with an action. Before sending, the client gathers its data model via MessageProcessor.getClientDataModel(), which reads dataModel.get('/') for every surface with sendDataModel enabled.
- The transport JSON-encodes that object into the
a2uiClientDataModel metadata field of the outgoing message.
- The array reports
length: 1000000000, so JSON.stringify writes null a billion times. The outgoing message is roughly 5 GB.
So one small, well-formed message from the agent makes the client build an enormous payload on its next turn. The client pays the cost, and the agent controls the index.
Why the two implementations differ
- Dart lists are dense, so writing index N allocates N+1 slots. It already caps this:
maxAutoVivifyIndex = 10000, enforced in DataModel.set at L86 and L125.
- web_core arrays are sparse, so the same write costs nothing on the heap and
DataModel.set has no cap.
Sparse arrays protect the heap, not the payload. Both implementations need a bound; they just need it for different resources.
Proposal
- Agree a shared upper bound on auto-vivified list indices. Dart's
10000 is arbitrary and worth choosing deliberately.
- Enforce it in
web_core's DataModel.set, throwing A2uiDataError as it already does for other invalid segments.
- Promote the behaviour to
conformance/core/data_model.yaml and drop the package-local tests.
- Check the other renderers and SDKs for the same gap.
Step 2 changes what input a shipped renderer accepts, so it needs a maintainer decision rather than being folded into a test change.
Repro
import {DataModel} from './dist/src/v0_9/state/data-model.js';
const m = new DataModel({items: ['a', 'b', 'c']});
m.set('/items/1000000', 'x');
console.log(m.get('/items').length); // 1000001
console.log(JSON.stringify(m.get('/items')).length); // 5000002
Context
Surfaced while migrating data-model.test.ts into the shared conformance suite in #2408. The exclusion is currently documented in the suite header and in the web_core test's doc comment; both should be removed once this is resolved.
web_core'sDataModelaccepts an arbitrarily large list index, such as/items/999999999. The write itself is cheap, because JavaScript arrays are sparse. The cost appears later, when that array is turned into JSON.Measured
Against the built
web_coreDataModel:JSON.stringifyexpands every hole tonull. At index 10⁹ that is roughly 5 GB of JSON from a 14 KB write.Why it matters
The index does not have to come from the page's own code. It can come from the agent:
sendDataModel: true. That opts the surface into sending its data model along with anything the client sends back, so the agent can see form state.updateDataModelwithpath: "/items/999999999". It looks ordinary andweb_coreaccepts it. Cost so far: ~14 KB.Buttonwith anaction. Before sending, the client gathers its data model viaMessageProcessor.getClientDataModel(), which readsdataModel.get('/')for every surface withsendDataModelenabled.a2uiClientDataModelmetadata field of the outgoing message.length: 1000000000, soJSON.stringifywritesnulla billion times. The outgoing message is roughly 5 GB.So one small, well-formed message from the agent makes the client build an enormous payload on its next turn. The client pays the cost, and the agent controls the index.
Why the two implementations differ
maxAutoVivifyIndex = 10000, enforced inDataModel.setat L86 and L125.DataModel.sethas no cap.Sparse arrays protect the heap, not the payload. Both implementations need a bound; they just need it for different resources.
Proposal
10000is arbitrary and worth choosing deliberately.web_core'sDataModel.set, throwingA2uiDataErroras it already does for other invalid segments.conformance/core/data_model.yamland drop the package-local tests.Step 2 changes what input a shipped renderer accepts, so it needs a maintainer decision rather than being folded into a test change.
Repro
Context
Surfaced while migrating
data-model.test.tsinto the shared conformance suite in #2408. The exclusion is currently documented in the suite header and in theweb_coretest's doc comment; both should be removed once this is resolved.