Origin Protection & Hardening¶
This page describes the security measures on the Brekz webservers FL09BRE002 and FL10BRE001 that:
- only allow web traffic that arrives through Cloudflare,
- protect the back office (
/administrator/) with Cloudflare Access, - restrict which PHP files can run,
- log every attempt to reach the servers directly, outside Cloudflare, and report those attempts weekly.
Background: security incident BR-8188
These measures follow from BR-8188 — Mogelijk security incident Brekz PrestaShop: onverwachte wachtwoord-resets za 17-10 17:30 (o.a. PET-gebruiker).
One of the possible routes under investigation was direct access to the origin server. That route bypasses Cloudflare, and therefore Cloudflare Access. At the time, ports 80 and 443 on both webservers were open to the entire internet. These measures close that route. They do not, by themselves, establish what caused the incident.
Implementation task
BR-8191 — Brekz Hosting - Origin-afscherming en hardening testomgeving (n.a.v. BR-8188)
Related documents
Rollout status¶
| Environment | Hostnames | Status |
|---|---|---|
| Test | *.ftest.nl |
Rolled out. Cloudflare Access on /administrator/ is being configured. |
| Accept | *.fstaging.nl |
Planned |
| Production | www.brekz.* |
Planned, prioritised because of BR-8188 |
Shared servers
Test, accept and production all run on the same two webservers. Every measure is therefore switched on per virtual host, not with the server firewall. A firewall change would affect all environments at once. The server-level components (the catch-all and the scripts) do not change how existing sites behave.
Overview¶
flowchart LR
V[Visitor] -->|HTTPS| CF[Cloudflare edge
WAF + Access]
CF -->|Cloudflare IP| NG
M[Whitelisted WAN IP
e.g. Uptime Kuma] -->|direct| NG
A[Anyone else
direct to server IP] -->|direct| NG
subgraph NG[nginx on FL09BRE002 / FL10BRE001]
G{geo check
$origin_allowed}
G -->|1 = Cloudflare
2 = whitelist| VH[Virtual host]
G -->|0 = direct| D[444 drop + log]
CA[Catch-all default vhost
unknown host / bare IP] --> D
end
VH --> P{PHP file?}
P -->|index.php, admin folder,
PhpAllowedScripts| FPM[PHP-FPM
display_errors off]
P -->|any other .php| E[404]
D --> L[(direct-origin.log)]
L --> R[Weekly report
SendGrid + Slack]
Measures¶
1. Cloudflare-only access per virtual host¶
Every virtual host that has CloudflareOnly switched on checks the IP address the connection really comes from. The check uses $realip_remote_addr, which is still the real connecting IP after the realip module has replaced $remote_addr with the visitor's IP. The address is matched against a geo map:
$origin_allowed |
Meaning | Result |
|---|---|---|
1 |
Cloudflare edge or localhost | Allowed |
2 |
Whitelisted WAN IP (OriginWhitelist) |
Allowed |
0 |
Anything else | Connection closed (444) and logged |
The check covers ports 80 and 443. A spoofed X-Forwarded-For header does not help an attacker: nginx only trusts that header on connections from Cloudflare.
Generated nginx snippet (per vhost)
# Alleen via Cloudflare (+ OriginWhitelist); directe origin-pogingen loggen en droppen
access_log /var/log/nginx/origin/direct-origin.log direct_origin if=$direct_origin;
if ($origin_allowed = 0) {
return 444;
}
2. Whitelist for specific WAN IPs¶
Some systems legitimately connect directly, for example uptime monitoring. Their addresses are listed in OriginWhitelist. Ansible manages this list, separately from the Cloudflare ranges.
| Host | Purpose |
|---|---|
| FL08PLO-MON01 | Uptime Kuma monitoring |
3. Catch-all default virtual host¶
A dedicated default server (000-default-catchall) answers every request that does not match a known server_name: the bare server IP, an unknown Host header, or an unknown TLS SNI name. It:
- presents a neutral self-signed certificate with a name that cannot be traced to Brekz or Flooris, so a scan of the bare IP does not reveal which sites are hosted,
- logs the request to
direct-origin.log, - closes the connection (
444).
4. Automatic Cloudflare IP updates¶
Cloudflare occasionally changes its IP ranges. Every day at 04:15, cloudflare-ip-update.php (PHP 8.4 CLI) fetches the current ranges from the Cloudflare API. It then:
- validates the response: the API call succeeded, every entry is a valid CIDR, and there are at least 10 IPv4 and 5 IPv6 ranges;
- compares the ranges with the current
geo-ranges.confandrealip.conf, and stops if nothing changed; - backs up both files, writes the new versions atomically and runs
nginx -t; - reloads nginx, or restores the backup if the test fails;
- sends a notification with the added and removed ranges, or with the error.
5. Weekly report of direct origin attempts¶
Every Monday at 08:00, report-direct-origin.php summarises the past seven days of direct-origin.log, including rotated and .gz files. The report contains:
- the total, and the count per day and per port (80/443);
- the count per target: a test vhost or the catch-all;
- the top source IPs,
Hostheaders, SNI names, paths and user agents; - warnings for requests that reached the catch-all through Cloudflare, which most likely means a
server_nameis missing; - whitelisted IPs that hit the catch-all.
The report is stored under /var/log/nginx/origin/reports/ and a summary is sent by SendGrid (with the full report attached) and/or a Slack webhook.
6. Cloudflare Access on the back office¶
The back office path /administrator/ is protected by a Cloudflare Access application, Brekz TEST admin, on all test hostnames and shop aliases. Only @flooris.nl accounts, plus explicitly added Brekz accounts, can sign in.
Measure 1 is a precondition for this one: without it, Access could be bypassed by connecting to the server IP directly.
7. display_errors disabled¶
The PHP-FPM pools of the environment have:
php_admin_flag[display_errors] = off
php_admin_flag[display_startup_errors] = off
php_admin_flag[log_errors] = on
php_admin_flag values cannot be overridden by the application through ini_set(), nor by .user.ini files. Errors are still logged, but they are never shown to visitors, where they could reveal paths, queries or configuration.
8. PHP only through the front controller¶
With PhpFrontControllerOnly switched on, nginx only passes these PHP files to PHP-FPM:
| Application | Allowed |
|---|---|
| PrestaShop | index.php, webservice/dispatcher.php (for /api/), and the admin folder (/administrator/, protected by Access) |
| Laravel / Symfony | index.php |
Every other .php request returns 404, even if the file exists. That blocks direct execution of uploaded or planted scripts, such as webshells in modules/ or upload/, and of configuration files such as config/settings.inc.php. Friendly URLs still work, because they are routed through index.php. Extra scripts can be allowed per vhost with PhpAllowedScripts.
Components¶
All measures are managed with Ansible in the flooris-hosting repository, under webhosting/brekz-new/.
| File | Purpose |
|---|---|
play_server_origin_protection.yml |
Server-level playbook (run first) |
base_files/base-server-origin-protection.yml |
Server-level tasks |
vars_server_origin.yml |
DefaultVhostName, OriginWhitelist, paths, cron times |
cloudflare/cloudflare-ip-ranges.yml |
Initial Cloudflare ranges (the update script takes over afterwards) |
play_web_test.yml |
Test vhosts, with a precheck for the server-level config |
environments/test/vhost_nginx_test/vhost-*.yml |
Opt-in: CloudflareOnly, PhpFrontControllerOnly, PhpAllowedScripts |
environments/test/vars-test.yml |
Opt-in: fpm_display_errors_off |
files/origin-protection/*.php |
PHP scripts |
| Path | Purpose |
|---|---|
/etc/nginx/conf.d/origin-protection.conf |
geo + map + log_format direct_origin |
/etc/nginx/cloudflare/geo-ranges.conf |
Cloudflare ranges for the geo check (maintained by script) |
/etc/nginx/cloudflare/realip.conf |
set_real_ip_from (maintained by script) |
/etc/nginx/sites-available/000-default-catchall |
Catch-all default vhost |
/opt/origin-protection/ |
lib.php, cloudflare-ip-update.php, report-direct-origin.php, settings.php, config.php |
/var/log/nginx/origin/direct-origin.log |
Log of direct attempts |
/var/log/nginx/origin/reports/ |
Weekly reports (kept for 1 year) |
/var/log/nginx/origin/cron.log |
Output of both cron jobs |
/etc/logrotate.d/nginx-origin-protection |
Weekly rotation, 12 weeks |
| Schedule | Job |
|---|---|
| Daily 04:15 | cloudflare-ip-update.php |
| Monday 08:00 | report-direct-origin.php |
Opt-in templates
The shared nginx and PHP-FPM templates are also used by accept and production. Every change only takes effect when its variable is set. Before rollout, the accept and production playbooks were rendered and compared with the previous version: the output was byte-identical.
Operations¶
Add an IP address to the whitelist
- Add the entry to
OriginWhitelistinvars_server_origin.yml:OriginWhitelist: - { ip: <existing-ip>, comment: "FL08PLO-MON01 Uptime Kuma" } - { ip: <new-ip>, comment: "<host and purpose>" } - Run
./run_server_origin_protection.shfromwebhosting/brekz-new/.
Set or change notification credentials
Credentials live only on the servers, in /opt/origin-protection/config.php (mode 0600). Ansible creates this file once and never overwrites it. Change it on both servers, then test:
php8.4 /opt/origin-protection/report-direct-origin.php --test-notify
Run the report or IP update manually
php8.4 /opt/origin-protection/report-direct-origin.php --no-notify --stdout # report only
php8.4 /opt/origin-protection/report-direct-origin.php --days=30 --no-notify --stdout
php8.4 /opt/origin-protection/cloudflare-ip-update.php --dry-run # show changes only
A legitimate PHP script returns 404
Add the script, relative to the document root, to PhpAllowedScripts in the vhost file, then run the play for that vhost again (for example --tags "prestashop").
Rollback
- Cloudflare-only or PHP restriction for one vhost: set
CloudflareOnly: falseorPhpFrontControllerOnly: falsein the vhost file and run that play again. - Catch-all:
rm /etc/nginx/sites-enabled/000-default-catchall && nginx -t && systemctl reload nginx
Known limitations and follow-up¶
| Topic | Description | Follow-up |
|---|---|---|
| Accept and production | So far only test (*.ftest.nl) is protected. |
Roll out to accept, then production (BR-8191). |
| Server firewall | UFW still allows 80/443 from anywhere. The restriction is enforced in nginx. | Once all environments are behind Cloudflare, limit UFW 80/443 to the Cloudflare ranges and the whitelist. |
| Bypass through another Cloudflare account | Someone could point a hostname in their own Cloudflare account at the server IP and override the Host header. That traffic comes from Cloudflare IPs, so it passes the geo check, and it skips the Brekz Access application. |
Add Authenticated Origin Pulls (mTLS with a zone-specific certificate), or validate the Cloudflare Access JWT (Cf-Access-Jwt-Assertion) at the origin for /administrator/. |
| Case variations in the admin path | nginx matches /administrator/ case-insensitively. Check that the Access application also covers /ADMINISTRATOR/. |
Verify during the Access rollout. If it is not covered, enforce lowercase in nginx. |