Référence
Documentation développeur
L’API de plugins, les points REST et le modèle de sécurité, rendus depuis le dossier docs.
API de plugins
Plugin API
Fortress CMS plugins are small PHP packages that extend the CMS through a documented,
stable API. Plugins are trusted code: only install plugins whose source you have
reviewed. The manifest and capability list are there to make that review easier,
not to sandbox the code.
Directory layout
plugins/
└── my-plugin/
├── plugin.json # required manifest
├── Plugin.php # required entry point
├── lang/
│ ├── en.php
│ └── fr.php
└── views/
└── settings.phpA plugin must live in plugins/{name} where {name} matches the manifestname and uses lowercase letters, digits and dashes.
Installing
Two ways, same result:
- From the panel — Plugins → Déposer un plugin: upload a ZIP whose
content is the folder above. The archive may be flat (plugin.json at the
root) or wrap the folder once; the manifest decides the name. Limits: .zip
only, 10 MB, allow-listed file extensions, valid manifest. The plugin is
installed disabled — enable it in the list to load it.
- From disk — copy the folder into
plugins/over FTP/SSH and reload
Plugins. Useful in CI, or when the upload is disabled with'plugin_upload' => false.
Neither path overwrites an existing plugin: delete it first if you are replacing
it. Installing from the panel writes a plugin.installed audit entry.
Manifest
{
"name": "reading-time",
"title": {
"en": "Reading time",
"fr": "Temps de lecture",
"es": "Tiempo de lectura",
"de": "Lesezeit"
},
"version": "1.0.0",
"author": "Your name",
"description": {
"en": "Appends an estimated reading time below each article.",
"fr": "Ajoute un temps de lecture estimé sous chaque article."
},
"entry": "Plugin.php",
"class": "Fortress\\Plugins\\ReadingTime\\Plugin",
"requires": {
"php": ">=8.2",
"cms": ">=1.0"
},
"capabilities": ["hooks", "admin.pages", "settings", "storage"]
}Required fields: name, title, version, entry, class.
Optional fields:
requires.phpandrequires.cms: simple constraints (>=8.2,^1.0,
~1.2, >1.0 <2.0). The plugin is listed with a load error when the
constraint is not satisfied.
capabilities: free-form strings shown in the admin panel. Suggested values:
hooks, routes, admin.pages, settings, database, storage, i18n,mail, media.
author,description: strings or{locale: string}maps.
The manifest is validated on every request. New plugins are stored as
disabled; an administrator must enable them explicitly.
Entry point
<?php
declare(strict_types=1);
namespace Fortress\Plugins\ReadingTime;
use Fortress\Core\Response;
use Fortress\Plugin\PluginApi;
use Fortress\Plugin\PluginInterface;
final class Plugin implements PluginInterface
{
public function register(PluginApi $api): void
{
$api->addFilter('content.render', static function (string $html, array $content): string {
$words = str_word_count(strip_tags($html));
$minutes = max(1, (int) ceil($words / 200));
return $html . '<p class="reading-time">'
. $minutes . ' min read</p>';
}, 20);
}
}register() runs once per request for each enabled plugin, after the database,
settings, session, locale and authentication services are ready.
API reference
Hooks and filters
$api->addAction(string $hook, callable $callback, int $priority = 10): void;
$api->addFilter(string $hook, callable $callback, int $priority = 10): void;
$api->doAction(string $hook, mixed ...$args): void;
$api->applyFilters(string $hook, mixed $value, mixed ...$args): mixed;Lower priority numbers run first. Exceptions thrown by callbacks are logged and
do not break the request.
Routes
$api->route('GET', '/contact', function (Request $request) use ($api): Response {
return $api->renderSite('Contact', $api->view('form', []));
});- Methods:
GET,POST,PUT,PATCH,DELETE. - Paths must start with
/and cannot use reserved prefixes:admin,api,
assets, media, install, blog, search, theme.css, theme.js, locale
codes, sitemap/robots/security files.
- Plugin routes are registered before the public content catch-all.
- Always verify CSRF on state-changing requests:
$api->verifyCsrf();.
Admin pages
$api->adminMenu('Reading time', 'settings', function (Request $request) use ($api): Response {
return Response::html($api->view('settings'));
});The page is available at /admin/plugins/{plugin}/{slug}. It is protected by
the normal admin session and rendered inside the admin layout. Handler return
values can be a Response or an HTML string.
Settings
$settings = $api->settings(['enabled' => '1']);
$api->saveSettings(['enabled' => $request->boolean('enabled') ? '1' : '0']);Settings are stored as JSON in the plugins table and are namespaced per
plugin. They are not exposed by the REST API.
Database
$db = $api->db();
$rows = $db->fetchAll('SELECT * FROM contact_messages ORDER BY id DESC LIMIT 50');Database exposes run, fetch, fetchAll, value, insert, update,delete, transaction, driver and pdo (escape hatch for DDL).
Use the driver name to write portable CREATE TABLE statements (SQLite usesINTEGER PRIMARY KEY AUTOINCREMENT, MySQL uses INT UNSIGNED AUTO_INCREMENT).
View and response helpers
$api->view('form', ['data' => $value]); // plugins/{name}/views/form.php
$api->renderSite($title, $html, $seo = []); // full public page with layout
$api->json(['ok' => true], 200);
$api->html('<p>Hello</p>');
$api->redirect('/contact?sent=1');Templates are plain PHP. Escape every value with e() or the global t() /csrf_field() helpers. PluginApi::view() injects $api into the template.
Request, user and URLs
$request = $api->request();
$name = (string) $request->input('name', '');
$user = $api->user(); // null when anonymous
$url = $api->url('/contact');
$absolute = $api->absoluteUrl('/');Internationalisation
$api->locale(); // en, fr, es or de
$api->t('title'); // plugins/{name}/lang/{locale}.php
$api->t('greeting', [':name' => 'Ada']);The lang/en.php file is the fallback. Translation values are plain strings;
placeholders use :name syntax.
Rate limiting
$limit = $api->rateLimit('contact:' . $request->ip, 5, 600);
if (!$limit['allowed']) {
// 429 with retry_after seconds
}The limiter is database-backed and shared across PHP workers.
Storage and media
$path = $api->storagePath(); // storage/plugin-data/{plugin}
$url = $api->mediaUrl(12); // /media/12/...Use storagePath() for private exports, caches or uploads that must not be
served directly. Use the media library for public images and PDFs.
Logging
$api->log('Webhook failed', ['status' => 500]);Messages go to the PHP error log, prefixed with [plugin:{name}].
Hook reference
| Hook | Type | Arguments |
|---|---|---|
site.head | filter | string $head, array $seo, string $pageHtml |
site.footer | filter | string $html, array $seo, string $pageHtml |
admin.head | filter | string $html |
admin.footer | filter | string $html |
content.render | filter | string $html, array $context |
content.saved | action | array $content, array $actor |
content.deleted | action | array $content, array $actor |
content.status | action | array $content, string $status |
media.uploaded | action | array $media |
auth.login | action | array $user |
sitemap.urls | filter | array $urls |
robots.rules | filter | array $lines |
install.completed | action | Database $db, int $adminId |
ai.brief | filter | array $brief (topic, locale, tone, audience, keywords, length, type) |
ai.article | filter | array $article, array $brief |
ai.generated | action | array $article, array $meta |
ai.assist | filter | string $text, string $action, array $payload |
llms.index | filter | string $document, string $locale |
llms.full | filter | string $document, string $locale |
llms.article | filter | string $document, array $content, string $locale |
live.saved | action | array $session |
live.published | action | array $session |
ai.brief runs before the prompt is built, so a plugin can enrich or rewrite
the brief. ai.article runs on the parsed article before it is returned to the
studio, and llms.* lets a plugin add sections to the machine-readable exports.
site.head output is inserted verbatim inside <head>; the plugin is
responsible for escaping values. CSP nonces are available throughcsp_nonce() for inline scripts.
Consent-aware scripts
Fortress ships a GDPR banner. A plugin that injects a third-party script must
never load it before the visitor agrees. Emit a dormant script instead — it is
stored as text and only executed once the matching category is granted:
Hooks::addFilter('site.footer', static function (string $html): string {
return $html . '<script type="text/plain" data-consent="analytics"
src="https://plausible.example/js/script.js" defer></script>';
});data-consentaccepts one or more categories, separated by spaces or commas
(analytics, marketing, preferences). necessary is always allowed.
- The category must be offered in Settings → Privacy & consent, otherwise it
can never be granted.
- For runtime checks use
window.FortressConsent.allows('analytics'); the
fortress:consent DOM event fires with the granted list after each decision.
- Server side,
\Fortress\Core\Consent::allows('analytics')reads the signed
cookie (never trust a client-sent category list).
Categories are also archived in consent_log; never store the raw IP yourself,
use Consent::record() semantics (HMAC pseudonymisation) if you need to log.
AI disclosure for plugin content
If a plugin creates content programmatically, tell the truth about its origin:
Content::save([
'type' => 'post',
'status' => 'draft',
'ai_generated' => true, // machine-drafted
'ai_watermark' => true, // show the public ✦ badge
// …
], $actorId, $actorEmail);\Fortress\Core\Content::aiDisclosed($content) returns the effective public
state once the site-wide setting and the per-item switch are applied.
Security guidelines
- Escape all output (
e(),htmlspecialchars) and never trust request data. - Verify CSRF on every POST/PUT/PATCH/DELETE handled by your plugin.
- Use prepared statements through
Database; never concatenate input into SQL. - Do not read
$_GET,$_POSTor$_SERVERdirectly; useRequest. - Keep secrets in plugin settings or environment variables, never in views.
- Do not expose raw stack traces or database errors to visitors.
- Treat capabilities as documentation for administrators, not as a sandbox.
- Do not call
eval,exec,shell_execor include user-controlled files.
Versioning
The CMS version is FORTRESS_VERSION (1.5.1). Declare a compatible range inrequires.cms. Breaking API changes will increment the CMS major version.
Reserved route prefixes (plugins cannot claim them): admin, api, assets,media, install, install.php, blog, search, sitemap.xml,robots.txt, security.txt, health, llms, llms.txt, llms-full.txt,live, cron, and the locale codes.
Testing a plugin
- Copy the plugin to
plugins/{name}. - Reload
/admin/plugins; the plugin appears as disabled. - Enable it and check the "Load error" badge if it does not appear.
- Exercise its public routes and admin pages.
- Check
storage/logs/php-error.logfor runtime errors.
API REST
REST API v1
The API is versioned under /api/v1. It returns JSON, uses Bearer API keys and
never exposes admin operations. Rate limits apply per key.
Authentication
Create a key in Admin → API keys. The plain key is shown once; only its
SHA-256 hash is stored.
curl -H "Authorization: Bearer fortress_..." \
https://example.com/api/v1/contentScopes
| Scope | Grants |
|---|---|
content:read | Read content. Without content:write, only published content is visible. |
content:write | Create, update and delete content, and read drafts. |
media:read | List media. |
media:write | Upload and delete media. |
plugins:read | List enabled plugins and registered hooks. |
Keys can expire (30, 90 or 365 days) and can be revoked immediately. Revoked or
expired keys receive 401.
Rate limiting
Default: 120 requests per minute per key. Change it in Settings → API.
Exceeding the limit returns 429 with Retry-After and this body:
{"error":{"code":"too_many_requests","message":"API rate limit exceeded."}}Errors
{
"error": {
"code": "validation_failed",
"message": "Human readable detail"
}
}| Status | Meaning |
|---|---|
| 400 | Malformed JSON or missing body |
| 401 | Missing, invalid, expired or revoked key |
| 403 | Missing scope |
| 404 | Resource not found (or draft without content:write) |
| 415 | Unsupported upload type |
| 422 | Validation failure |
| 429 | Rate limit exceeded |
| 500 | Unexpected server error |
Endpoints
Public
GET /api/v1/health
{"ok":true,"name":"Fortress CMS API","version":"1.0.0","php":"8.3.12",
"database":"up","locales":["en","fr","es","de"],"time":"2026-01-01T10:00:00Z"}GET /api/v1/locales
{"data":[{"code":"en","name":"English","native":"English","default":true}]}Machine-readable content (LLMs.txt)
Not part of the JSON API, but a documented public surface for AI crawlers:
| Route | Content |
|---|---|
GET /llms.txt | Index of published pages and articles, default locale |
GET /llms-full.txt | Full Markdown corpus of the default locale |
GET /llms/{locale}.txt | Index for en, fr, es or de |
GET /llms/{slug}.md | One article as Markdown, in the language of that slug |
Served as text/plain (or text/markdown), Cache-Control: public, max-age=600,
and advertised in the HTML with<link rel="alternate" type="text/markdown" href="/llms/{slug}.md">.
Only published content is exposed. Toggle the whole surface in
Settings → LLMs.txt.
Live media
Published recordings are public without a key:
GET /live— index of published takesGET /live/{id}— player pageGET /live/media/{id}— the stream itself, with HTTP range support
Draft takes require an authenticated administrator session, not an API key.
Content
GET /api/v1/content
Query parameters:
page(default 1)per_page(default 10, maximum 50)type:pageorpoststatus:draftorpublished(requirescontent:writeto read drafts)locale:en,fr,es,deq: search in title, excerpt and body
{
"data": [
{
"id": 12,
"type": "post",
"status": "published",
"published_at": "2026-01-01T09:00:00Z",
"translations": {
"en": {"title": "Hello", "slug": "hello", "excerpt": "…"},
"fr": {"title": "Bonjour", "slug": "bonjour", "excerpt": "…"}
}
}
],
"meta": {"page": 1, "per_page": 10, "total": 1, "pages": 1}
}GET /api/v1/content/{id}
Returns the full content object, including body_md and rendered body_html
for every translation.
POST /api/v1/content
{
"type": "post",
"status": "published",
"published_at": "2026-02-01T08:00:00Z",
"in_menu": false,
"menu_order": 0,
"is_home": false,
"featured_image": null,
"translations": {
"en": {
"title": "Release notes",
"slug": "release-notes",
"excerpt": "What changed.",
"body_md": "# Release notes\n\n- Faster install\n- New hooks",
"seo_title": "Release notes",
"seo_description": "What changed in this release."
},
"fr": {
"title": "Notes de version",
"slug": "notes-de-version",
"body_md": "# Notes de version\n\n- Installation plus rapide"
}
}
}translationsmay also be an array of objects containing alocalefield.- The default language translation is mandatory.
slugis generated from the title when omitted.- Slugs must be unique per locale and cannot use reserved paths.
bodyis accepted as an alias forbody_md.- Raw HTML is escaped; Markdown is rendered by the CMS.
PATCH /api/v1/content/{id}
Send a complete replacement of the fields you want to change. Translations are
replaced as a set: include every locale that must remain.
DELETE /api/v1/content/{id}
Returns 204 on success.
Media
GET /api/v1/media?page=1
{
"data": [{"id":4,"filename":"20260101-ab12.png","mime":"image/png",
"size":2048,"width":640,"height":480,"alt":"Diagram",
"url":"https://example.com/media/4/20260101-ab12.png"}],
"meta": {"page":1,"total":1,"pages":1}
}POST /api/v1/media — multipart/form-data with a file field.
Allowed: JPEG, PNG, WebP, GIF, PDF. Maximum size is configured in settings
(5 MB by default, 20 MB hard cap). Files are validated by MIME type and stored
outside the web root.
DELETE /api/v1/media/{id}
Returns 204. Content that referenced the file loses its featured image.
Plugins
GET /api/v1/plugins (scope plugins:read)
Returns enabled plugins with their version, capabilities and requirements, plus
the registered hooks and CMS/PHP versions. Plugin settings are not exposed.
CORS
Cross-origin access is disabled by default. Add exact origins in
Settings → API → Allowed CORS origins. No wildcard and no credentials are
used; send the key in the Authorization header.
Best practices
- Use one key per integration and the smallest scope set.
- Rotate keys periodically and revoke them immediately after a leak.
- Always use HTTPS; keys are bearer credentials.
- Treat
422details as user-facing validation messages. - Paginate list endpoints;
per_pageis capped at 50.
Modèle de sécurité
Security model
Fortress CMS is built for small sites that still deserve serious security. This
document describes the controls that ship by default, the trust boundaries and
the limits of the model. It does not claim any certification; it aims to make
the implementation reviewable.
Supported versions
| Version | Security support |
|---|---|
| 1.x | Yes |
Reporting a vulnerability
Do not open a public issue for a vulnerability. Send a private report to the
site owner using the contact published in /.well-known/security.txt, and
include:
- affected version and PHP version,
- a minimal reproduction,
- the impact you believe is possible,
- whether the issue is already public.
You will receive an acknowledgement, a triage decision and a remediation plan.
Please allow time for a fix before disclosure.
Threat model
In scope
- Unauthenticated visitors attacking the public site, admin login or installer.
- Authenticated administrators attacking other parts of the application.
- Malicious file uploads and stored content.
- Credential stuffing and brute force.
- Cross-site scripting, CSRF, SQL injection, path traversal, open redirects.
- Leakage of secrets, session identifiers and API keys.
- Supply-chain risk from the core (zero runtime PHP dependencies).
Out of scope
- A compromised server, PHP runtime, database server or hosting control panel.
- Malicious plugins: plugins execute PHP with application privileges.
- Denial of service at the network layer (use a WAF or proxy).
- Vulnerabilities in the browser, operating system or email client.
Controls
Authentication
- Passwords hashed with Argon2id when available (bcrypt cost 12 fallback).
- Minimum length 12, common-password and identity checks, rehash on login.
- Optional TOTP second factor (RFC 6238) with replay protection.
- Single-use recovery codes stored as password hashes.
- Login rate limiting per IP and per account pair (5 attempts / 15 minutes).
- Generic error messages; dummy password verification for unknown accounts.
- Session versioning invalidates every other session on password or 2FA change.
Sessions
HttpOnly,SameSite=Laxcookies;Secureon HTTPS.- Strict session mode, cookies only, no URL session IDs.
- Regeneration on login, logout and privilege changes.
- Idle timeout (default 45 minutes) and user-agent binding.
- Session files stored in
storage/sessionswhen writable.
CSRF and request integrity
- A per-session CSRF token on every state-changing form.
hash_equalscomparison and same-origin checks forOrigin/Referer.- API requests use Bearer keys and are not cookie-authenticated.
- All admin POST routes go through the
csrfmiddleware. POST /consentis CSRF-protected too: a consent record has to come from this
site's own form, not from a cross-site POST that grants categories on the
visitor's behalf. A foreign Origin is rejected before the token is read.
Output and content
- Views escape with
e()(htmlspecialchars,ENT_QUOTES, UTF-8). - Markdown is parsed after escaping the whole document; raw HTML is never
trusted. Only a fixed set of tags generated by the renderer can appear.
- Link and image URLs are restricted to local paths, anchors,
http(s)and
mailto. javascript:, data: and protocol-relative URLs are rejected.
- JSON-LD is encoded with
JSON_HEX_TAGandJSON_HEX_AMPand requires a CSP
nonce.
Security headers
Every response receives:
Content-Security-Policywith nonces,frame-ancestors 'none',
base-uri 'none', object-src 'none', no unsafe-inline.
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy:no-referrerfor admin,strict-origin-when-cross-origin
for the public site.
Permissions-Policydenying camera, microphone, geolocation, payment and USB.Cross-Origin-Opener-Policy: same-origin,
Cross-Origin-Resource-Policy: same-origin,X-Permitted-Cross-Domain-Policies: none.
Strict-Transport-Securitywhen HTTPS is detected.Cache-Control: no-storeandX-Robots-Tag: noindexfor the admin panel.
Database
- PDO prepared statements for every query.
- Identifier whitelisting for dynamic table/column names.
- Strict SQL mode on MySQL where permitted.
- SQLite uses WAL, foreign keys and a busy timeout.
- SQLite database, configuration and uploads live in
storage/, denied by
.htaccess and Nginx rules.
Uploads
- 20 MB hard cap; configurable limit (default 5 MB).
- MIME detection with
finfo, image structure validation withgetimagesize. - Random filenames, no user extension, no SVG (XSS vector).
- Files stored outside the web root, mode
0640. - Served through PHP with an explicit
Content-Type,nosniffand
Content-Disposition; path traversal checks use realpath.
Rate limiting and audit
- Database-backed fixed-window limiter shared across workers.
- Login, 2FA, contact forms, API keys and the installer are limited.
- Audit log records actor, e-mail, action, target, IP, user agent, timestamp and
optional JSON metadata.
- API keys stored as SHA-256 hashes, with scopes, expiry and revocation.
Installer
- Automatically launched by
index.phpuntilstorage/config.phpand
storage/install.lock exist.
- CSRF-protected, rate limited and locked permanently after success.
- Optional deployment token via
FORTRESS_SETUP_TOKEN. - Writes a random 256-bit application key, a strict config file (
0640) and a
lock file containing the config hash.
- Reinstalling requires manual deletion of both files, protecting production
from accidental resets.
Secrets
storage/config.phpcontains the database password and application key.- TOTP secrets are encrypted at rest with AES-256-GCM using a key derived from
the application key.
- API keys and recovery codes are stored only as hashes.
- AI provider keys are encrypted with the same envelope (
enc.v1.prefix) and
are never rendered back into a form; only a "Connected" badge is displayed.
- The AI cron token (
ai_cron_token) is a 48-character hex secret compared with
hash_equals; rotate it if it leaks.
- No telemetry, external CDN or third-party script is loaded.
AI engine
- The engine is off after installation; enabling it is an explicit action.
- Every provider call happens server side. Keys never reach the browser, and the
settings page discards a key whose value is echoed back.
- If the
app_keyrotates, stored keys stop decrypting and the engine reports a
configuration error instead of sending a blank credential.
- Provider responses are treated as untrusted input: the article must be valid
JSON with a title and a body, otherwise the request fails and nothing is
saved. Generated Markdown is rendered through the same escaping/sanitising
pipeline as user content.
- Endpoints are CSRF-protected, rate limited per administrator and audited
(ai_jobs). The cron endpoint is token-gated and refuses to run when the
token is empty.
ai.brief,ai.articleandai.assistrun on the server; a plugin can change
the output but never sees another tenant's credentials.
LLMs.txt
/llms.txt,/llms-full.txt,/llms/{locale}.txtand/llms/{slug}.mdexpose
published content only, in the same way as the sitemap and the RSS feed.
- Drafts, scheduled content and unpublished live sessions never appear.
- The exports are plain text served with
X-Content-Type-Options: nosniffand
no HTML interpretation, so an injected angle bracket cannot become markup.
- If publishing machine-readable content is not desired, disable it in
Settings → LLMs.txt; the routes then return 404.
Live studio
- Recordings are written to
storage/live/(outside the web root), with random
file names and mode 0640.
- Only authenticated authors can start a take; the chunk endpoint requires a
valid session and the X-CSRF-Token header.
- Chunks are capped at 8 MB each and at
live_max_mbper session; the size
counter is authoritative even if the client lies.
- MIME types are validated against an allow-list per capture kind; anything else
is normalised to video/webm/audio/webm, served with nosniff.
- Draft recordings are served only to the administrator who started them (or an
admin). Published recordings are public and listed on /live.
live_retention_dayspurges abandoned drafts during/cron/ai; sessions left
in recording (tab closed mid-take) are purged after 24 hours regardless.
The public panel demo
/demo-admin renders the real administration panel against a separate SQLite
database seeded with fictional content (storage/cache/demo/admin.sqlite). It
is not a mock-up, so its safety is structural rather than cosmetic:
- Separate data. The demo never opens
storage/app.db; the only database it
reads is the generated one, which contains no user, key or log of the real
site. A stored app_key is never used there.
- Read-only twice.
Apprefuses every non-GET/HEADrequest with405
before routing, and the PDO connection is opened with PRAGMA query_only = ON
— an accidental write in a display path fails at the driver, not at the edge.
The single exception is POST /admin/content/preview, the editor's renderer:
it turns a Markdown string into HTML through Markdown::render() and never
touches the database. It exists because the visual editor re-renders the source
when you switch back from the Markdown view. It is listed by name inDemo::isReadOnlyPost(), not opened by a pattern, so a future POST route stays
refused until someone adds it there.
- No authentication bypass.
Auth::bootreturns a single fictional
administrator stored in that database; no session flag, cookie or password is
involved, so nothing carries over to /admin.
- Bounded cost. 240 requests / 10 min / IP, plus a version-stamped cache so
the database is built once and rebuilt only after an upgrade.
- Removable.
'admin_demo' => falseinstorage/config.phpturns the route
into a plain 404.
Installing a plugin from the panel
POST /admin/plugins/install is the only route in the CMS that writes executable
code from an HTTP request, so it is deliberately narrow:
- Admin only, CSRF protected, audit logged, and switchable off with
'plugin_upload' => false in storage/config.php.
- ZIP only, 10 MB maximum; the archive is read by
Core\ZipReader, which
refuses absolute paths, .., backslashes, null bytes, encrypted entries,
unsupported compression methods, more than 2000 entries or more than 32 MB
uncompressed. A CRC mismatch aborts the whole install.
- Extension allow-list: `php, json, css, js, map, svg, png, jpg, jpeg, webp,
gif, ico, md, txt, po, mo, ini, yml, yaml. A .phtml, .phar, .htaccess` or
shell script is refused with the file name in the message.
- Manifest validated before anything is written: name pattern, entry file
present in the archive, class name, PHP and CMS constraints.
- No overwrite: an existing folder is never replaced, so an upload cannot
silently swap the code of a plugin you already trust.
- Installed disabled: the plugin lands on disk and appears in the list as
Inactif. loadEnabled() only includes plugins enabled in the database, so an
upload alone executes nothing.
- Second path check at write time:
realpath()of every target directory must
stay under the plugin folder, independently of the reader's own validation.
What this does not do: sandbox the plugin. Once enabled, a plugin is ordinary
PHP in the application process — it can read the database, the settings and the
filesystem. The install gate protects against a malformed or hostile archive; it
cannot protect against hostile logic you chose to enable. That is why the panel
says so, and why the feature can be turned off outright.
CSRF and opaque origins
The CSRF check compares the Origin (or Referer) host with the request host.
A literal Origin: null — produced by sandboxed iframes, embedded preview
panes and file:// pages — has no host to compare and is therefore skipped.
This does not open a cross-site hole: the session cookie is SameSite=Lax, so a
cross-site POST never carries it, and the per-session token is still required.
Real origins are always compared and rejected on mismatch.
Consent and disclosure integrity
- The consent cookie (
fortress_consent) is HMAC-SHA256 signed with a key
derived from app_key via HKDF; a tampered or truncated value is discarded
and the banner is shown again.
- It is
HttpOnly+SameSite=Lax(+Secureover HTTPS), so no script can
read or forge it — the server is the only reader.
- Bumping
consent_versioninvalidates every stored decision at once, which is
what a changed processing purpose requires.
POST /consentis rate limited (40 / 10 min / IP), validates the decision
against an allow-list, and rejects requests whose Origin host differs from
the site host. return is forced to a local absolute path.
- Decisions are archived in
consent_logwith the IP pseudonymised by
HMAC: proof of consent without keeping identifiable traffic logs. Rows older
than 24 months are pruned automatically.
- The AI mark is server-rendered and can only be removed by an authenticated
editor. It cannot be hidden by query parameter, cookie or client script, and
it is duplicated in JSON-LD, llms.txt and per-article Markdown so that
machine readers see the same disclosure as humans.
Plugins and trust
Plugins are PHP code installed by an administrator. They are not sandboxed
and inherit the application's database and filesystem privileges. The manifest
declares capabilities for transparency; it is not an enforcement boundary.
Before enabling a plugin:
- Review its source code and dependencies.
- Check that it uses
Request,Databaseand the helpers instead of raw
superglobals.
- Check CSRF verification on state-changing routes.
- Prefer plugins with a small, readable surface.
Hardening checklist
- [ ] Serve the site over HTTPS and enable Force HTTPS.
- [ ] Enable TOTP and store recovery codes offline.
- [ ] Set Require 2FA once every administrator has enrolled.
- [ ] Keep
storage/writable but not publicly accessible. - [ ] Verify
/.well-known/security.txtpublishes a real contact. - [ ] Set a security contact in the settings.
- [ ] Disable search engine indexing on staging.
- [ ] Back up
storage/app.db(or MySQL) andstorage/config.php. - [ ] Keep PHP and the operating system patched.
- [ ] Review the audit log after deployment.
- [ ] Remove or protect any additional files uploaded outside the CMS.
- [ ] Use a WAF or reverse proxy for DDoS and bot filtering if needed.
- [ ] Install
php-curland keepallow_url_fopen = Offfor the AI engine. - [ ] Review Settings → AI (mode, daily cap) before enabling the cron.
- [ ] Protect
/cron/aiwith a secret token and call it over HTTPS. - [ ] Restrict the camera/microphone prompt to administrators (
Permissions-Policy
already scopes it to admin pages).
- [ ] Decide whether
/llms.txtshould stay public; disable it otherwise.
Known limitations
- No email-based password reset by design: use the account page or restore from
a backup; the admin account can be updated with a CLI script if locked out.
- Malicious plugins are equivalent to a server compromise.
- SQLite is single-node; use MySQL for multi-server deployments.
- The CMS does not manage TLS certificates, backups, mail delivery or WAF rules.
- Security depends on the hosting environment: protect PHP-FPM, the database and
the filesystem.
- The audit log is not cryptographically chained; protect database access and
ship logs to an append-only store if you need stronger guarantees.
References
- OWASP Application Security Verification Standard (ASVS)
- OWASP Cheat Sheet Series: Authentication, Session Management, CSRF, XSS,
File Upload, SQL Injection, Password Storage
- PHP manual:
password_hash,session, PDO prepared statements