Contents How-to Guide Public

ERPat CMS (Content) — Security Posture

ERPat CMS (Content) — Security Posture This document describes the security model of the CMS module: how output is escaped, how user-generated content is constrained, the RBAC…

Guide version: r1 Module version: 1.0.0 Updated: 2026-07-22 Estimated time: 9 min 2 views

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 view html_escape()s every scalar at output. The sole exception is rich_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/textareastrip_tags + trim + clamp to max_length.
  • rich_textsanitize_rich_text: kills <script>/<style>/<iframe>/<object>/ <embed>/<form>/<svg>/<math> (tag + body) and HTML comments, reduces to the configured allowed_html_tags (p, strong, em, ul, ol, li, a, h2–h4, blockquote, br), scrubs attributes to allowed_html_attributes (href, title, target, rel), drops all on* handlers and inline style, rejects javascript:/vbscript:/ data: (and obfuscated) URL schemes, and forces rel="noopener noreferrer" on external links.
  • url → validates scheme; rejects javascript:/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/v1 and /v1/api):
    • Client API (Content_api): bearer + X-Company-Key, scope cms: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.

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:user data message; the fixed classifier task lives in role: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_filter short-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); strict blocks clearly prohibited content; off skips 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 the comment and/or reaction scope.
  • Bans key on the logged-in user_id or, 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 to cms.comment_ban_max_days (30d); hours <= 0 means 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_bans cron, 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 (no tenant_id), enforced by Cms_base_model pinning the default connection. 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 /community requires CmsRouteResolver::can_override() to pass, re-checked every request (nothing cached):

    1. master flag cms.route_overrides_enabled on, and
    2. the route-specific flag on (cms.homepage_override_enabled for site.home; cms.module_overrides_enabled for module.*; an unmapped route can never override), and
    3. an active assignment row with source_type='cms_page', and
    4. source_id pointing to a page that is published right 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.

  • Public surface gating. The whole public surface is gated by cms.enabled and the module_content master 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.
Was this guide helpful?

Report a content problem