fix: CMS media URL and schema handling#124
Conversation
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.
Security Review — PR #124: fix: CMS media URL and schema handling
Three findings, ordered by severity. No prior automation threads to reconcile (first run on this PR).
[MEDIUM] passthrough() stores arbitrary extra fields in the database — comment and implementation disagree
File: packages/ui/src/lib/schema-converter.ts
The inline comment states the intent is to "strip unknown keys silently rather than rejecting them" to match the default z.object() behaviour. However, passthrough() does not strip — it passes unknown keys through into validation.data. The correct Zod method to strip unknown keys (matching the default z.object() behaviour) is .strip().
Concrete impact on the CMS create/update paths (packages/stack/src/plugins/cms/api/plugin.ts):
validation = zodSchema.safeParse(dataWithResolvedRelations) // passthrough lets unknowns in
processedData = validation.data // unknowns now in processedData
data: JSON.stringify(processedData) // unknowns persisted to DB
Any field that exists in the POST/PUT body but is not declared in the content-type JSON Schema will survive validation and be written to the data column verbatim. On retrieval, parsedData is produced by JSON.parse(item.data), so those extra fields are also returned to the client. If any consumer template renders parsedData values without sanitisation (e.g. dangerouslySetInnerHTML or an unsafe rich-text renderer), a sufficiently privileged user can store an XSS payload.
Recommended fix: Replace .passthrough() with .strip() to match the stated intent and prevent unschema'd keys from persisting:
if (schema && typeof (schema as z.ZodObject<z.ZodRawShape>).strip === "function") {
schema = (schema as z.ZodObject<z.ZodRawShape>).strip();
}[LOW-MEDIUM] Protocol-only allowedUrlPrefixes creates an effectively open URL allowlist; no operator warning
File: packages/stack/src/plugins/media/api/plugin.ts
The new matchesUrlPrefix branch explicitly handles prefixes that end with ://:
if (trimmedPrefix.endsWith("://")) {
return url.startsWith(trimmedPrefix); // matches ANY URL with that scheme
}The added test confirms and documents this as intentional:
allowedUrlPrefixes: ["https://", "http://"]
// → accepts https://images.example.net/affiliate/photo.jpg ✔
// → accepts http://images.example.net/affiliate/photo.jpg ✔z.httpUrl() in the request schema (createAssetSchema) blocks raw IP addresses and localhost (it requires a registrable domain), but any public or internal domain-name-reachable URL passes. Two concrete risks:
http://asset registration on an HTTPS site — Mixed-content rules will cause browsers to block the image silently, and it normalises storing unencrypted asset URLs in the media library.- Latent SSRF surface — The URL is stored but not fetched by this plugin. However, consumer-supplied
onAfterUploadhooks (e.g. image resizing, thumbnail generation, virus scanning) commonly do fetch the URL server-side. Allowing protocol-only prefixes means any internal service reachable by its domain name (e.g.http://internal-service.corp/) becomes a target for an authenticated user who can POST to/api/media/assets.
Recommended additions:
- Add a JSDoc warning to the
allowedUrlPrefixesoption documenting that protocol-only strings ("https://","http://") disable origin-based restrictions. - Consider rejecting
"http://"as a standalone prefix (or at minimum logging a startup warning) since mixing HTTP assets into an HTTPS media library is almost always unintentional.
[LOW] type="url" removed from media URL input — browser-level validation lost
File: packages/stack/src/plugins/media/client/components/media-picker/url-tab.tsx
The input was changed from type="url" to type="text" with inputMode="url". inputMode only hints the on-screen keyboard; it does not trigger the browser's built-in URL format validation or (in some browsers) its phishing/suspicious-URL UI. Server-side z.httpUrl() still validates the URL before the asset is created, so there is no server-side bypass — this is a defense-in-depth reduction only.
If the motivation was mobile autocapitalise/autocorrect behaviour the attributes autoCapitalize="none" and autoCorrect="off" already present achieve that goal without losing URL validation. Switching back to type="url" with those attributes applied would restore both.
Not a finding — console.warn removal in helpers.tsx
The removal of the unknown fieldType warning is an observability change, not a security change. No action needed.
Sent by Cursor Automation: Find vulnerabilities
Clarify that formSchemaToZod intentionally uses passthrough() (strip would silently drop fields from a newer live Zod schema), document the security implications of protocol-only allowedUrlPrefixes, and sync the rebuilt registry JSON. Co-authored-by: Cursor <cursoragent@cursor.com>
- Pin react-day-picker to v9 in the registry test app: shadcn's calendar
still uses deprecated v8 ClassNames keys removed in react-day-picker 10,
failing next build type checks. Also switch the workspace calendar to the
canonical month_grid key.
- Drop @tanstack/*>zod 3.x overrides: latest @tanstack/start-plugin-core
requires zod ^4.4 (.prefault), so the forced downgrade crashed the
tanstack codegen build.
- Use `import type { Todo }` in codegen todo templates; rolldown rejects
value imports of type-only exports (react-router build failure).
- Scope the chat smoke test assertion to the chat interface so sidebar
conversation titles (and retries with the in-memory adapter) don't
trigger strict mode violations.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
✅ Shadcn registry validated — no registry changes detected. |
… codegen
- Pin react and react-dom overrides to exactly 19.2.7: react-dom requires
the identical react version at runtime, and the caret override allowed
fresh codegen installs to pair react 19.2.4 with react-dom 19.2.7,
crashing the react-router/tanstack E2E servers at startup.
- Use `import type { MyRouterContext }` in the tanstack __root.tsx template
(rolldown MISSING_EXPORT, same class as the Todo import fix).
Co-authored-by: Cursor <cursoragent@cursor.com>
- Pin the zod override to exactly 4.4.3: zod brands its minor version into its types, so a fresh codegen-project install resolving 4.4.x alongside the lockfile's 4.2.x fails Next.js typechecking (FlexibleSchema mismatch). - Overlay vite.config.ts with the nitro/vite plugin for the tanstack codegen project: the current shadcn start template dropped nitro, emitting a bare dist/server fetch handler instead of the runnable .output/server/index.mjs that start:e2e expects. Guarantee overlay plugin deps via extraDeps. Co-authored-by: Cursor <cursoragent@cursor.com>
The fresh shadcn start template now resolves react-start 1.171 + vite 8 (rolldown) + latest nitro, whose SSR bundle crashes at runtime with a tslib __extends CJS/ESM interop error, 500ing every request and making the E2E job grind through 3 retries per test. Pin the toolchain to the known-good combination (react-start 1.167.16, vite 7.3.1, nitro 3.0.260603-beta) and restore the scoped zod v3 overrides that this toolchain requires. Co-authored-by: Cursor <cursoragent@cursor.com>


Summary
https://andhttp://, with coverage for both.Test plan
pnpm --filter @btst/stack exec vitest run src/plugins/media/__tests__/plugin.test.tspnpm --filter @btst/stack typecheckNote
Medium Risk
formSchemaToZodpassthrough changes validation semantics for stale schemas; protocol-wideallowedUrlPrefixescan widen accepted asset URLs if misconfigured.Overview
Fixes CMS/media URL registration and form validation when stored JSON schemas lag behind live Zod definitions, plus small toolchain and test hardening.
Media: The URL tab stops submit event bubbling (so nested CMS editor forms don’t save/navigate accidentally), switches the field to
type="text"with URL-friendly input hints, and mirrors the same in registry bundles. The media APImatchesUrlPrefixnow supports protocol-only prefixes likehttps://andhttp://(with docs on mixed content/SSRF when using broad prefixes); a new Vitest case covers explicithttps:///http://allowlists.CMS / auto-form:
formSchemaToZodapplies.passthrough()on objects fromz.fromJSONSchemaso extra keys inparsedDataaren’t rejected withunrecognized_keyswhen the DB schema is stale. Auto-form object fields omitdefaultValuefrom spread props to avoid controlled/uncontrolled warnings, andbuildFieldConfigFromJsonSchemano longer warns on unknown customfieldTypes (CMS injects those later).calendar.tsxusesmonth_gridfor react-day-picker v9; registry test script pins react-day-picker ^9 alongside tiptap.Also bumps
@btst/stackto 2.12.2, pins React 19.2.7 and zod 4.4.3, scopes a chat smoke assertion to[data-testid="chat-interface"], and adds Agent Working Principles toAGENTS.md.Reviewed by Cursor Bugbot for commit b702b6a. Bugbot is set up for automated code reviews on this repo. Configure here.