dotCMS / dotCMS/core

Block Editor (Story Block) field content is inlined into the contentlet template as live Velocity, unlike every other author-text field

Open
#37,299 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

OKR : Customer Support Team : Maintenance Type : Defect
Dominant language
Java
Stars
970
Forks
486
Avg merge
3d 33m
Merged PRs (30d)
170

Description

Problem Statement

ContentletLoader.buildVelocity() generates the .contentlet Velocity template for every
contentlet. Every branch in that method which can carry author-typed text first checks whether
the value contains $ or #, and if it does, routes the value into its own separate field
template via $velutil.mergeTemplate(...) instead of inlining it — so that author text is never
parsed as Velocity.

One branch skips that check: Story Block fields holding valid JSON — the normal path for every
Block Editor field.

Field kind contains("$") || contains("#") guard Location
Hidden / Constant (incl. widgetCode) yes ContentletLoader.java:225
Generic text / WYSIWYG / textarea yes ContentletLoader.java:260
Story Block, invalid JSON yes ContentletLoader.java:195
Story Block, valid JSON no ContentletLoader.java:182-190

The unguarded branch appends the raw JSON straight into the generated template:

final String jsonStr = contFieldValueObject.toString()
        .replaceAll(":\\s*\\[\\s*\\]", ":\\$contents.getEmptyList()");
sb.append("#set($")
        .append(field.variable())
        .append("= $json.generate(")
        .append(jsonStr)          // raw - no espaceForVelocity, no mergeTemplate indirection
        .append("))");

Net effect: anything an author types into a Block Editor that looks like Velocity becomes live
Velocity inside the generated contentlet template
. The identical text in a WYSIWYG field would
have been isolated into its own field template and never evaluated.

This is a behavioural difference between two field types that authors reasonably expect to behave
the same way, and it is a migration hazard: commit 5576c53baa ("Transform WYSIWYG field to
block editor fields") moved bodies such as Web Page Content's body from the guarded path onto
the unguarded one.

Where the evaluation happens

Not at Story Block render time. renderMarks in VM_global_library.vm:7 emits node text as
$!{content.text}, and Velocity does not re-parse the value of a reference. If author-typed
Velocity executes, it executes at the ContentletLoader inlining point, i.e. while the
.contentlet template itself is being parsed. That is consistent with the reported errors
pointing at /EDIT_MODE/<identifier>_<lang>_<variant>.contentlet on line 1 at a high column
number — the generated single-line template, not hand-authored VTL.

Customer impact

Reported via Freshdesk #38995 (dotEvergreen,
Cloud). A #dotParse(...) in Block Editor content is evaluated in a context where the author's
$ references were never defined, so the argument is rendered literally and the include fails.
The customer's contentlet renders completely empty on the page while the same VTL produces
correct output in the Velocity Playground.

An author currently has no way to put a literal $ or # into Block Editor text and have it
survive as text.

Steps to Reproduce

  1. On a content type with a Block Editor (Story Block) field — e.g. Web Page Content body
    create a contentlet and type Velocity syntax into the Block Editor, for example:
    #dotParse("//$someUndefinedVar") or simply $someUndefinedVar.
  2. Add the contentlet to a container on a page and render the page in Edit Mode.
  3. Inspect the generated file under the velocity root (VelocityUtil.getVelocityRootPath()) at
    /EDIT_MODE/<identifier>_<lang>_<variant>.contentlet.
  4. Observed: the field is emitted as #set($body= $json.generate({...})) with the author's
    #dotParse( / $someUndefinedVar text sitting inside it as live Velocity, and it is
    evaluated when the template is parsed.
  5. Control: put the identical text into a WYSIWYG field on the same content type and repeat.
    That field is emitted as #set($body=$velutil.mergeTemplate("...")) and the text is not
    evaluated. The two generated files differing on identical input is the defect.

Symptom reproduced on demo.dotcms.com.

Expected Behaviour

Text an author types into a Block Editor is treated as content, not as template source. A Story
Block field carrying $ or # should be handled the same way every other author-text field in
buildVelocity() is handled — isolated so it is not parsed as Velocity — and should render
identically to the same text in a WYSIWYG field.

Actual Behaviour

Story Block JSON is inlined verbatim into the generated contentlet template. Author-typed $ and
# are evaluated as Velocity. Depending on what the author typed this ranges from silently wrong
output to an exception that aborts the contentlet render.

Acceptance Criteria

  • Author-typed $ and # in a Block Editor field are not evaluated as Velocity.
  • A Block Editor field and a WYSIWYG field containing identical text produce equivalent
    rendered output.
  • There is a defined, documented way for an author to include a literal $ or # in Block
    Editor text.
  • The :[]$contents.getEmptyList() substitution continues to work (see constraint
    below), with a test covering a Story Block field that has both an empty relationship
    collection and author-typed $/# in the same value.
  • Existing Story Block rendering (marks, headings, lists, tables, dotContent / dotImage /
    dotVideo nodes, gridBlock nesting) is unchanged.

Constraint for whoever picks this up

This cannot be fixed by blanket-escaping the JSON. The :[]$contents.getEmptyList()
substitution added in June 2025 (e2f5d37642, "Fixing page rendering when a block editor field
contains empty relationship fields") deliberately injects Velocity into this JSON and relies
on it being evaluated. The fix has to separate substitutions dotCMS injects from text the
author typed
, rather than turning evaluation off wholesale.

Note also that UtilMethods.espaceForVelocity (UtilMethods.java:1509) is not sufficient on its
own here — it neutralises ", ## and \, but not a single # followed by a directive name.
The protection the other branches actually rely on is the mergeTemplate indirection.

dotCMS Version

Current Release (dotEvergreen), dotCMS Cloud. Present on main.

Not a recent regression in itself — the unguarded branch dates to the original Story Block work
(c6a46cf4b0, 2021-10-20) — but exposure widened when WYSIWYG fields were migrated to Block
Editor in 5576c53baa (2022-08-30).

Severity

Medium - Some functionality impacted

Links

Related

A second, independent defect surfaced by the same customer report is being tracked separately:
DotParse.resolveFileAsset (DotParse.java:281) does not guard indexOf('/'), so a malformed
// path throws StringIndexOutOfBoundsException, which is rethrown as DotStateException and
escapes the recovery path in DotDirective.render — blanking the entire contentlet rather than
just the failed include. That defect is what turns this one into a blank page rather than a
visibly wrong string. It is still being confirmed and is not in scope here.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in ContentletLoader.java at buildVelocity(), especially the valid Story Block JSON branch around lines 182-190, and compare it with the guarded author-text branches. Trace the generated .contentlet template and verify that author-typed $ and # are not evaluated while the :[] substitution and existing Story Block rendering remain unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
backend, content
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.