Skip to content

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.


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:

  1. validates the response: the API call succeeded, every entry is a valid CIDR, and there are at least 10 IPv4 and 5 IPv6 ranges;
  2. compares the ranges with the current geo-ranges.conf and realip.conf, and stops if nothing changed;
  3. backs up both files, writes the new versions atomically and runs nginx -t;
  4. reloads nginx, or restores the backup if the test fails;
  5. 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, Host headers, SNI names, paths and user agents;
  • warnings for requests that reached the catch-all through Cloudflare, which most likely means a server_name is 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
  1. Add the entry to OriginWhitelist in vars_server_origin.yml:
    OriginWhitelist:
      - { ip: <existing-ip>, comment: "FL08PLO-MON01 Uptime Kuma" }
      - { ip: <new-ip>, comment: "<host and purpose>" }
    
  2. Run ./run_server_origin_protection.sh from webhosting/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: false or PhpFrontControllerOnly: false in 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.