Security
What Fegg does for you, and what to set per project
Most protections are on by default. This page lists them and the settings that change them.
Secrets and environments
Passwords, API keys and anything else that differs per server are not committed. Put them in htdocs/_fegg/site/config/env.php (in .gitignore; copy env.php.dist) or in environment variables, and read them with feggEnv('NAME', $default). See Settings.
<?php
defined('FEGG_CORE_DIR') || exit;
return array(
'FEGG_ENV' => 'production',
'FEGG_DB_PASSWORD' => '...',
'FEGG_RECAPTCHA_SECRET' => '...',
'FEGG_SMTP_PASSWORD' => '...',
);
- A site without
env.phpruns as production: no error details on screen, for anyone. Test servers whose project path matchessite/config/env/paths.php(the LionHeart develop server) use the committedsite/config/env/staging.phpinstead: it holds only test values, never production secrets (Test servers deployed with git pull). - Environments are chosen by the server's path, never by the request's host name, which the client controls.
- Error details are shown only in developer mode: a
developer_ipaddress andFEGG_ENVother thanproduction.developer_ipalone is not a security boundary: behind a proxy or load balancer every request may come from the same address.
Security headers
Every response (pages, redirects, 404, error pages, the CMS) gets these headers. The bootstrap sends them before the controller runs.
| Header | Default | Setting in site/config/settings.php |
|---|---|---|
X-Content-Type-Options | nosniff | $settings['security_headers'] |
Referrer-Policy | strict-origin-when-cross-origin | $settings['security_headers'] |
X-Frame-Options | SAMEORIGIN (the CMS image and file pickers are iframes) | $settings['security_headers']; $this->allowFraming() for one response |
Strict-Transport-Security | not sent | $settings['hsts'] (sent on HTTPS responses only) |
Content-Security-Policy | not sent | $settings['csp']; $settings['csp_report_only'] = true sends it as Content-Security-Policy-Report-Only |
// Change or remove a default ('' removes it), or add another header
$settings['security_headers'] = array(
'Referrer-Policy' => 'same-origin',
'Permissions-Policy' => 'camera=(), microphone=()',
);
// Only once the whole site redirects to HTTPS (and every subdomain, with includeSubDomains)
$settings['hsts'] = 'max-age=31536000';
// Try a policy in report-only mode first
$settings['csp'] = "default-src 'self'; img-src 'self' data:; script-src 'self' https://www.google.com https://www.gstatic.com";
$settings['csp_report_only'] = true;
- Turn HSTS on only when every page is served over HTTPS: browsers then refuse HTTP for that domain for
max-ageseconds. - A CSP must list inline scripts and every external service the site uses (analytics, reCAPTCHA, Turnstile, YouTube ...). Watch the browser console in report-only mode before you enforce it.
- A page that other sites may show in a frame (an embeddable widget) calls
$this->allowFraming()beforedisplayPage(). It removesX-Frame-Optionsfor that response only.
Sessions and cookies
- A session starts only when something is written to it (
setSession(),setTicket(), the CMS). Reading does not create one, so ordinary public pages send noSet-Cookie. - The session cookie is
HttpOnly,SameSite=Lax, andSecurewhen the request is HTTPS (also behind a proxy that sendsX-Forwarded-Proto: https). session.use_strict_modeis on: a session ID the server did not issue is replaced.- Call regenerateSessionId() after a login. unsetSession() without a name ends the session and deletes its cookie.
- setCookie() sets
HttpOnlycookies,SameSite=Strictby default,Secureover HTTPS. unsetCookie() deletes one.
Forms
- Output escaping:
{{ $x }}escapes HTML. In URLs and JavaScript usefeggUrlandfeggJs;./fegg-cli template:lintreports outputs without them. See Template security. - Double submission: public forms use tickets (
setTicket()/useTicket()). The skeleton's contact form revokes the ticket when a confirmation fails, and sends the auto-reply to exactly one address. - CSRF in the CMS: every POST to a CMS screen must carry the session's token, or it gets a 403. Every action that changes data must call
$this->requirePost()first: the token is only checked on POST, so an action without it can be triggered by a link on another site. A GET then gets a 405, and deletes are small POST forms, not links. See Controller. - Actions: only a controller's own public methods can be called from a URL, never the framework's (
setSession(),mail()...). See Controller. - Database: values always go through
?placeholders. Table, column and order names are checked byFegg\Modeland must never come from user input. - Spam: the contact form supports reCAPTCHA and Turnstile. When the service cannot be reached the check fails, it is never skipped (see Captcha).
CMS login
- After 5 failed logins for one login ID from one address, or 20 from one address, logins from that address are refused for 15 minutes. The failures are kept in the core table
cms_login_attempt: runfegg-cli migrationafter updating the core. Without the table the login works unthrottled and a warning is logged. /cms/login/authis POST only. Deleted users (valid = 0) cannot log in, and deleting a user ends their sessions. Old password hashes are upgraded at login. An unknown login ID takes as long to check as a wrong password.- Logout is a POST with the CSRF token (see Controller).
- Users are created and their passwords changed with
fegg-cli user:create/user:password, which never take the password on the command line./cms/register/needs a CMS login and developer mode. - Basic authentication compares the user and password with
hash_equals(). Withbasic_auth_scope = 1it is checked before the controller's__init()and action, so redirects, downloads and direct output are protected too.
Uploads
- The extension must be allowed, and script extensions (
php,phtml,phar,cgi...) are always refused. - The content must match the extension (
getimagesize()for images,finfofor known types): a script renamed to.jpgis refused. - Directories are created
0755, files0644. Files are saved under generated names. - The upload directory below the document root gets an
.htaccessthat stops scripts from running.fegg-cli doctorreports a missinghtdocs/up_dir/.htaccess. On nginx, deny scripts under the upload URL yourself. - Stored files are sent with
nosniffand an escapedContent-Disposition; HTML, SVG and XML are always attachments.
See Upload.
Protecting _fegg/
htdocs/_fegg/ is inside the document root in the standard layout. It is protected by .htaccess files, and every PHP file starts with defined('FEGG_CORE_DIR') || exit;. Check a live site with ./fegg-cli doctor https://example.com after every FTP upload: it also warns about outdated .htaccess rules. See Directory structure and fegg-cli.