fix: harden CMS security controls
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user