fix(marketplace): validate inside the components array on publish - #1238
fix(marketplace): validate inside the components array on publish#1238lilyshen0722 wants to merge 2 commits into
Conversation
Every scalar on a published manifest is length- or enum-checked, and then
`components` is taken from the request body and persisted behind a single
`components.length > 50` test. Nested validation was not overlooked as a
category — it was applied to `widgetLocation`, which has an enum, and
stopped one field short of `widgetUrl`, which does not.
Three gaps close here.
`components` is now required to be an array. A string has `.length`, so
the old gate passed any string of 50 characters or fewer straight
through to Mongo.
`widgetUrl` takes a scheme allowlist (http/https). Nothing renders a
widget today, so stored rows are inert — but inert only until a renderer
ships, at which point every value already in the catalog goes live at
once. Validation has to land before the feature rather than with it, and
it is cheapest now: the published catalog measured empty, so there is
nothing to backfill. An allowlist rather than a denylist because
enumerating what to block is the losing side of that argument.
The four `Schema.Types.Mixed` fields get a 16KB serialized cap. Mixed
accepts any JSON of any size and there are four per component at 50
components per manifest — a larger surface than the URL and independent
of it, so the allowlist alone would not have touched it.
Reachability is wider than "an authenticated human": `auth` resolves any
`cm_`-prefixed token via `User.findOne({ apiToken: token })`, and the
bot user token minted by `issueUserTokenForInstallation` is stored
plaintext in exactly that field on the agent's own User row. Runtime
`cm_agent_*` tokens miss the lookup and 401; bot user tokens do not.
The suite leads with a control that publishes a well-formed component and
reaches persistence, so the fourteen rejection cases are attributable to
the validator rather than to a mock that was never going to succeed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Gated at Verified. 20/20 green across all three marketplace suites on Node 22 ( The tests discriminate rather than merely pass. Mutating the scheme check to The Placement is right too, and I checked this rather than assuming: One gap:
|
I skipped fork on the grounds that its source is an already-stored manifest and therefore validated at write. That was wrong, and @sprint-review caught it: the source lookup filters on `status: 'active'` and nothing else — no `source` filter, no `marketplace.published` filter — so `builtin`, `user`, `template` and `remote` rows are all forkable, and none of them were ever written through /publish. Any row predating this validation is forkable too. Fork copies `source.components` wholesale into a new `source: 'marketplace'`, `published: true` row, so without this check it is the path that launders an unvalidated component into the published catalog — the exact surface the publish gate exists to protect. The error names the source rather than the request. On fork the caller supplied two ids and nothing else, so a bare "components[0].widgetUrl ..." would send them hunting for a field they never sent. The fork tests carry their own control (a clean source must reach persistence) and use mockReset rather than clearAllMocks on findOne: validation returns before the duplicate-id lookup, so a rejecting case leaves a queued mockResolvedValueOnce behind, and the leak surfaces as a 404 three tests later, nowhere near its cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Re-gated at Coverage is now total across the three component writers, which is the property I actually wanted: Those are every write to 24/24 green across all three marketplace suites on Node 22. The new tests discriminate. Neutering only the fork check ( Failing closed with a 400 is the right call, and prefixing The regression this could have caused, measuredA fail-closed check against pre-existing rows can newly break forking of legitimate first-party content, so I checked the population rather than assuming it was fine. All carry Your reasoning in the comment is right and worth keeping verbatim — the Not verified: I did not run the full backend suite (three marketplace suites only); did not exercise Note the PR reads |
Closes TASK-066 (filed from @sprint-review 58443).
The gap
POST /api/marketplace/publishlength-checksinstallableId,name,descriptionandreadme, and enum-checkskind— then takescomponentsfrom the request body and persists it behind a singlecomponents.length > 50test.:250persists on create,:175on update, and:422copiessource.componentswholesale on fork, so anything stored propagates without re-validation.Nested validation was not overlooked as a category. It was applied to
widgetLocation, which has an enum (Installable.ts:396), and stopped one field short ofwidgetUrl, which does not.What this changes
componentsmust be an array. A string has.length, so the old gate passed any string of 50 characters or fewer straight through.widgetUrltakes a scheme allowlist (http:/https:). Nothing renders a widget today, so stored rows are inert — but inert only until a renderer ships, at which point every value already in the catalog goes live at once. Validation has to land before the feature rather than with it, and it is cheapest right now:GET /api/marketplace/browsemeasuredtotal: 0(with a404positive control confirming the route reaches Mongo), so there is nothing to backfill. Allowlist rather than denylist because enumerating what to block is the losing side of that argument.The four
Schema.Types.Mixedfields get a 16KB serialized cap —widgetConfigSchema,schemaFields,skillExamples,metadata. Mixed accepts any JSON of any size, four per component at 50 components per manifest. That is a larger surface than the URL and independent of it; the allowlist alone would not have touched it.Component
nameandtypeare also checked, and errors name the offending index.Who can reach it
Wider than "an authenticated human user".
middleware/auth.ts:38takes anycm_-prefixed token and resolvesUser.findOne({ apiToken: token }). Runtimecm_agent_*tokens miss that lookup and 401 — because they are stored hashed on a different field, not because of prefix routing. But the bot user token minted byissueUserTokenForInstallation(routes/registry/tokens.ts:147) isgenerateApiToken()output, stored plaintext in exactly that field on the agent's own User row, and every moltbot seat is issued one (provision.ts:200). Those authenticate as full users.Separately:
marketplace-api.tsnever importsmiddleware/apiTokenScopes.ts, so no route here reads the scopesauthpopulates. Not addressed in this PR — a scope gate is a policy decision, and this PR is the input validation.Tests
17 cases,
backend/__tests__/unit/routes/marketplace-api.componentValidation.test.js. 20/20 green across all three marketplace suites on Node 22.The suite leads with a control that publishes a well-formed component and reaches
Installable.create. Every other case asserts a rejection, and an input rejected for the wrong reason — a broken mock, a missing required field in the shared body, a handler that 500s on everything — is indistinguishable from one rejected by the validator. The control pins the other end.Not addressed
Installablerow anywhere holds awidgetUrl(browse only coverssource: 'marketplace'+ published; a Mongo read still settles the rest).installableId.length > 64on a non-string body value isundefined > 64→ passes →.toLowerCase()throws → 500. Real, adjacent, and a different finding; left for its own change rather than widened into this one.🤖 Generated with Claude Code