This document describes the security model of the CMS module: how output is escaped, how user-generated content is constrained, the RBAC permission map, CSRF vs bearer authentication, the AkbAI prompt-injection defense, anti-abuse bans, guest email hashing, primary-DB isolation, feature-flag safety, and audit logging.
The overriding principle is safe by default: nothing that affects the live public site is enabled out of the box, all user content is treated as untrusted, all rendered output is escaped, and arbitrary HTML/JS/CSS execution on public pages is never possible.
1. XSS / escaping
- No raw HTML as a source of truth. Public pages are composed of whitelisted, server-rendered components configured with JSON props — never free-form HTML. The page builder cannot inject markup or scripts.
- The escaping contract (
CmsComponentRenderer): the renderer trusts only sanitized props and never echoes raw input; each component viewhtml_escape()s every scalar at output. The sole exception isrich_text, which is already allowlist-sanitized upstream and emitted as-is. <head>SEO values (title, description, canonical, OG, Twitter, structured data) are escaped at emission; structured data is emitted as a typed JSON blob.- Admin DataTables escape every cell (
html_escape) and build action links with the framework anchor helpers.
2. The sanitizer allowlist
CmsBlockSanitizer is the single coerce-and-escape layer. sanitize_props() walks
a component definition's fields[] and sanitizes each prop strictly by its
declared type (config/cms.php field_types is the allowlist). Unknown/disabled
field types are dropped (fail-closed). Per type:
text/textarea→strip_tags+ trim + clamp tomax_length.rich_text→sanitize_rich_text: kills<script>/<style>/<iframe>/<object>/ <embed>/<form>/<svg>/<math>(tag + body) and HTML comments, reduces to the configuredallowed_html_tags(p, strong, em, ul, ol, li, a, h2–h4, blockquote, br), scrubs attributes toallowed_html_attributes(href, title, target, rel), drops allon*handlers and inlinestyle, rejectsjavascript:/vbscript:/data:(and obfuscated) URL schemes, and forcesrel="noopener noreferrer"on external links.url→ validates scheme; rejectsjavascript:/vbscript:/data:; allows site-relative, anchors,mailto:/tel:, or valid absolute http(s).number→ int/float cast;boolean→ 0/1.select/multi_select/color_token→ constrained to declared options (color also constrained to the design-token allowlist — never raw CSS).media→ positive int id only.repeater→ recurses, sanitizing each row against the sub-field schema.
URL-scheme detection is hardened against whitespace/control-character and entity obfuscation.
3. RBAC permission map
The CMS uses ERPat's existing RBAC (flat, underscore permission keys) — it does
not introduce a second permission system. The plan's dotted keys are mapped to
ERPat flat keys in config/permissions.php. The base content permission equals
the module slug and gates the entire admin controller (with_module('content') +
with_permission('content')); admins bypass all checks.
| Permission | Gates |
|---|---|
content |
Base — whole admin controller; dashboard; restore-from-trash |
content_create / content_update / content_delete |
Create / edit / trash content (auto-generated by the module level) |
content_publish |
Publish / unpublish / schedule |
content_rollback |
Manage revisions + roll back |
content_taxonomy |
Categories & tags |
content_media |
Media library |
content_templates |
Templates |
content_components |
Manage component definitions/instances |
content_components_sync |
Sync / upgrade / roll back component packages |
content_comments_moderate |
Moderate comments & reactions |
content_comments_override_ai |
Override an AkbAI comment verdict |
content_bans |
Manage CMS user bans |
content_akbai |
Configure AkbAI comment safety |
content_routes_assign |
Assign homepage / module pages |
content_routes_activate |
Activate / roll back route assignments |
content_seo |
SEO, redirects, sitemap |
content_lighthouse |
Run Lighthouse / PageSpeed |
content_analytics |
View analytics |
content_settings |
Settings & integrations |
content_eu_comment / content_eu_react |
End-User API write permits |
Separation of duties: publishing (content_publish) is distinct from route
assignment (content_routes_assign + content_routes_activate) — an editor can
publish a post without being able to replace the homepage. Overriding an AI verdict
requires a separate permission from ordinary moderation. Every admin JSON endpoint
re-checks its permission (with_permission(.., 'no_permission') echoes a JSON error
and exits).
4. CSRF (web) vs bearer (API)
Three distinct surfaces with three distinct protections:
- Staff admin (
Content_admin) — session auth + ERPat's global CSRF middleware on every POST. - Public web POST (
Content::comment/react/track_view) — the public layout renders the native CodeIgniter CSRF token for AJAX; these POSTs are CSRF-protected by the global CSRF middleware. - Module-owned APIs — stateless bearer auth, so session CSRF does not apply
(consistent with the rest of
/api/v1and/v1/api):- Client API (
Content_api): bearer +X-Company-Key, scopecms:read. - End-User API (
Content_euapi): per-user HS256 JWT (sub, self-only). Writes enforce an HTTP-method guard (405 on the wrong verb) instead of CSRF.
- Client API (
5. AkbAI prompt-injection defense (comment-as-data)
When AkbAI comment scanning is enabled (cms.akbai_comment_scan_enabled, off by
default), every comment is classified before it can become public, with
structural prompt-injection safety:
- The untrusted comment is sent only as a
role:userdata message; the fixed classifier task lives inrole:system. A comment can therefore never rewrite the classifier's instructions — injection safety is structural, not heuristic. - The system prompt additionally instructs the model to treat manipulation attempts ("ignore previous instructions", "you are now", role-play, system impersonation, encoded payloads) as a prompt-injection signal that raises the score/severity — it never obeys instructions embedded in the comment.
- Input is truncated to a bounded size before sending; an upstream
finish_reason=content_filtershort-circuits to a hard block before any parsing. - AkbAI never blocks submission. On downtime/timeout/parse failure the pipeline degrades to a deterministic local heuristic (block terms + a blatant injection-phrase check + link/spam heuristics), and ultimately the comment is held as pending for the human queue. Submission never fails.
- Modes:
advisory(default) never hard-blocks (suspicious → flagged/held);strictblocks clearly prohibited content;offskips AI entirely. - The optional AI auto-reply (
cms.akbai_comment_reply_enabled, off) is a separate constrained call given only a topic summary (never the raw comment), and the generated reply is itself re-scanned before posting.
Public-visibility invariant: a comment is shown publicly only when it is
approved on both status and moderation_status. A blocked verdict parks it
as spam; a flagged verdict holds it as pending. Every verdict is recorded in
cms_comment_moderation_logs with provider, severity, score and categories.
6. Anti-abuse temporary bans
CmsBanService enforces commenter/reactor bans:
- Repeat offenders (threshold
cms.comment_ban_threshold, default 3) can be banned for thecommentand/orreactionscope. - Bans key on the logged-in
user_idor, for guests, an HMAC-SHA256 visitor hash of IP+UA (peppered with a domain-separation constant) — never a bare IP. The raw IP/UA are never stored. - Duration defaults to
cms.comment_ban_default_hours(24h), clamped tocms.comment_ban_max_days(30d);hours <= 0means permanent. - Banned identities are rejected on both the web POST path and the End-User API
writes (403
banned) before any rate limit or scan. - Bans expire automatically (
content_expire_banscron, hourly) and can be lifted manually. Apply/lift writes a moderation log row and an audit event (cms.user.banned).
Comment submission also runs a per-action rate limit (atomic with the ban re-check) and a hidden honeypot field (a non-empty value is silently accepted without storing anything).
7. Guest email hashing
Guest comments never persist a plaintext email. The email is hashed server-side on
submit (visitor_email_hash, an HMAC) and only the hash is stored in
cms_comments; the raw value is dropped before the request ends and is never passed
to async jobs, caches, or logs. The same privacy-preserving hashing applies to
view-tracking analytics (only ip_hash / user_agent_hash / referrer_hash are
stored) and to guest reaction de-duplication (visitor_id + ip_hash). The APIs
never expose any email or hash — only display names.
8. Primary-DB isolation & feature-flag safety
-
Primary-DB isolation. All
cms_*tables live in the primary database only (notenant_id), enforced byCms_base_modelpinning thedefaultconnection. Public content is global and shared; public writes (no tenant session) and admin reads (a tenant session) can never diverge. Migrations are a no-op on tenant connections. -
Feature-flag safety — homepage/module override. Replacing
/or/communityrequiresCmsRouteResolver::can_override()to pass, re-checked every request (nothing cached):- master flag
cms.route_overrides_enabledon, and - the route-specific flag on (
cms.homepage_override_enabledforsite.home;cms.module_overrides_enabledformodule.*; an unmapped route can never override), and - an
activeassignment row withsource_type='cms_page', and source_idpointing to a page that ispublishedright now.
Any miss → the existing system controller renders. There is no web-server/router hijacking and no stale render: if the assigned page is unpublished/deleted/made non-public, the very next request silently falls back. Assigning and activating are separate permissions/steps, and every change is reversible (each assignment keeps a previous-state snapshot for rollback; deactivate restores the system default instantly). All defaults ship off.
- master flag
-
Public surface gating. The whole public surface is gated by
cms.enabledand themodule_contentmaster toggle; when off, every public route 404s.
9. Media upload security
CmsMediaService validates uploads by server-side MIME inspection (not the
extension alone), restricts to an image MIME/extension allowlist, enforces a size
cap (10 MB default) and max dimensions, sanitizes filenames, generates a safe
storage path, and prevents script/PHP execution. Image variants are generated for
public delivery.
10. Audit logging
CmsAuditService wraps ERPat's set_system_logs(); cms.* events are registered
in application/config/system_logs.php. Audited actions include (non-exhaustive):
- Content lifecycle:
cms.content.created/updated/published/unpublished/scheduled/ trashed/restored/rolledback. - Media:
cms.media.uploaded/updated/deleted. - Components:
cms.component.created/updated/synced/upgraded/rolledback. - Routing:
cms.route_assignment.updated/activated/deactivated(+ rollback). - Moderation & bans: comment moderation decisions and
cms.user.banned.
Each event captures actor, before/after state and contextual remarks, giving a complete, reviewable trail of every publish and public-routing change.
Summary
- All public output is escaped; no raw HTML/JS/CSS execution is possible.
- User content is constrained to an allowlisted, type-checked field schema.
- RBAC reuses ERPat's system with separation of duties for high-impact actions.
- Web POSTs use CSRF; bearer APIs use scope/permit auth with method guards.
- AkbAI treats comments as data (structural prompt-injection safety) and never blocks submission; the public-visibility invariant holds end to end.
- Abuse is contained by privacy-preserving (hashed) temporary bans, rate limits and a honeypot.
- Guest emails are hashed and never persisted in plaintext.
- All CMS data is isolated to the primary DB; every live-affecting capability is off-by-default, gated, reversible, and audit-logged.