Security
CSRF
AJAX endpoints (/_content-blocks/* by default) require an X-CSRF-Token header bound to the token id content_blocks. Stimulus controllers read it from a data-cb-csrf-token attribute rendered by the bundle. Your app needs:
framework.session: true(CSRF tokens are session-bound)framework.csrf_protection.enabled: true
Symfony 7.x
Symfony 7.x defaults CSRF to stateless — the token id content_blocks falls through to the session-based fallback automatically, so no extra config is needed beyond enabling CSRF and the session.
Firewalls & access control
The bundle exposes two URL families with different exposure:
| Path prefix | Audience | Mode |
|---|---|---|
/_content-blocks/public/* | Anyone (loaded inside the public iframe) | Public |
/_content-blocks/* (everything else) | Authenticated admin (block CRUD, section CRUD, sidebars, upload) | Admin-only |
The public sub-prefix is intentional: it lets you lock the admin endpoints down without breaking the iframe's CSS and overlay JS.
Simplest: mount the admin endpoints under your admin path
Both families can be mounted wherever you like. With the builder endpoints under /admin/content-blocks, a ^/admin firewall or access_control rule already covers them, and none of the patterns below is needed. See Mounting the routes.
The rest of this section assumes the default mount.
With a single firewall, an access_control split is enough:
# config/packages/security.yaml
security:
access_control:
- { path: ^/_content-blocks/public, roles: PUBLIC_ACCESS }
- { path: ^/_content-blocks, roles: ROLE_ADMIN }With separate admin and front-office firewalls, extend the admin firewall's pattern to cover the admin endpoints (and exclude the public sub-prefix), otherwise the builder's AJAX calls run unauthenticated:
security:
firewalls:
admin:
pattern: ^/(admin|_content-blocks(?!/public))
# ...
main:
# public site — handles the iframe URL, no admin auth here
pattern: ^/When the session expires
A form-login firewall answers an unauthenticated request with a redirect to the login page. For the builder's own calls, the bundle turns that redirect into a 401 carrying {"error": "session_expired"} and the X-Content-Blocks-Session: expired header, so the builder can tell a lost session from a save. The editor sees a Session expired banner with a link that opens the page again in a new tab. Their unsaved edit stays on screen and is sent again once they are logged back in. Full-page navigations are left alone and still reach your login form.
Nothing to configure. The builder does not keep the session alive: your session lifetime still applies.
Cross-firewall auth detection
The render template auto-detects preview mode by calling AccessCheckerInterface::canEdit() while serving the public URL — i.e. the request passes through the public/main firewall, but the user authenticated against the admin firewall. With separate firewall contexts (context: admin), Symfony's standard Security::isGranted() will not see the admin token from the main firewall and the iframe falls back to public mode (no editing UI, even when an admin opens the builder).
If your firewalls use isolated contexts, the access checker has to read the admin token directly from the session:
use ContentBlocks\Security\AccessCheckerInterface;
use ContentBlocks\Entity\ContentArea;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
final class PageAccessChecker implements AccessCheckerInterface
{
public function __construct(
private readonly TokenStorageInterface $tokens,
private readonly RequestStack $requests,
) {}
public function canEdit(ContentArea $contentArea): bool
{
return $this->isAdmin() && $this->ownsArea($contentArea);
}
private function isAdmin(): bool
{
// 1) Standard path: a token is in the current firewall's storage.
$token = $this->tokens->getToken();
if ($token && \in_array('ROLE_ADMIN', $token->getRoleNames(), true)) {
return true;
}
// 2) Cross-firewall fallback: the iframe runs under the public
// firewall, so the admin token isn't visible via $tokens. Read
// the serialized admin token from the session directly. The key
// is `_security_<context_or_firewall_name>` — `_security_admin`
// when `context: admin` or the firewall name is `admin`.
$request = $this->requests->getMainRequest();
if (!$request || !$request->hasSession()) {
return false;
}
$serialized = $request->getSession()->get('_security_admin');
if (!\is_string($serialized)) {
return false;
}
$adminToken = unserialize($serialized);
return $adminToken instanceof TokenInterface
&& \in_array('ROLE_ADMIN', $adminToken->getRoleNames(), true);
}
private function ownsArea(ContentArea $area): bool
{
// your app's ownership check
}
}See host services for the base AccessCheckerInterface contract, and Rendering for how preview mode depends on it.
Block data sanitization
The block's form is the whitelist + validator
A block's data is never written raw. BlockComponent::persistDraft() submits the form built by the block's buildForm(), and only on success writes $form->getData() to the draft.
Two guarantees fall out of this:
- Key whitelist — the compound form only maps its declared children, so an unexpected key in the POST is dropped; it never reaches
data. - Value validation — each field's
constraints(e.g.Assert\Choice,Assert\Length) run on submit, and a value that fails them is never written: the field keeps its stored value, while the valid fields of the same form still save. An error on the form itself (a constraint across fields) writes nothing. Nested collections validate via theirentry_type's own constraints.
There is no getAllowedDataKeys() / sanitizeData() / processData() hook — a custom block secures its data purely by what it declares in buildForm() (fields + constraints). The kit's AbstractKitBlock::choiceConstraint() derives an Assert\Choice from the field's full coded choice set for exactly this reason.
The clipboard goes through the same door
Copy/paste stores its entry in the browser's localStorage, which is what lets a copy survive leaving the page — and what makes the payload user-writable. So a paste is not a restore: every block in it is replayed through its own form (ContentBlocks\Clipboard\BlockDataReplayer) before anything is written. A key your block type does not declare never reaches Block.data; a value your constraints refuse is reset to the type's default and reported to the editor, rather than costing the whole block.
This is stricter than the two older restore paths (section-template insert, area import), and deliberately so: they keep block data verbatim — unknown keys warn instead of dropping, and collection-entry ids survive so translations still match. An import file is still input, though, so what those paths keep is held elsewhere: the structure is checked on arrival (below), and the kit's views guard HTML, links and colours at render (Editor HTML and links). Nothing extra to do in a custom block — the form you already wrote is the filter for everything typed in the builder.
Restored structure
An import, a section template and a pasted section all rebuild sections, columns and blocks from a payload. The structure is checked on the way in: a section layout is kept only if this install's SectionLayoutRegistry knows it (otherwise the section is full), a column preset only if it is col-1 … col-12 (otherwise col-12), and a block without a string type is dropped. Column settings go through ColumnSettings::sanitize().
Size is bounded too, since each block is replayed through its form: an import or a pasted section holding more than 1 000 sections, 20 columns in one section or 5 000 blocks is refused (RestoredStructure::MAX_*) before any file is stored, and a paste body over 5 MB answers 413.
Raw-HTML caveat
The kit's html_raw block renders 0, so it trusts its editors. It is disabled by default (content_blocks_kit.blocks.html_raw.enabled: false) and must be explicitly opted in.
Editor HTML and links
Some stored values reach a page without passing through the block's form — an import, a section template, a translation, machine-translation output — so the kit's views guard them again at render:
rich_textis sanitized. Its HTML goes throughcb_kit_rich_html, backed bysymfony/html-sanitizer: the W3C safe elements,class, and astyleattribute reduced to formatting properties (colour, alignment, sizes, margins — no positioning, nourl()). Script, event handlers,<iframe>and unsafe link schemes are removed. To sanitize differently, redefine the servicecontent_blocks_kit.rich_text_sanitizerwith anyHtmlSanitizerInterface.- Links keep safe schemes only. Every kit link (button, button group, image, gallery, card, breadcrumb) goes through
cb_kit_safe_url: http(s),mailto:,tel:or a scheme-less URL. Anything else —javascript:,data:— renders as no link (#for a button). The form refuses the same values on save (SafeLinkConstraint). A custom block with a link field can use both.
html_raw stays the one block that renders editor HTML untouched.
File upload
The upload endpoint (content_blocks_upload) checks the CSRF token, canEdit() on the area posted with the file (area, sent by the builder's upload widgets from data-cb-area-id on the shell), the size (content_blocks.upload.max_size) and the MIME type sniffed from the file (content_blocks.upload.allowed_mime_types) before handing it to your FileStorageInterface.
An import writes files too, and goes through the same checks: each file's MIME type is sniffed from its bytes and checked against the same list, and its stored extension is derived from that type. The mimeType and extension written in the export are ignored, so a forged export cannot place a .php or .html file under your public upload prefix. Each file is also hashed on arrival and kept only if it is one the export lists, and the content is resolved only against files the server itself checked, so a forged manifest cannot point a block at a file of its choosing. A refused file stops the import before the content is written.
image/svg+xml is not in the default list. An SVG can carry script, which runs when the file is opened directly from your domain — stored XSS on your own origin. Add it to allowed_mime_types only if your editors are fully trusted, and then serve the upload directory with Content-Security-Policy: sandbox (or script-src 'none').
Stored paths are confined
A stored file path is editor input: the image widget accepts a pasted path, and any text field can hold a string that starts with your public prefix. The exporter reads every such path into the zip, so a path must never resolve outside the upload directory. LocalFileStorage refuses ./../empty segments, backslashes and NUL bytes, then requires the resolved real path to be a file under the real upload directory — a symlink pointing out is not followed.
If you aliased FileStorageInterface to your own implementation, hold read(), remove() and isStoredPath() to the same rule. Flysystem already refuses path traversal by default (PathTraversalDetected).
Access denials and error messages
A refused canEdit() throws ContentBlocksAccessDeniedException, an AccessDeniedHttpException: the kernel answers 403, not 500. An import answers the reason of its own refusals (ImportRefusedException); any other exception during an import answers a generic message, so a library's text (an ORM entity dump, say) never reaches the client.
Preview responses
A request carrying cb_preview=1 holds the draft, so its response is sent Cache-Control: private, no-store and, unless your app already set one, X-Frame-Options: SAMEORIGIN (PreviewResponseListener). The builder frames the preview from the same origin, which that allows. The i18n workbench page sends the same two headers.
The builder shell itself is rendered inside your admin page, so its framing policy is yours: send X-Frame-Options: SAMEORIGIN (or Content-Security-Policy: frame-ancestors 'self') on the admin pages that include it, or a hostile page could frame Publish and Delete under a visitor's click.
Editor scripts from a CDN
With cdn: true (the default), the kit loads TinyMCE (7.9.3) or CKEditor 5 (48.3.1) from a pinned version with a Subresource Integrity hash: if the CDN served different bytes, the browser refuses them. A script_url or style_url of your own carries no hash — it is your file. To avoid the CDN altogether, set cdn: false and bundle the editor.
Content Security Policy
The packages run under a strict policy, with one allowance: inline style attributes. A section's spacing, a column's span or a gallery's column count is written as style="--cb-…" on the element itself, because it belongs to that element's stored data.
On the public page:
default-src 'self';
script-src 'self';
style-src 'self';
style-src-attr 'unsafe-inline';
img-src 'self' data:;
frame-src https://www.youtube.com https://player.vimeo.com;style-src-attrlets the attributes through while<style>elements and stylesheets stay locked to your origin. A browser without CSP Level 3 ignores it and falls back tostyle-src: there, add'unsafe-inline'tostyle-srcinstead.- Scripts are files served by your own routes (the slider, the preview overlay), never inline. The preview's data travels in
<script type="application/json">blocks, which are never run, so it needs no nonce. frame-srcis for the kit'sembedblock; drop it if you disabled that block. Add your image or video CDN toimg-srcandmedia-srcif yourImageUrlResolverInterfaceor uploads point there.
In the admin page hosting the builder, add:
frame-src 'self'(the preview is a same-origin iframe), andframe-ancestors 'self'(see Preview responses);- for the kit's rich text with
cdn: true, the editor's CDN inscript-srcandstyle-src(https://cdn.jsdelivr.netfor TinyMCE,https://cdn.ckeditor.comfor CKEditor), and whatever the editor itself requires: both inject styles into their UI, see their CSP notes. Withcdn: falsethe editor is one of your own bundles and needs nothing extra.
The read-only asset report page (content_blocks_asset_report) carries an inline <style>; it is an admin page and never shown to visitors.