Démo publique Vraie interface, données fictives, lecture seule : rien ici n’est modifiable.

Quitter la démo ↗

Documentation

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.php

A plugin must live in plugins/{name} where {name} matches the manifest
name and uses lowercase letters, digits and dashes.

Installing

Two ways, same result:

  1. 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.

  1. 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.php and requires.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 uses
INTEGER 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

HookTypeArguments
site.headfilterstring $head, array $seo, string $pageHtml
site.footerfilterstring $html, array $seo, string $pageHtml
admin.headfilterstring $html
admin.footerfilterstring $html
content.renderfilterstring $html, array $context
content.savedactionarray $content, array $actor
content.deletedactionarray $content, array $actor
content.statusactionarray $content, string $status
media.uploadedactionarray $media
auth.loginactionarray $user
sitemap.urlsfilterarray $urls
robots.rulesfilterarray $lines
install.completedactionDatabase $db, int $adminId
ai.brieffilterarray $brief (topic, locale, tone, audience, keywords, length, type)
ai.articlefilterarray $article, array $brief
ai.generatedactionarray $article, array $meta
ai.assistfilterstring $text, string $action, array $payload
llms.indexfilterstring $document, string $locale
llms.fullfilterstring $document, string $locale
llms.articlefilterstring $document, array $content, string $locale
live.savedactionarray $session
live.publishedactionarray $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 through
csp_nonce() for inline 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-consent accepts 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

  1. Escape all output (e(), htmlspecialchars) and never trust request data.
  2. Verify CSRF on every POST/PUT/PATCH/DELETE handled by your plugin.
  3. Use prepared statements through Database; never concatenate input into SQL.
  4. Do not read $_GET, $_POST or $_SERVER directly; use Request.
  5. Keep secrets in plugin settings or environment variables, never in views.
  6. Do not expose raw stack traces or database errors to visitors.
  7. Treat capabilities as documentation for administrators, not as a sandbox.
  8. Do not call eval, exec, shell_exec or include user-controlled files.

Versioning

The CMS version is FORTRESS_VERSION (1.5.1). Declare a compatible range in
requires.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

  1. Copy the plugin to plugins/{name}.
  2. Reload /admin/plugins; the plugin appears as disabled.
  3. Enable it and check the "Load error" badge if it does not appear.
  4. Exercise its public routes and admin pages.
  5. Check storage/logs/php-error.log for 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/content

Scopes

ScopeGrants
content:readRead content. Without content:write, only published content is visible.
content:writeCreate, update and delete content, and read drafts.
media:readList media.
media:writeUpload and delete media.
plugins:readList 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"
  }
}
StatusMeaning
400Malformed JSON or missing body
401Missing, invalid, expired or revoked key
403Missing scope
404Resource not found (or draft without content:write)
415Unsupported upload type
422Validation failure
429Rate limit exceeded
500Unexpected 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:

RouteContent
GET /llms.txtIndex of published pages and articles, default locale
GET /llms-full.txtFull Markdown corpus of the default locale
GET /llms/{locale}.txtIndex for en, fr, es or de
GET /llms/{slug}.mdOne 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 takes
  • GET /live/{id} — player page
  • GET /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: page or post
  • status: draft or published (requires content:write to read drafts)
  • locale: en, fr, es, de
  • q: 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"
    }
  }
}
  • translations may also be an array of objects containing a locale field.
  • The default language translation is mandatory.
  • slug is generated from the title when omitted.
  • Slugs must be unique per locale and cannot use reserved paths.
  • body is accepted as an alias for body_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 422 details as user-facing validation messages.
  • Paginate list endpoints; per_page is 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

VersionSecurity support
1.xYes

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=Lax cookies; Secure on 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/sessions when writable.

CSRF and request integrity

  • A per-session CSRF token on every state-changing form.
  • hash_equals comparison and same-origin checks for Origin/Referer.
  • API requests use Bearer keys and are not cookie-authenticated.
  • All admin POST routes go through the csrf middleware.
  • POST /consent is 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_TAG and JSON_HEX_AMP and requires a CSP

nonce.

Security headers

Every response receives:

  • Content-Security-Policy with nonces, frame-ancestors 'none',

base-uri 'none', object-src 'none', no unsafe-inline.

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • Referrer-Policy: no-referrer for admin, strict-origin-when-cross-origin

for the public site.

  • Permissions-Policy denying 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-Security when HTTPS is detected.
  • Cache-Control: no-store and X-Robots-Tag: noindex for 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 with getimagesize.
  • 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, nosniff and

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.php until storage/config.php and

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.php contains 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_key rotates, 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.article and ai.assist run 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}.txt and /llms/{slug}.md expose

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: nosniff and

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_mb per 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_days purges 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. App refuses every non-GET/HEAD request with 405

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 in
Demo::isReadOnlyPost(), not opened by a pattern, so a future POST route stays
refused until someone adds it there.

  • No authentication bypass. Auth::boot returns 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' => false in storage/config.php turns 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.

  • 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 (+ Secure over HTTPS), so no script can

read or forge it — the server is the only reader.

  • Bumping consent_version invalidates every stored decision at once, which is

what a changed processing purpose requires.

  • POST /consent is 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_log with 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:

  1. Review its source code and dependencies.
  2. Check that it uses Request, Database and the helpers instead of raw

superglobals.

  1. Check CSRF verification on state-changing routes.
  2. 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.txt publishes a real contact.
  • [ ] Set a security contact in the settings.
  • [ ] Disable search engine indexing on staging.
  • [ ] Back up storage/app.db (or MySQL) and storage/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-curl and keep allow_url_fopen = Off for the AI engine.
  • [ ] Review Settings → AI (mode, daily cap) before enabling the cron.
  • [ ] Protect /cron/ai with 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.txt should 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
Aperçu statique du panneau — données fictives, les actions sont désactivées. Retour au site