Concrete CMS Security
Operational reference for writing or reviewing Concrete CMS PHP. Read top-to-bottom for a new task; jump to Danger signals or Review priorities when triaging an existing PR.
Structure
- Danger signals — grep patterns demanding deeper inspection
- Review priorities — order to inspect a PR, by risk-per-minute
- Critical patterns (C1–C7) — RCE / privesc / auth bypass
- High-frequency patterns (H1–H7) — XSS, CSRF, IDOR, SSRF, etc.
- Pre-flight checklist
- Threat model & history — actors and recurring patterns
Sections 1–5 are the working reference. Section 6 is evidence.
Skip this skill only for documentation/marketing copy, frontend-only changes that add no endpoints, or read-only review without modification.
Danger signals
Stop and apply the linked rule if any of these appear.
RCE class:
| Signal |
Rule |
unserialize( |
C1 |
phar://, file://, data://, gopher:// |
C1, H4 |
include $x, require $x (variable path) |
C3 |
move_uploaded_file( |
C2 |
uniqid( used for security/paths |
C2 |
exec, shell_exec, system, passthru, backticks |
— block entirely |
Auth / privilege class:
| Signal |
Rule |
$request->request->all(), raw $_POST to update |
C7 |
File::getByID(, Page::getByID(, Event::getByID(, Express*::getByID( |
H3 |
canAccess() before a mutation |
C5 |
== in auth/password/token/guard code |
C6 |
=== true/false on a JSON-decoded field |
C6 |
OAuth callback w/o state, session regen, or uIsActive |
C7 |
| Password/email/2FA change w/o current-password re-verify |
C7 |
CSRF / state-change class:
| Signal |
Rule |
GET routes named delete, download, do_update, star, approve*, rescan, install_*, add/removeFavorite* |
H2, C4 |
Mutating controller missing token->validate(...) |
H2 |
| Token emitted in view but never validated |
H2 |
if ($valid) throw; else proceed on token check |
H2 — inverted |
XSS / output class:
| Signal |
Rule |
<?= $var ?> or <?php echo $var without h() in template |
H1 |
t('format %s', $userInput) (translation as format string) |
H1 |
href="<?= $url ?>", value="<?= $x ?>" without attribute escape |
H1 |
| User-controlled "numeric" field interpolated without cast |
H1 |
File / permission / SSRF / path class:
| Signal |
Rule |
mkdir( without explicit permissions argument |
H6 |
chmod(..., 0777) |
H6 |
file_get_contents($userUrl), Guzzle/cURL on user URL |
H4 |
| Redirects followed on server-side fetch without re-validation |
H4, C2 |
| ZIP/archive extraction without per-entry traversal check |
C2, C3 |
String field concatenated into a $path that is read/written/included |
C3 |
realpath() not called before opening a constructed path |
C3 |
.. filtering done before URL/path decoding |
C3 |
High-risk subtrees (extra scrutiny for any new code here):
concrete/controllers/dialog/ ← 2026 CSRF cluster
concrete/controllers/backend/file/ ← CSRF + IDOR
concrete/controllers/single_page/dashboard/extend/ ← CSRF→RCE (marketplace)
concrete/controllers/single_page/dashboard/system/update/ ← CSRF→RCE (core)
concrete/controllers/frontend/conversations/ ← IDOR cluster
concrete/blocks/express_entry_*/ ← unserialize RCE × 2
concrete/blocks/{page_list,search,switch_language,form}/ ← XSS history
authentication/oauth/ ← state, session, uIsActive
src/File/{Importer*,Service/} ← extension-only validation, races
Review priorities
Inspect in this order; flag at first failure rather than skimming the whole diff.
- New/modified controller actions — HTTP method, what it mutates, CSRF token, permission check, blast-radius match. → C4, C5, H2
- GET routes that write/delete/install/update/approve/download — should be POST + token. → H2, C4
- Object loads by integer ID — per-object
canView*() / canEdit*() before return/mutate. → H3
- File upload handlers — content/magic-byte validation before
move_uploaded_file; non-executable destination; no uniqid() for security; redirects disabled. → C2
- Template/path/filename inputs — whitelist,
../null-byte/scheme rejection after decoding, realpath() confinement. → C3
unserialize(, dynamic include/require, stream wrappers — any of these reading DB/request/config = block the PR. → C1, C3
- Template output — every interpolation through
h() or Twig, including attribute values and translation arguments. → H1
- OAuth / auth callbacks —
state, session regen, uIsActive, current-password re-verify, field whitelist. → C7
- URL fetches — private/metadata IP block, DNS rebinding, scheme restrictions, redirect handling. → H4
- Auth/guard comparisons —
=== or hash_equals(); source-of-request validation. → C6
- Account/permission mutations — action-specific permission, not just
canAccess(). → C5, C7
composer.json/composer.lock changes — new deps inherit their CVE history. → H7
Critical patterns (CVSS 7+ outcomes)
C1. Never unserialize() data from DB, request, or block config
// DON'T
$config = unserialize($this->btTable_filterFields);
// DO
$config = json_decode($this->btTable_filterFields, true);
if (!is_array($config) || !isset($config['expected_key'])) {
throw new \UnexpectedValueException('Invalid config');
}
- No
unserialize() on values from DB, request, files, or any admin/attacker-influenced channel.
json_decode() against a known schema; validate structure before use.
phar:// also deserializes — filter phar: from any path passed to file_exists, is_file, fopen, include.
- Block-config columns holding serialized PHP (
btTable_filterFields, btTable_columns) are smells. Migrate to JSON.
- If migration is impossible:
unserialize($data, ['allowed_classes' => false]), then validate.
C2. Validate uploads by content before writing; never trust extension
// DON'T — race window: attacker can execute before unlink
move_uploaded_file($_FILES['f']['tmp_name'], $dest);
if (!$this->extensionAllowed($dest)) { unlink($dest); }
// DO — validate magic bytes / mime before any write
if (!in_array(mime_content_type($_FILES['f']['tmp_name']), $allowedMimes, true)
|| !$this->validateMagicBytes($_FILES['f']['tmp_name'])) {
throw new \RuntimeException('Rejected');
}
move_uploaded_file($_FILES['f']['tmp_name'], $nonExecutablePath);
- Reject by content type and magic bytes, not extension.
.png containing <?php must fail at validation, not get cleaned up after.
- Validate before bytes hit any path under webroot,
application/, or DIR_PACKAGES.
- No
uniqid() for security-sensitive paths — predictable. Use bin2hex(random_bytes(16)).
- Disable redirects on server-side fetches that will be unpacked/executed; validate final URL.
- ZIP/archive extraction: validate every entry name for traversal before extracting (zip-slip).
C3. Whitelist any input becoming a file path or template name
// DON'T
include DIR_BASE . '/application/blocks/' . $type . '/' . $template . '.php';
// DO
if (!preg_match('/^[A-Za-z0-9_-]+$/', $template)) {
throw new \InvalidArgumentException('Invalid template name');
}
$base = DIR_BASE . '/application/blocks/' . $type;
$full = realpath($base . '/' . $template . '.php');
if ($full === false || strpos($full, realpath($base) . DIRECTORY_SEPARATOR) !== 0) {
throw new \RuntimeException('Path escapes base');
}
include $full;
- Whitelist
[A-Za-z0-9_-]. Reject .., absolute paths, null bytes, stream wrappers — after URL/path decoding (..%2f, ..%252f are real).
realpath()-confirm the result is under the intended root before opening.
- Template-name fields are code, not data — the framework will include the resolved path.
C4. Package/marketplace/update endpoints need CSRF tokens, even on GET
// DO
public function do_update() {
if (!$this->canUpgrade()) throw new UserMessageException(t('Access Denied'));
$token = $this->app->make('token');
if (!$token->validate('do_core_update')) {
throw new UserMessageException($token->getErrorMessage());
}
$this->performCoreUpdate($this->request->request->get('version'));
}
- Any route that fetches, installs, or executes code from a remote identifier (marketplace ID, package handle, version) is a CSRF-to-RCE target by default.
- Routes writing under
DIR_PACKAGES or upgrading core MUST validate a token. Permission alone is insufficient.
- Reject GET where possible; require POST with action-scoped token.
C5. Authorization must match the action's blast radius
// DON'T — page-view permission gating a privilege-escalating mutation
if (!$this->canAccess()) throw new UserMessageException(t('Access Denied'));
$this->addUsersToGroup($_POST['users'], $_POST['gID']);
// DO — permission scoped to what's being granted
$group = Group::getByID((int) $this->request->request->get('gID'));
if (!$group || !$this->currentUserCanAssignToGroup($group)) {
throw new UserMessageException(t('Access Denied'));
}
// ... plus CSRF token check ...
- Page-view permission ≠ mutate permission. Each action method needs a check matching what it can do.
- Adding to Administrators requires Administrator-level authority.
- Group-membership, permission-grant, and user-role changes are privilege-escalation-adjacent — check must be at least as strict as the privilege granted.
C6. === and hash_equals(); validate request source, not just field values
// DON'T — json_decode("true") returns PHP true, defeating the guard
if ($request->request->get('_fromCIF') === true) { /* skip validation */ }
// DO — validate the source (route, internal caller, auth context)
if ($this->isInternalCIFImport() && $this->authenticatedCaller()) { /* ... */ }
// constant-time for opaque tokens
if (hash_equals($expectedToken, $providedToken)) { /* ... */ }
=== / !== everywhere in auth, authorization, guard code, and token checks.
hash_equals() for opaque tokens (OAuth state, CSRF, password hashes, API keys).
- Validate request source — controller path, HTTP method, authenticated caller — not just a flag in the body.
C7. Re-auth for credential changes; uIsActive on every auth path
// DO — field whitelist plus current-password re-verify
$fields = $this->request->request->only(['uFirstName', 'uLastName', 'uTimezone']);
if ($this->request->request->has('uPassword')) {
if (!$this->verifyCurrentPassword($this->request->request->get('uPasswordCurrent'))) {
throw new UserMessageException(t('Current password required'));
}
$fields['uPassword'] = $this->request->request->get('uPassword');
}
$this->getUserInfo()->update($fields);
- Whitelist accepted fields. Never
$request->request->all() to a model update.
- Password / email / 2FA / session-hardening changes need current-password (or fresh factor) re-verify server-side.
uIsActive checked at every auth path: username/password, OAuth, API tokens, "remember me", password-reset, SAML.
- OAuth callbacks specifically: validated
state, regenerated session ID post-auth, uIsActive before issuing tokens, escaped integration metadata in views.
High-frequency patterns (bulk of CVE volume)
H1. HTML-escape every user-controllable string on output
// DON'T
<h2><?= $integration->getName() ?></h2>
<a href="<?= $url ?>">link</a>
<?= t('Welcome %s', $userName) ?>
// DO — h() everywhere; Twig auto-escapes
<h2><?= h($integration->getName()) ?></h2>
<a href="<?= h($url) ?>">link</a>
<?= t('Welcome %s', h($userName)) ?>
h() (or app('helper/text')->entities()) on every user-controllable string in PHP templates.
- Prefer Twig for new templates — auto-escapes by default.
- Translation helpers do NOT sanitize. Escape input before it enters a format string.
- Attribute values need escaping (
href, value, data-*, style).
- Numeric-looking fields aren't safe — cast or validate types explicitly.
- Any name/title/label field an editor controls is a potential XSS payload.
H2. CSRF token on every state-changing request
// DO
public function delete() {
if (!$this->canEditPages()) throw new UserMessageException(t('Access Denied'));
$token = $this->app->make('token');
if (!$token->validate('delete_pages')) {
throw new UserMessageException($token->getErrorMessage());
}
$this->deletePages($this->request->request->get('cIDs'));
}
- State-changing GETs are CSRF-vulnerable. Validate a token regardless of HTTP method; prefer POST.
- Emitting a token in the view does not validate it.
- Test by removing the token — the action must fail. Inverted checks are real CVEs.
- Use action-scoped token names (
delete_pages, update_core), not a single global token.
H3. Per-object permission check on every load
// DO
public function view($fID) {
$file = File::getByID((int) $fID);
if (!$file || !(new Checker($file))->canViewFile()) {
return new JsonResponse(['error' => 'Not found'], 404); // not 403
}
return new JsonResponse($file->getJSONObject());
}
- Every controller that loads an object by ID checks the relevant permission before returning or mutating.
- 404 for objects the user can't see (not 403) — avoids existence disclosure.
- Frontend/dialog/REST endpoints have a poor history; checks are not optional there.
- Prefer public-identifier (UUID) URLs over sequential integers. The 9.5.1 Express Entry rewrite did exactly this.
H4. Validate and pin URL fetch destinations
// DO
$url = $this->validateExternalUrl($request->request->get('feed_url'));
$ip = $this->resolveAndValidateIp($url); // reject private/metadata
$body = $this->fetchByIp($url, $ip, ['follow_redirects' => false]);
- Resolve hostname yourself; reject private ranges (RFC1918, link-local, loopback, IPv6 equivalents, IPv4-mapped IPv6, cloud metadata
169.254.169.254).
- Connect using validated IP, not hostname, to defeat DNS rebinding.
- Reject non-decimal-dotted IPs (hex, octal, big-endian integer).
- Disable redirects, or re-validate redirect targets with the same rules.
- Block non-HTTP(S) schemes:
file://, phar://, gopher://, data://, dict://.
- Use
Concrete\Core\Url\Validation utilities added in 9.5.1.
H5. No verbose error handlers; no XXE-able XML in production
- Disable whoops and verbose handlers in production. Catch exceptions at controller boundaries; generic error to client; detail to server log.
- Never include
$_SERVER / $_ENV in network-reachable error output.
- For XML/SVG: refuse external entities (
libxml_set_external_entity_loader), set LIBXML_NONET, disable DOCTYPE if not needed.
H6. Explicit file/directory permissions; never 0777
// DON'T // DO
mkdir($path); mkdir($path, 0755, true);
chmod($f, 0777); chmod($f, 0644);
- Always pass explicit perms to
mkdir, chmod, touch. 0755 dirs, 0644 files.
- If
0777 seems necessary, the fix is changing ownership, not loosening permissions.
H7. Keep dependencies patched
- Upstream CVEs in
composer.lock are real CVEs for the site. Update on the same cadence as core.
- Prefer libraries already in Concrete's tree over pulling new packages.
- Watch in particular: Guzzle, Symfony components, league/oauth2-server, enshrined/svg-sanitized, Laminas/Zend Mail.
Pre-flight checklist
Answer each with "yes" or "not applicable" before submitting:
- State change? CSRF token validated (not just emitted), fails closed when missing?
- Object load by ID? Per-object permission check before return/mutate? 404 for hidden objects?
- User-controllable output? Every interpolation escaped — admin strings, attribute values, translation arguments?
- Deserialization or template path? No
unserialize() / dynamic include reachable from DB, request, or admin block config?
- File upload? Content/magic-byte validation before write to webroot-reachable path? No
uniqid() for security?
- Server-side URL fetch? Private/metadata IPs blocked, DNS rebinding mitigated, schemes restricted, redirects disabled or re-validated?
- Account-state change? Current-password verified for credential changes, field whitelist on updates,
uIsActive on every auth path?
- Privilege boundary? Permission matches action blast radius (not page-view)?
=== / hash_equals() in security comparisons?
Threat model & history
Three actor profiles dominate the CVE history:
- Rogue admin / editor — has elevated privileges; explicitly in-scope per Concrete's security team. Admin-supplied strings must still be HTML-encoded; admin-supplied serialized data must not reach
unserialize(); admin-supplied paths must be whitelist-validated.
- Unauthenticated CSRF attacker with an authenticated victim — most chains target admin sessions. Every state-changing request validates a token.
- Unauthenticated outsider hitting a public endpoint — dialog/frontend/REST endpoints have repeatedly leaked data without auth. Every endpoint is unauthenticated until proven otherwise.
Patterns recur — none of these rules are theoretical. Each maps to multiple disclosed CVEs:
- H1 admin-controlled XSS — 30+ CVEs across 2022–2026, every year
- H2 state-change CSRF — 20+ CVEs, mostly batched in 9.5.1 after years of accumulation
- H3 sequential-ID IDOR — 10+ CVEs (flagged CVE-2023-48653, exploded in 9.5.1)
- C7 OAuth + credential re-auth — CVE-2021-40101 + CVE-2026-8327 (password-no-reverify, 5yr gap); CVE-2022-43687 (session fix), CVE-2022-43693 (missing
state), CVE-2026-7887 (missing uIsActive)
- C1 unserialize / PHAR — CVE-2021-40102, CVE-2026-3452, CVE-2026-8135
- C2/C3 file & path RCE — 2021 Fortbridge race +
uniqid, CVE-2022-21829, CVE-2026-8134
- C6 type-juggling guards — CVE-2022-43690 (loose
==), CVE-2026-8135 (json_decode("true"))
- H4 SSRF in URL fetchers — CVE-2021-22969/22970/40109, CVE-2026-7890
- H5 error/XML info disclosure — CVE-2022-43689 (SVG XXE), CVE-2022-43691 (whoops)
Repeat-offender subsystems: Express blocks, OAuth callbacks, marketplace/extend controllers, file uploader, dashboard dialog controllers. Mirror 9.5.1-era post-rewrite versions, not pre-rewrite originals.
1---2name: concrete-cms-security3description: Apply this skill whenever you are writing, reviewing, or modifying PHP code for Concrete CMS (concrete5) — custom blocks, packages, single pages, dashboard or dialog or backend controllers, REST endpoints, Express entities, themes, attribute types, jobs, or anything in concrete/controllers, concrete/blocks, controllers/, blocks/, packages/, src/. Use it even when the user does not say "security" — for example, adding a new endpoint, accepting a POST parameter, rendering a user-supplied string, unserializing block config, fetching a URL server-side, or handling a file upload. Concrete CMS shipped 90+ CVEs over the past four years and the failure modes repeat — admin strings rendered raw, missing CSRF tokens on state-changing GETs, unserialize() on stored data, file uploads validated after writing, sequential IDs without per-object authz, type-juggling guard bypasses, OAuth handlers that skip account-state checks. This skill encodes the patterns behind those CVEs so you do not reproduce them.4---56# Concrete CMS Security78Operational reference for writing or reviewing Concrete CMS PHP. Read top-to-bottom for a new task; jump to **Danger signals** or **Review priorities** when triaging an existing PR.910## Structure11121. **Danger signals** — grep patterns demanding deeper inspection132. **Review priorities** — order to inspect a PR, by risk-per-minute143. **Critical patterns (C1–C7)** — RCE / privesc / auth bypass154. **High-frequency patterns (H1–H7)** — XSS, CSRF, IDOR, SSRF, etc.165. **Pre-flight checklist**176. **Threat model & history** — actors and recurring patterns1819Sections 1–5 are the working reference. Section 6 is evidence.2021Skip this skill only for documentation/marketing copy, frontend-only changes that add no endpoints, or read-only review without modification.2223---2425## Danger signals2627Stop and apply the linked rule if any of these appear.2829**RCE class:**3031| Signal | Rule |32|-------------------------------------------------------|------------------|33| `unserialize(` | C1 |34| `phar://`, `file://`, `data://`, `gopher://` | C1, H4 |35| `include $x`, `require $x` (variable path) | C3 |36| `move_uploaded_file(` | C2 |37| `uniqid(` used for security/paths | C2 |38| `exec`, `shell_exec`, `system`, `passthru`, backticks | — block entirely |3940**Auth / privilege class:**4142| Signal | Rule |43|-----------------------------------------------------------------------------|------|44| `$request->request->all()`, raw `$_POST` to update | C7 |45| `File::getByID(`, `Page::getByID(`, `Event::getByID(`, `Express*::getByID(` | H3 |46| `canAccess()` before a mutation | C5 |47| `==` in auth/password/token/guard code | C6 |48| `=== true/false` on a JSON-decoded field | C6 |49| OAuth callback w/o `state`, session regen, or `uIsActive` | C7 |50| Password/email/2FA change w/o current-password re-verify | C7 |5152**CSRF / state-change class:**5354| Signal | Rule |55|----------------------------------------------------------------------------------------------------------------------|---------------|56| GET routes named `delete`, `download`, `do_update`, `star`, `approve*`, `rescan`, `install_*`, `add/removeFavorite*` | H2, C4 |57| Mutating controller missing `token->validate(...)` | H2 |58| Token emitted in view but never validated | H2 |59| `if ($valid) throw; else proceed` on token check | H2 — inverted |6061**XSS / output class:**6263| Signal | Rule |64|--------------------------------------------------------------------|------|65| `<?= $var ?>` or `<?php echo $var` without `h()` in template | H1 |66| `t('format %s', $userInput)` (translation as format string) | H1 |67| `href="<?= $url ?>"`, `value="<?= $x ?>"` without attribute escape | H1 |68| User-controlled "numeric" field interpolated without cast | H1 |6970**File / permission / SSRF / path class:**7172| Signal | Rule |73|------------------------------------------------------------------------|--------|74| `mkdir(` without explicit permissions argument | H6 |75| `chmod(..., 0777)` | H6 |76| `file_get_contents($userUrl)`, Guzzle/cURL on user URL | H4 |77| Redirects followed on server-side fetch without re-validation | H4, C2 |78| ZIP/archive extraction without per-entry traversal check | C2, C3 |79| String field concatenated into a `$path` that is read/written/included | C3 |80| `realpath()` not called before opening a constructed path | C3 |81| `..` filtering done before URL/path decoding | C3 |8283**High-risk subtrees** (extra scrutiny for any new code here):8485```86concrete/controllers/dialog/ ← 2026 CSRF cluster87concrete/controllers/backend/file/ ← CSRF + IDOR88concrete/controllers/single_page/dashboard/extend/ ← CSRF→RCE (marketplace)89concrete/controllers/single_page/dashboard/system/update/ ← CSRF→RCE (core)90concrete/controllers/frontend/conversations/ ← IDOR cluster91concrete/blocks/express_entry_*/ ← unserialize RCE × 292concrete/blocks/{page_list,search,switch_language,form}/ ← XSS history93authentication/oauth/ ← state, session, uIsActive94src/File/{Importer*,Service/} ← extension-only validation, races95```9697---9899## Review priorities100101Inspect in this order; flag at first failure rather than skimming the whole diff.1021031. **New/modified controller actions** — HTTP method, what it mutates, CSRF token, permission check, blast-radius match. → C4, C5, H21042. **GET routes that write/delete/install/update/approve/download** — should be POST + token. → H2, C41053. **Object loads by integer ID** — per-object `canView*()` / `canEdit*()` before return/mutate. → H31064. **File upload handlers** — content/magic-byte validation before `move_uploaded_file`; non-executable destination; no `uniqid()` for security; redirects disabled. → C21075. **Template/path/filename inputs** — whitelist, `..`/null-byte/scheme rejection *after* decoding, `realpath()` confinement. → C31086. **`unserialize(`, dynamic `include`/`require`, stream wrappers** — any of these reading DB/request/config = block the PR. → C1, C31097. **Template output** — every interpolation through `h()` or Twig, including attribute values and translation arguments. → H11108. **OAuth / auth callbacks** — `state`, session regen, `uIsActive`, current-password re-verify, field whitelist. → C71119. **URL fetches** — private/metadata IP block, DNS rebinding, scheme restrictions, redirect handling. → H411210. **Auth/guard comparisons** — `===` or `hash_equals()`; source-of-request validation. → C611311. **Account/permission mutations** — action-specific permission, not just `canAccess()`. → C5, C711412. **`composer.json`/`composer.lock` changes** — new deps inherit their CVE history. → H7115116---117118## Critical patterns (CVSS 7+ outcomes)119120### C1. Never `unserialize()` data from DB, request, or block config121122```php123// DON'T124$config = unserialize($this->btTable_filterFields);125126// DO127$config = json_decode($this->btTable_filterFields, true);128if (!is_array($config) || !isset($config['expected_key'])) {129 throw new \UnexpectedValueException('Invalid config');130}131```132133- No `unserialize()` on values from DB, request, files, or any admin/attacker-influenced channel.134- `json_decode()` against a known schema; validate structure before use.135- `phar://` also deserializes — filter `phar:` from any path passed to `file_exists`, `is_file`, `fopen`, `include`.136- Block-config columns holding serialized PHP (`btTable_filterFields`, `btTable_columns`) are smells. Migrate to JSON.137- If migration is impossible: `unserialize($data, ['allowed_classes' => false])`, then validate.138139### C2. Validate uploads by content *before* writing; never trust extension140141```php142// DON'T — race window: attacker can execute before unlink143move_uploaded_file($_FILES['f']['tmp_name'], $dest);144if (!$this->extensionAllowed($dest)) { unlink($dest); }145146// DO — validate magic bytes / mime before any write147if (!in_array(mime_content_type($_FILES['f']['tmp_name']), $allowedMimes, true)148 || !$this->validateMagicBytes($_FILES['f']['tmp_name'])) {149 throw new \RuntimeException('Rejected');150}151move_uploaded_file($_FILES['f']['tmp_name'], $nonExecutablePath);152```153154- Reject by content type and magic bytes, not extension. `.png` containing `<?php` must fail at validation, not get cleaned up after.155- Validate before bytes hit any path under webroot, `application/`, or `DIR_PACKAGES`.156- No `uniqid()` for security-sensitive paths — predictable. Use `bin2hex(random_bytes(16))`.157- Disable redirects on server-side fetches that will be unpacked/executed; validate final URL.158- ZIP/archive extraction: validate every entry name for traversal before extracting (zip-slip).159160### C3. Whitelist any input becoming a file path or template name161162```php163// DON'T164include DIR_BASE . '/application/blocks/' . $type . '/' . $template . '.php';165166// DO167if (!preg_match('/^[A-Za-z0-9_-]+$/', $template)) {168 throw new \InvalidArgumentException('Invalid template name');169}170$base = DIR_BASE . '/application/blocks/' . $type;171$full = realpath($base . '/' . $template . '.php');172if ($full === false || strpos($full, realpath($base) . DIRECTORY_SEPARATOR) !== 0) {173 throw new \RuntimeException('Path escapes base');174}175include $full;176```177178- Whitelist `[A-Za-z0-9_-]`. Reject `..`, absolute paths, null bytes, stream wrappers — *after* URL/path decoding (`..%2f`, `..%252f` are real).179- `realpath()`-confirm the result is under the intended root before opening.180- Template-name fields are code, not data — the framework will include the resolved path.181182### C4. Package/marketplace/update endpoints need CSRF tokens, even on GET183184```php185// DO186public function do_update() {187 if (!$this->canUpgrade()) throw new UserMessageException(t('Access Denied'));188 $token = $this->app->make('token');189 if (!$token->validate('do_core_update')) {190 throw new UserMessageException($token->getErrorMessage());191 }192 $this->performCoreUpdate($this->request->request->get('version'));193}194```195196- Any route that fetches, installs, or executes code from a remote identifier (marketplace ID, package handle, version) is a CSRF-to-RCE target by default.197- Routes writing under `DIR_PACKAGES` or upgrading core MUST validate a token. Permission alone is insufficient.198- Reject GET where possible; require POST with action-scoped token.199200### C5. Authorization must match the action's blast radius201202```php203// DON'T — page-view permission gating a privilege-escalating mutation204if (!$this->canAccess()) throw new UserMessageException(t('Access Denied'));205$this->addUsersToGroup($_POST['users'], $_POST['gID']);206207// DO — permission scoped to what's being granted208$group = Group::getByID((int) $this->request->request->get('gID'));209if (!$group || !$this->currentUserCanAssignToGroup($group)) {210 throw new UserMessageException(t('Access Denied'));211}212// ... plus CSRF token check ...213```214215- Page-view permission ≠ mutate permission. Each action method needs a check matching what it can do.216- Adding to Administrators requires Administrator-level authority.217- Group-membership, permission-grant, and user-role changes are privilege-escalation-adjacent — check must be at least as strict as the privilege granted.218219### C6. `===` and `hash_equals()`; validate request *source*, not just field values220221```php222// DON'T — json_decode("true") returns PHP true, defeating the guard223if ($request->request->get('_fromCIF') === true) { /* skip validation */ }224225// DO — validate the source (route, internal caller, auth context)226if ($this->isInternalCIFImport() && $this->authenticatedCaller()) { /* ... */ }227// constant-time for opaque tokens228if (hash_equals($expectedToken, $providedToken)) { /* ... */ }229```230231- `===` / `!==` everywhere in auth, authorization, guard code, and token checks.232- `hash_equals()` for opaque tokens (OAuth `state`, CSRF, password hashes, API keys).233- Validate request source — controller path, HTTP method, authenticated caller — not just a flag in the body.234235### C7. Re-auth for credential changes; `uIsActive` on every auth path236237```php238// DO — field whitelist plus current-password re-verify239$fields = $this->request->request->only(['uFirstName', 'uLastName', 'uTimezone']);240if ($this->request->request->has('uPassword')) {241 if (!$this->verifyCurrentPassword($this->request->request->get('uPasswordCurrent'))) {242 throw new UserMessageException(t('Current password required'));243 }244 $fields['uPassword'] = $this->request->request->get('uPassword');245}246$this->getUserInfo()->update($fields);247```248249- Whitelist accepted fields. Never `$request->request->all()` to a model update.250- Password / email / 2FA / session-hardening changes need current-password (or fresh factor) re-verify server-side.251- `uIsActive` checked at every auth path: username/password, OAuth, API tokens, "remember me", password-reset, SAML.252- OAuth callbacks specifically: validated `state`, regenerated session ID post-auth, `uIsActive` before issuing tokens, escaped integration metadata in views.253254---255256## High-frequency patterns (bulk of CVE volume)257258### H1. HTML-escape every user-controllable string on output259260```php261// DON'T262<h2><?= $integration->getName() ?></h2>263<a href="<?= $url ?>">link</a>264<?= t('Welcome %s', $userName) ?>265266// DO — h() everywhere; Twig auto-escapes267<h2><?= h($integration->getName()) ?></h2>268<a href="<?= h($url) ?>">link</a>269<?= t('Welcome %s', h($userName)) ?>270```271272- `h()` (or `app('helper/text')->entities()`) on every user-controllable string in PHP templates.273- Prefer Twig for new templates — auto-escapes by default.274- Translation helpers do NOT sanitize. Escape input *before* it enters a format string.275- Attribute values need escaping (`href`, `value`, `data-*`, `style`).276- Numeric-looking fields aren't safe — cast or validate types explicitly.277- Any name/title/label field an editor controls is a potential XSS payload.278279### H2. CSRF token on every state-changing request280281```php282// DO283public function delete() {284 if (!$this->canEditPages()) throw new UserMessageException(t('Access Denied'));285 $token = $this->app->make('token');286 if (!$token->validate('delete_pages')) {287 throw new UserMessageException($token->getErrorMessage());288 }289 $this->deletePages($this->request->request->get('cIDs'));290}291```292293- State-changing GETs are CSRF-vulnerable. Validate a token regardless of HTTP method; prefer POST.294- Emitting a token in the view does not validate it.295- Test by removing the token — the action must fail. Inverted checks are real CVEs.296- Use action-scoped token names (`delete_pages`, `update_core`), not a single global token.297298### H3. Per-object permission check on every load299300```php301// DO302public function view($fID) {303 $file = File::getByID((int) $fID);304 if (!$file || !(new Checker($file))->canViewFile()) {305 return new JsonResponse(['error' => 'Not found'], 404); // not 403306 }307 return new JsonResponse($file->getJSONObject());308}309```310311- Every controller that loads an object by ID checks the relevant permission before returning or mutating.312- 404 for objects the user can't see (not 403) — avoids existence disclosure.313- Frontend/dialog/REST endpoints have a poor history; checks are not optional there.314- Prefer public-identifier (UUID) URLs over sequential integers. The 9.5.1 Express Entry rewrite did exactly this.315316### H4. Validate and pin URL fetch destinations317318```php319// DO320$url = $this->validateExternalUrl($request->request->get('feed_url'));321$ip = $this->resolveAndValidateIp($url); // reject private/metadata322$body = $this->fetchByIp($url, $ip, ['follow_redirects' => false]);323```324325- Resolve hostname yourself; reject private ranges (RFC1918, link-local, loopback, IPv6 equivalents, IPv4-mapped IPv6, cloud metadata `169.254.169.254`).326- Connect using validated IP, not hostname, to defeat DNS rebinding.327- Reject non-decimal-dotted IPs (hex, octal, big-endian integer).328- Disable redirects, or re-validate redirect targets with the same rules.329- Block non-HTTP(S) schemes: `file://`, `phar://`, `gopher://`, `data://`, `dict://`.330- Use `Concrete\Core\Url\Validation` utilities added in 9.5.1.331332### H5. No verbose error handlers; no XXE-able XML in production333334- Disable whoops and verbose handlers in production. Catch exceptions at controller boundaries; generic error to client; detail to server log.335- Never include `$_SERVER` / `$_ENV` in network-reachable error output.336- For XML/SVG: refuse external entities (`libxml_set_external_entity_loader`), set `LIBXML_NONET`, disable DOCTYPE if not needed.337338### H6. Explicit file/directory permissions; never 0777339340```php341// DON'T // DO342mkdir($path); mkdir($path, 0755, true);343chmod($f, 0777); chmod($f, 0644);344```345346- Always pass explicit perms to `mkdir`, `chmod`, `touch`. `0755` dirs, `0644` files.347- If `0777` seems necessary, the fix is changing ownership, not loosening permissions.348349### H7. Keep dependencies patched350351- Upstream CVEs in `composer.lock` are real CVEs for the site. Update on the same cadence as core.352- Prefer libraries already in Concrete's tree over pulling new packages.353- Watch in particular: Guzzle, Symfony components, league/oauth2-server, enshrined/svg-sanitized, Laminas/Zend Mail.354355---356357## Pre-flight checklist358359Answer each with "yes" or "not applicable" before submitting:3603611. **State change?** CSRF token validated (not just emitted), fails closed when missing?3622. **Object load by ID?** Per-object permission check before return/mutate? 404 for hidden objects?3633. **User-controllable output?** Every interpolation escaped — admin strings, attribute values, translation arguments?3644. **Deserialization or template path?** No `unserialize()` / dynamic include reachable from DB, request, or admin block config?3655. **File upload?** Content/magic-byte validation *before* write to webroot-reachable path? No `uniqid()` for security?3666. **Server-side URL fetch?** Private/metadata IPs blocked, DNS rebinding mitigated, schemes restricted, redirects disabled or re-validated?3677. **Account-state change?** Current-password verified for credential changes, field whitelist on updates, `uIsActive` on every auth path?3688. **Privilege boundary?** Permission matches action blast radius (not page-view)? `===` / `hash_equals()` in security comparisons?369370---371372## Threat model & history373374**Three actor profiles** dominate the CVE history:3753761. **Rogue admin / editor** — has elevated privileges; explicitly in-scope per Concrete's security team. Admin-supplied strings must still be HTML-encoded; admin-supplied serialized data must not reach `unserialize()`; admin-supplied paths must be whitelist-validated.3772. **Unauthenticated CSRF attacker** with an authenticated victim — most chains target admin sessions. Every state-changing request validates a token.3783. **Unauthenticated outsider** hitting a public endpoint — dialog/frontend/REST endpoints have repeatedly leaked data without auth. Every endpoint is unauthenticated until proven otherwise.379380**Patterns recur — none of these rules are theoretical.** Each maps to multiple disclosed CVEs:381382- **H1** admin-controlled XSS — 30+ CVEs across 2022–2026, every year383- **H2** state-change CSRF — 20+ CVEs, mostly batched in 9.5.1 after years of accumulation384- **H3** sequential-ID IDOR — 10+ CVEs (flagged CVE-2023-48653, exploded in 9.5.1)385- **C7** OAuth + credential re-auth — CVE-2021-40101 + CVE-2026-8327 (password-no-reverify, 5yr gap); CVE-2022-43687 (session fix), CVE-2022-43693 (missing `state`), CVE-2026-7887 (missing `uIsActive`)386- **C1** unserialize / PHAR — CVE-2021-40102, CVE-2026-3452, CVE-2026-8135387- **C2/C3** file & path RCE — 2021 Fortbridge race + `uniqid`, CVE-2022-21829, CVE-2026-8134388- **C6** type-juggling guards — CVE-2022-43690 (loose `==`), CVE-2026-8135 (`json_decode("true")`)389- **H4** SSRF in URL fetchers — CVE-2021-22969/22970/40109, CVE-2026-7890390- **H5** error/XML info disclosure — CVE-2022-43689 (SVG XXE), CVE-2022-43691 (whoops)391392**Repeat-offender subsystems**: Express blocks, OAuth callbacks, marketplace/extend controllers, file uploader, dashboard dialog controllers. Mirror 9.5.1-era post-rewrite versions, not pre-rewrite originals.