Skip to content

fix: CMS media URL and schema handling#124

Merged
olliethedev merged 7 commits into
mainfrom
fix/cms-admin-form
Jun 10, 2026
Merged

fix: CMS media URL and schema handling#124
olliethedev merged 7 commits into
mainfrom
fix/cms-admin-form

Conversation

@olliethedev

@olliethedev olliethedev commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Prevent media URL submissions from bubbling into CMS editor forms and triggering unintended saves/navigation.
  • Allow explicitly configured protocol-wide media URL prefixes such as https:// and http://, with coverage for both.
  • Smooth CMS auto-form behavior by avoiding mixed controlled input props, suppressing expected custom field-type warnings, and allowing JSON-schema-derived form data to passthrough unknown keys.

Test plan

  • pnpm --filter @btst/stack exec vitest run src/plugins/media/__tests__/plugin.test.ts
  • pnpm --filter @btst/stack typecheck

Note

Medium Risk
formSchemaToZod passthrough changes validation semantics for stale schemas; protocol-wide allowedUrlPrefixes can 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 API matchesUrlPrefix now supports protocol-only prefixes like https:// and http:// (with docs on mixed content/SSRF when using broad prefixes); a new Vitest case covers explicit https:// / http:// allowlists.

CMS / auto-form: formSchemaToZod applies .passthrough() on objects from z.fromJSONSchema so extra keys in parsedData aren’t rejected with unrecognized_keys when the DB schema is stale. Auto-form object fields omit defaultValue from spread props to avoid controlled/uncontrolled warnings, and buildFieldConfigFromJsonSchema no longer warns on unknown custom fieldTypes (CMS injects those later). calendar.tsx uses month_grid for react-day-picker v9; registry test script pins react-day-picker ^9 alongside tiptap.

Also bumps @btst/stack to 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 to AGENTS.md.

Reviewed by Cursor Bugbot for commit b702b6a. Bugbot is set up for automated code reviews on this repo. Configure here.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
better-stack-docs Ready Ready Preview, Comment Jun 10, 2026 8:44pm
better-stack-playground Ready Ready Preview, Comment Jun 10, 2026 8:44pm

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. Latent SSRF surface — The URL is stored but not fetched by this plugin. However, consumer-supplied onAfterUpload hooks (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 allowedUrlPrefixes option 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.

Open in Web View Automation 

Sent by Cursor Automation: Find vulnerabilities

Comment thread packages/ui/src/lib/schema-converter.ts
Comment thread packages/stack/src/plugins/media/api/plugin.ts
olliethedev and others added 2 commits June 10, 2026 12:54
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>
@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

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>
@olliethedev olliethedev merged commit 2aecbae into main Jun 10, 2026
9 checks passed
@olliethedev olliethedev deleted the fix/cms-admin-form branch June 10, 2026 20:57
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