fix: harden CMS security controls

This commit is contained in:
Andy Dixon
2026-06-08 17:12:42 +01:00
parent 42fe5b9304
commit a636ecd991
297 changed files with 174942 additions and 7485 deletions
+97
View File
@@ -0,0 +1,97 @@
# NeoCMS Architecture
## Design Goals
NeoCMS adds controlled visual editing to static HTML websites while retaining three properties:
* Public pages remain ordinary HTML files served directly by the web server.
* Supporting state uses files rather than a database service.
* Existing page structure and styling remain under the site's control.
## Request Flow
`cms/index.php` renders the authenticated administration shell. It passes the CSRF token and configured editable class to
`cms/js/cms.js` through metadata tags. The JavaScript client loads public pages into a same-origin iframe, binds editing
behaviour to matching regions, and submits complete serialised documents to `cms/controller/index.php`.
The preview iframe uses `sandbox="allow-same-origin"` without `allow-scripts`. Parent-side JavaScript may still inspect and
edit the DOM, while scripts belonging to public pages cannot reach the administration window, session, or CSRF token.
`CMSController` is the single API dispatcher. It authenticates every request and delegates mutating actions through
`requirePost()`, which enforces POST, CSRF validation, and the requested role capability.
Image uploads use the separate `cms/image_upload.php` endpoint because TinyMCE sends multipart file data. It applies the
same authentication, role, and CSRF requirements before validating image bytes.
## Storage Model
Published content stays in the web document root. NeoCMS stores supporting data beneath `cms/data/`:
| Path or document | Contents |
| --- | --- |
| `drafts/` | Complete unpublished HTML documents, named by a hash of their URI. |
| `revisions/` | Immutable snapshots of earlier published documents. |
| `scheduled/` | Complete HTML staged for future publication. |
| `drafts.json` | Human-readable draft ownership and timestamp metadata. |
| `revisions.json` | Revision URI, author, reason, and timestamp index. |
| `schedules.json` | Publication times and staged-document identifiers. |
| `shared.json` | Named global HTML fragments. |
| `menus.json` | Named flat menu structures with optional parent labels. |
| `media.json` | Reusable image alternative text. |
| `activity.json` | The latest 250 dashboard activity entries. |
| `login-attempts.json` | Hashed address and identity buckets used for login throttling. |
`FileStore` replaces JSON documents atomically by writing a temporary neighbour and renaming it. Public page publication
uses the same approach. A revision is created before replacing, deleting, or globally updating a public page. Private
directories use mode `0700` and private files use `0600` where supported. Configurable count and byte quotas prevent drafts,
schedules, revisions, and uploads from growing without bound.
## Editing Model
The configured `editableClass` identifies editable regions. The client edits a region's inner HTML in TinyMCE, then writes
it back to the iframe only. Drafting, scheduling, and publishing serialise a clone of the complete iframe document after
removing temporary CMS controls.
`neo-dupe` marks a repeatable block. `data-neo-shared="key"` marks a globally managed fragment. `data-neo-menu="name"`
marks generated navigation that should be replaced whenever its named menu changes.
The bundled templates use the default `editable` class. Sites that override `editableClass` must update their templates
to match; otherwise the resulting page is valid HTML but, quite reasonably, is not treated as editable by NeoCMS.
## Roles
Capabilities are intentionally small and cumulative:
| Role | Capabilities |
| --- | --- |
| `editor` | Save drafts and upload images. |
| `publisher` | Editor capabilities plus publish, schedule, and restore revisions. |
| `administrator` | Publisher capabilities plus page, media, shared-content, and menu management. |
The interface hides controls the user cannot invoke, but server-side checks remain authoritative. Hidden buttons are a
convenience, not a security boundary wearing a clever disguise.
Active sessions are bound to the current configured password hash. Removing an account or changing its hash invalidates
the session on the next request, while roles are re-read from configuration so demotion is immediate.
## Scheduled Publishing
Every API request opportunistically processes due jobs. Reliable unattended operation comes from invoking
`cms/publish_scheduled.php` through cron. The worker is CLI-only and uses the same publication and revision path as the UI.
## Security Boundaries
NeoCMS relies on PHP sessions with strict mode, HTTP-only cookies, SameSite=Lax, and Secure cookies under HTTPS. Mutating
requests require a constant-time-checked CSRF token. Page paths are canonicalised and constrained to HTML files beneath the
document root, excluding the CMS directory and symbolic links. Uploads are constrained by byte size, detected MIME type,
image decoding, dimensions, pixel count, file count, and total storage. Login attempts are throttled by hashed address and
address/username buckets.
HTML and JSON responses receive Content Security Policy, anti-framing, no-sniffing, referrer, permissions, cross-origin,
cache-control, and conditional HSTS headers. CDN scripts and styles are fixed to explicit versions and protected by SRI.
The supplied Apache rules protect configuration, data, logs, CLI entry points, and uploaded executable extensions. Other
servers must provide equivalent denial rules, and production use should always use HTTPS.
Audit entries are locked while appending, normalised to prevent forged lines, rotated at a configurable size, and pruned
after a configurable retention period. They remain operational records rather than a tamper-proof ledger.
+76
View File
@@ -0,0 +1,76 @@
# Development and Testing
## Source Layout
| Path | Responsibility |
| --- | --- |
| `cms/src/NeoCMS/Authentication.php` | Sessions, credentials, roles, capabilities, and CSRF tokens. |
| `cms/src/NeoCMS/CMSController.php` | API dispatch and all page, draft, revision, schedule, media, menu, and shared-content operations. |
| `cms/src/NeoCMS/FileStore.php` | Atomic JSON metadata storage and managed directories. |
| `cms/src/NeoCMS/LoginRateLimiter.php` | Locked, filesystem-backed login throttling. |
| `cms/src/NeoCMS/Logger.php` | Daily audit logging with field normalisation, size rotation, and retention. |
| `cms/src/NeoCMS/SecurityHeaders.php` | CSP, anti-framing, cache, transport, and browser-policy headers. |
| `cms/js/cms.js` | Iframe editing, dialogues, workflow state, and API client. |
| `cms/index.php` | Authenticated administration shell. |
| `cms/image_upload.php` | Multipart image upload endpoint. |
| `cms/publish_scheduled.php` | CLI scheduled-publication worker. |
| `tests/run.php` | Isolated integration suite. |
TinyMCE 8.6.0 beneath `cms/tinymce/` is vendored third-party code and should not be reformatted or documented as first-party
source. Its package metadata, changelog, and licence information remain in that directory.
## Documentation Style
Comments use British English and explain intent, constraints, side effects, and security decisions. They should not narrate
obvious syntax. A comment saying that a variable is assigned adds little; a comment explaining why a temporary file is
renamed atomically earns its tea.
Public and non-obvious private methods use docblocks. Browser workflows use JSDoc-style comments. CSS and HTML comments
describe logical regions rather than every individual declaration or tag.
## Required Checks
Run the integration suite:
```bash
php tests/run.php
```
Lint all first-party PHP files:
```bash
find cms tests -path 'cms/tinymce' -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l
```
Parse-check the administration client:
```bash
node --check cms/js/cms.js
```
Check patch whitespace before committing:
```bash
git diff --check
```
## Adding an API Action
1. Add a private method named `<action>Action()` to `CMSController`.
2. Use `requirePost('<capability>')` before every mutation.
3. Obtain required strings through `requiredPost()` or `requiredRequest()`.
4. Constrain paths through the existing URI and page-path helpers.
5. Record meaningful mutations with `activity()` and create revisions before replacing public content.
6. Return data through `respond()` so headers and JSON encoding remain consistent.
7. Add an integration assertion to `tests/run.php`.
8. Document user-visible behaviour in `README.md` and `cms/welcome.html` when appropriate.
Mutating actions that accept full documents must use `requiredContentPost()` rather than reading `$_POST` directly. New
filesystem operations must reuse canonical path helpers, reject symbolic links, and respect the configured count and byte
ceilings. New HTML entry points should use `SecurityHeaders` before emitting output.
## Filesystem Tests
The integration suite creates unique public and metadata directories under the operating system's temporary directory. It
seeds an authenticated administrator session, invokes controller actions directly, and cleans up in a `finally` block.
Tests must never rely on or modify the repository's public HTML, uploads, logs, or data files.