Skip to content

Classify System Composer names by own key, and draw entry uuids from crypto.getRandomValues - #73

Merged
ww-mw merged 3 commits into
mainfrom
fix-catalog-own-keys-and-uuid
Oct 11, 2026
Merged

ww-mw merged 3 commits into
mainfrom
fix-catalog-own-keys-and-uuid

Conversation

@ww-mw

@ww-mw ww-mw commented Oct 11, 2026

Copy link
Copy Markdown
Member

Fixes two CodeQL code-scanning alerts, one commit each, each with its rebuilt dist/:

A third commit only adds tests.

Neither alert is a real vulnerability in how this package is used. The first one sat on top of a real correctness bug, and fixing it is most of this PR.

Alert 4: prototype-polluting assignment (CodeQL: medium; effective: low)

The flagged line was catalog[def.container][def.name] = def.type in catalogFromDefinitions. No file can reach the polluting form, because both readers (scanScXml and scanScJsonText) only ever pass the literal 'interfaces' or 'modeledDataTypes' as container. But the function is exported from the barrel, and a JS caller is not held to the type union. Passing container: '__proto__' really did write the definition onto Object.prototype, and the new test for that fails on main. So the alert is reachable only by a caller of the exported API, not by an input file.

catalogFromDefinitions now picks the bag by comparing container to the two literals instead of indexing with it. A container outside the union is skipped.

The correctness bug underneath

All catalog lookups went through plain {} objects: catalog.interfaces[name] || catalog.modeledDataTypes[name], then SC_TYPE_TO_CLASSIFICATION[scType]. A name that Object.prototype carries makes the interface lookup return the inherited function, which is truthy, so the modeled-data-type bag was never consulted. toString, constructor, valueOf, hasOwnProperty, isPrototypeOf, propertyIsEnumerable and toLocaleString are all valid MATLAB names.

I measured this on the arch_binary.sldd / arch_binary_as_text.sldd / arch.sldd fixtures, with the entry and its catalog definition renamed together:

Case main This PR
Struct type named any of the seven names above, compressed-binary and text .sldd Kind Data Interface, classification undefined, isStructType false Kind Struct Type, classification StructType, isStructType true
Numeric, enum or alias type named any of the seven names: numeric in compressed-binary and text .sldd; enum and alias in text .sldd only, because arch_binary.sldd has no enum or alias type to rename Kind correct (class fallback), classification undefined classification NumericType / EnumType / AliasType
Interface (data, physical, service, value type) of those names correct (its own key shadows the inherited one) correct
Catalog type string "constructor" classificationOf returns the Object function null
Definition named __proto__, through catalogFromDefinitions or the text reader silently dropped by the setter (not in Object.keys) recorded, classifies as DataInterface / StructType
catalogFromDefinitions with container: '__proto__' writes onto Object.prototype skipped

A struct type is the only one whose Kind changes, because Simulink.Bus is the only class with no 1:1 class fallback.

The fix:

  • classificationOf reads only own keys, in both bags and in the type map.

  • Both readers build prototype-less bags with a new emptyCatalogBag(): the binary path through catalogFromDefinitions, and the text path in SlddNode. emptyCatalogBag() is a module-level export of ScCatalog.ts that the barrel does not re-export, so dist/index.d.ts is byte-identical.

  • The SystemComposerCatalog type is unchanged. A host that builds or clones the catalog itself (which gives it a prototype again) is still classified correctly, because the lookups no longer depend on the bags being prototype-less.

  • One visible difference for a consumer: a bag is now Object.create(null), so it inherits no methods. Three things behave differently:

    • bag.hasOwnProperty(x) called as a method throws TypeError: bag.hasOwnProperty is not a function.
    • ${bag}, String(bag) and bag + '' throw TypeError: Cannot convert object to primitive value.
    • toStrictEqual against an object literal fails.

    toEqual and toMatchObject (which data-explorer-vscode's tests use on these bags) accept it; I checked that with a vitest probe.

dex-core already classifies these names correctly, because its catalog is a hash map with no inherited keys. This PR brings the reference into agreement with the port.

Alert 3: insecure randomness (CodeQL: high; effective: low)

CodeQL fired because the value is stored under the property uuid. Its maybeAccountInfo heuristic includes ([uU]|^|_|[a-z](?=U))([uU][iI][dD]), which matches the uid in uuid. The value is an entry's identity, not a secret. It only has to be unique (it tells two same-named entries apart, see entrySelector.ts), and the file stores it in plain text.

The real reason to change it anyway: a Math.random uuid published the generator's state. Every nibble was four bits of V8's xorshift128+ output, and two uuids drawn back to back are enough to solve for the 128-bit state over GF(2). I ran this on main's dist, in a fresh Node 24.14.1 process each time: from the first two uuids, the third was predicted exactly in 5 of 5 runs.

generateUuid now takes 16 bytes from crypto.getRandomValues. That is a global in Node 19+ (so every VS Code extension host), in browsers and in the web extension's worker, and it needs no secure context. The output format is unchanged: 32 lowercase hex nibbles as 8-4-4-4-12, with no version or variant nibble fixed. That is deliberately not crypto.randomUUID, which would pin the 13th nibble to 4 and the 17th to one of 8–b; dex-core's parity depends on the existing shape. The exported signature and SectionNode.d.ts are unchanged; npm run build changes only SectionNode.js and its two maps.

One new runtime requirement: crypto is a global only from Node 19 on, and core's package.json has no engines field, so nothing declares that floor. Node 18 is end-of-life, CI and release both run Node 22 (.github/workflows/ci.yml and release.yml), and every VS Code extension host has it (data-explorer-vscode requires VS Code ^1.106.0). This PR does not add an engines field.

Tests

  • test/scCatalog.test.ts adds 11 cases: the five names in both formats, a rename to toString through the UI path, own-key classification of a catalog that has a prototype (hand-built from object literals, structuredCloned, or JSON-cloned), __proto__ as a name through catalogFromDefinitions, __proto__ as a struct-type name and as a data-interface name through the text reader, and __proto__ as a container. On main: 11 failed, 18 passed. With this PR: 29 of 29 pass.
    • The two text-reader cases pin SlddNode._parseSystemComposer's own prototype-less bags, which nothing else covers. Every other inherited name is an own key once it is written, so the own-key lookup reads it correctly even from a plain {}. Only __proto__ is lost, because the setter drops it. With both bags put back to {}, the full suite fails exactly those two cases (2 failed, 6615 passed, 26 skipped). With either one put back alone, only that bag's case fails.
    • The hand-built case was checked against main's classificationOf on its own: both the { toString: … } literal and the five-name Object.fromEntries catalog come back null there.
  • test/sectionNode.test.ts already had a generateUuid block (format, plus uniqueness over 200 calls). This PR raises uniqueness to 10,000 calls and adds two cases:
    • "Fixes no nibble": over 10,000 uuids each of the 32 positions takes all 16 values. The chance of a miss is about 3e-278. A temporary mutation to crypto.randomUUID() fails this case and nothing else.
    • "Draws nothing from Math.random": on main it fails (Math.random called 32 times); with this PR the file passes 50 of 50.

Verification

All run locally on Node 24.14.1. CI runs Node 22; I had no Node 22 available to try.

  • npm run verify on each commit's tree, exit 0 all three times:
    • commit 1: 205 test files passed, 1 skipped; 6613 tests passed, 26 skipped
    • commit 2: 6615 tests passed, 26 skipped
    • commit 3: 205 test files passed, 1 skipped; 6617 tests passed, 26 skipped
    • smoke, check:pack, check:leak and check:browser OK every time
  • dist/ is current at each commit. For commits 1 and 2, I checked out each one, ran npm run build, and git status was empty. Commit 3 changes only test/scCatalog.test.ts, and the build inside its npm run verify left dist/ unchanged.
  • CodeQL 2.27.2, javascript-code-scanning.qls (89 queries), on fresh databases:
    • main: 3 results: prototype-polluting @ ScCatalog.ts:142, insecure-randomness @ SectionNode.ts:184, polynomial-redos @ ScCatalog.ts:185
    • after commit 1: 2 results
    • after commit 2: 1 result, the polynomial-redos one, now at ScCatalog.ts:217 because this PR's comments move it. That one belongs to the ReDoS branch.
    • commit 3 changes only a test file, and I did not re-run CodeQL on it.
  • No dependency or lockfile change.

Merge order with the ReDoS branch

This PR changes src/datamodel/parser/ScCatalog.ts and test/scCatalog.test.ts, which the parallel ReDoS branch (fix-redos-file-scanners) also changes. Whichever merges second needs main merged in and dist/ rebuilt (npm run build). Expect the single-line dist/datamodel/parser/ScCatalog.*.map files to conflict in that merge.

A dry run of that branch's source diff over this branch applies cleanly (3 hunks, 32-line offset). The test file conflicts only because both branches append a describe block at the end of test/scCatalog.test.ts; keep both.

Follow-ups, not in this PR

The same inherited-key lookup pattern exists elsewhere. I reproduced each case on this branch:

  • src/datamodel/node/data/NodeClassMap.ts:112 (getClass) and :141 (CLASS_MAP[obj._array_class]): an entry whose class is toString, constructor, valueOf or hasOwnProperty makes the whole dictionary fail to open with TypeError: NodeClass.parse is not a function. An unknown class such as Toaster opens fine.
  • src/datamodel/kindMap.ts:101-107 (kindForClass): returns a function instead of a Kind string for those class names, for example kindForClass('constructor') returns Object.
  • src/datamodel/parser/MatParser.ts:757 ((fields[fn] as MatVariable[]).push(child), after the if (!fields[fn]) guard on 756): a struct array (1x2 here) with a field named constructor, toString, valueOf or hasOwnProperty makes the .mat fail to open with TypeError: fields[fn].push is not a function. A 1x1 struct with the same field reads fine.

ww-mw added 3 commits October 11, 2026 17:54
… type

classificationOf asked `catalog.interfaces[name] || catalog.modeledDataTypes[name]` of plain
objects, so a name Object.prototype carries was answered by the interface bag with the
inherited function and the modeled bag was never asked. toString, constructor, valueOf,
hasOwnProperty, isPrototypeOf, propertyIsEnumerable and toLocaleString are all valid MATLAB
names. A struct type of any of those names read as Kind "Data Interface", classification
undefined, isStructType false, in a compressed-binary and in a text .sldd alike. A numeric,
enum or alias type of such a name lost its classification too, and kept its Kind only through
the class fallback, which Simulink.Bus does not have. An interface was unaffected: its own key
shadows the inherited one. And a catalogued type spelled "constructor" classified as the
Object function, not as null.

Every lookup is now of an own key, in both bags and in the type map, and both readers build
prototype-less bags (emptyCatalogBag, a module export the barrel does not carry), so a
definition named `__proto__` is recorded rather than dropped by the setter.

catalogFromDefinitions now chooses the bag by comparing `container` rather than indexing with
it. That is code-scanning alert 4, js/prototype-polluting-assignment at ScCatalog.ts:142. No
file could reach it, because both readers pass a literal container. But the function is
public, and a JS caller passing container '__proto__' wrote the definition onto
Object.prototype. A container outside the union is now skipped. CodeQL 2.27.2's
code-scanning suite over this tree: that query's result goes from 1 to 0, and nothing else
changes.

test/scCatalog.test.ts adds 9 cases. At origin/main 9 fail and 18 pass; here 27 of 27 pass.
They cover five such names in both formats, a rename to toString through the UI path,
own-key classification of a catalog that has a prototype again (host-built, structuredClone'd,
JSON-cloned), `__proto__` as a name, and `__proto__` as a container.
generateUuid drew its 32 nibbles from Math.random. The uuid is an entry's identity, not a
secret: it tells two same-named entries apart, and the file stores it in plain text. But each
uuid published 128 bits of V8's xorshift128+ output, and two uuids drawn back to back are
enough to solve for the generator's state. From the first two uuids of a fresh Node 24
process, the third was predicted exactly in 5 of 5 runs, by a GF(2) solve over the nibbles.

It now takes 16 bytes from crypto.getRandomValues. That is a global in Node 19+ (so in every
VS Code extension host), in browsers and in the web extension's worker, and it needs no
secure context. The shape is unchanged: 32 lowercase hex nibbles as 8-4-4-4-12, none of them
fixed. That is deliberately not crypto.randomUUID, which pins the version nibble to 4 and the
variant nibble to 8-b. The exported signature and the declarations do not change.

That is code-scanning alert 3, js/insecure-randomness at SectionNode.ts:184. It fired because
the value is stored as `uuid`, which the query's account-information heuristic matches (a
`uid` after a `u`). CodeQL 2.27.2's code-scanning suite: that query's result goes from 1 to 0.

test/sectionNode.test.ts raises the uniqueness case from 200 uuids to 10,000 and adds two
cases. One asserts that each of the 32 positions takes all 16 values over 10,000 uuids, which
a switch to crypto.randomUUID fails. The other asserts that Math.random is never called, which
fails at origin/main (32 calls) and passes here.
…by own key

Nothing pinned SlddNode._parseSystemComposer's switch to emptyCatalogBag. With both of its bags
put back to a plain {}, every test still passed. A plain {} loses only one name: once any other
inherited name is written there it is an own key, and the own-key lookup reads it correctly.
`__proto__` is the exception, because writing it calls the prototype setter, which drops a
string. So two cases now rename a struct type and a data interface to `__proto__` in the text
dictionary. Each asserts that the catalog keeps the name, that the entry classifies as
StructType or DataInterface, and that the text catalog equals the binary one. With both bags
put back to {}, the full suite fails exactly those two (2 failed, 6615 passed). With either one
put back alone, only that bag's case fails.

The own-key case also checked a catalog with a prototype only through clones of a reader's
catalog. It now checks one built by hand, too: { toString: <struct type> } as an object
literal, and all five names through Object.fromEntries. Both come back null from main's
classificationOf.

The two helpers take the name to rename from, instead of always StructType. At origin/main the
file has 11 failures and 18 passes; here all 29 pass.
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