feat(cms): phase-2 sweep onto core primitives - #143
Conversation
Migrate the CMS plugin to the v3 core primitives: declare cmsResources via createResource/createResourceQueryKeys and rewrite cms-hooks.tsx as thin wrappers; move the editor onto resource useForm with inline server field errors; move RelationField onto useSelect with server-side search; replace sonner with useNotify; convert all UI strings to useTranslate with localization overrides; gate create/edit/delete with ComposedRoute permission + CanAccess; add a URL-synced search box via useListState backed by a new search param on the list API. Core gains params (path parameters) and nextPageParam (envelope pagination) on resource query declarations to support the sweep. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 61ee7de. Configure here.
There was a problem hiding this comment.
Security Review
Summary
One confirmed medium-severity vulnerability (DoS via unbounded full-table scan) and two informational items.
🔴 Medium — Unbounded full-table scan via search parameter
Files: packages/stack/src/plugins/cms/schemas.ts · packages/stack/src/plugins/cms/api/getters.ts
The new search query parameter is declared as z.string().optional() with no length limit. When any non-empty search value is received, getAllContentItems removes the limit and offset from the DB query and fetches the entire contentItem table into memory before filtering:
// getters.ts lines 215-216
limit: !needsInMemoryFilter ? params?.limit : undefined, // ← limit dropped
offset: !needsInMemoryFilter ? params?.offset : undefined, // ← offset droppedAny authenticated user (or any caller with access to the /content/:typeSlug?search=x endpoint) can trigger a complete DB table read on every request. With a large content dataset this exhausts server memory and CPU — a straightforward DoS vector.
An unbounded string also wastes CPU in contentItemMatchesSearch, which runs String.prototype.includes across every string value in every item's parsedData.
Remediation — two independent fixes, both needed:
- Cap the search string length in the schema:
search: z.string().max(200).optional(),- Cap the DB fetch even when searching — either set a hard ceiling (e.g.
config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE) or require a per-type upper bound to be configured:
// in getters.ts: always pass a limit to findMany
limit: params?.limit ?? DEFAULT_MAX_PAGE_SIZE,Without a DB-level cap, a legitimate search across a type with 100 k items still loads everything into memory on every keystroke.
🟡 Informational — Fail-open permission gate (useCan / <CanAccess>)
File: packages/stack/src/context/auth.tsx
// When no auth provider is configured, or provider has no can():
if (!auth || !canFn) {
return { can: true, isPending: false }; // ← always allowed
}This is documented and intentional (backward-compatible opt-in), but it creates a silent footgun: a developer who wires <CanAccess resource="admin:panel" action="view"> in their UI expecting access control gets zero protection if they forget to pass auth to StackProvider. No warning, no runtime error, everything renders.
Recommendation: At minimum, add a console.warn in development (process.env.NODE_ENV !== 'production') when <CanAccess> is rendered without a configured auth provider, so the oversight is surfaced during development.
🟡 Informational — curl | sh install in devcontainer Dockerfile
File: .devcontainer/Dockerfile
RUN curl https://mise.run | shMise is installed by piping an unversioned remote URL into sh with no checksum verification. If mise.run is compromised or returns different content, the entire dev image build is compromised. This affects developer machines, not production, but it is still a supply-chain risk.
Recommendation: Pin to a specific mise release and verify the checksum, or install from the Arch package repository (pacman -S mise) if it is available in the package set used:
RUN curl -fsSL https://github.com/jdx/mise/releases/download/v2025.x.y/mise-v2025.x.y-linux-x64.tar.gz \
| tar -xz -C /usr/local/bin --strip-components=1 \
&& echo "<expected-sha256> /usr/local/bin/mise" | sha256sum -cSent by Cursor Automation: Find vulnerabilities
| ) { | ||
| return z.object({ | ||
| slug: z.string().optional(), | ||
| search: z.string().optional(), |
There was a problem hiding this comment.
DoS — no length limit on search.
With search non-empty, getAllContentItems removes limit/offset from the DB query and loads the entire table into memory before filtering. Add a length cap here:
search: z.string().max(200).optional(),Also add a hard DB-fetch ceiling in getAllContentItems (e.g. config.maxPageSize) so a search against a large type cannot exhaust server memory.
There was a problem hiding this comment.
Fixed in cfe0d80: search is now bounded to 200 chars, matching the blog plugin's query bound.
| where: whereConditions, | ||
| limit: params?.limit, | ||
| offset: params?.offset, | ||
| limit: !needsInMemoryFilter ? params?.limit : undefined, |
There was a problem hiding this comment.
DoS — DB query runs without limit when search is set.
When needsInMemoryFilter is true, both limit and offset are passed as undefined to findMany, loading every row in the type. Even with a short search string, a content type with thousands of items causes a full table scan on every request.
// Fix: always apply a cap
limit: params?.limit ?? config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE,
offset: !needsInMemoryFilter ? params?.offset : 0,Pagination after the in-memory filter can then trim to the caller-requested window.
There was a problem hiding this comment.
Fixed in cfe0d80: the DB scan is capped at DEFAULT_MAX_PAGE_SIZE (1000) when search forces the in-memory filter, with pagination still applied after filtering. Items beyond the cap are not searched — acceptable for this fallback until search can be pushed to the adapter.
The ejected registry copy of RelationField imported the cms resource instance from cms-resource.ts, which isn't exported by the package's cms/client/hooks entry point, failing registry validation. Wrap the useSelect call in a useContentOptions hook exported from the hooks barrel so the ejected component stays fully typed. Co-authored-by: Cursor <cursoragent@cursor.com>
|
✅ Shadcn registry validated — no registry changes detected. |
- The list page re-seeds the search input when `?q=` changes externally (hydration after SSR-empty search params, back/forward), so the debounced URL write only ever reflects user edits instead of clobbering the query (Bugbot). - Bound the `search` param to 200 chars and cap the DB scan at DEFAULT_MAX_PAGE_SIZE when search forces the in-memory filter, so a search over a large content type cannot exhaust server memory (security review). Co-authored-by: Cursor <cursoragent@cursor.com>



Summary
Part of the v3 Phase 2 per-plugin adoption sweep (#136). Migrates the CMS plugin onto the core primitives, mirroring the blog sweep (#142):
cmsResourcesviacreateResource/createResourceQueryKeys;cms-hooks.tsxbecomes thin public wrappers over the factory-generated hooks.useFormwith inline server field errors (the API now emits ZodissuessoStackError.errorspopulates correctly) and save toasts.RelationFielduses resourceuseSelectwith debounced server-side search;MultipleSelectorinternal filtering disabled.searchparam on the content list API (slug + data values, case-insensitive) with matching SSG discriminator normalization.?q=search box on the content list viauseListState, with a non-suspense query path while searching to avoid full-page suspense flashes.useNotify()replaces direct sonner usage; all UI strings converted touseTranslate()withlocalizationoverride precedence (newcms-relations.tscatalog).ComposedRoutepermissionprop on the editor route andCanAccessaround New/Edit/Delete controls (cms:contentresource).pagination.tsx, local error helpers,SHARED_QUERY_CONFIGcopies.Core changes (needed by the sweep)
ResourceQueryDefgainsparams(declared path parameters, e.g./content/:typeSlug) andnextPageParam(custom pagination for{ items, total }page envelopes).Test plan
pnpm typecheckandpnpm lintcleancms-query-keysparity, getterssearch, andclient-sweep(notify, field errors, CanAccess, useListState, i18n precedence) testsbtst-cms.json)Made with Cursor