0% прочитано

How I Protected Laravel from Sensitive-Path Scanners with Nginx and Fail2ban

After investigating automated scanning, I moved sensitive-path protection in front of Laravel. Nginx returns 403 immediately, a dedicated security log records the probe, Fail2ban reads that log, and nftables temporarily blocks the source on HTTP and HTTPS.

10 жовтня 2026 р. 12 хв читанняNginx

In the previous article I covered the investigation of the suspicious traffic: sudden traffic spikes, repeated IP addresses, sequential page crawling, and probes for /.env, /app/.env, /.git, and wp-config.php.

The investigation also exposed another problem: a dangerous request did not always stop where I expected.

/app/.env was particularly revealing. Because of Nginx location precedence, the request could reach a separate proxy path and end with 500 instead of being rejected early.

I changed the protection boundary after that.

A high-confidence dangerous request should reach neither Laravel nor another internal service.

The goal is not to identify every bot. I block only a narrow set of requests with a very high probability of being sensitive-file probes.

A Dangerous Request Is Better Stopped Before Laravel

Reject as early as possible
Less desirable: Internet   |   vNginx   |   vLaravel / another service   |   vprocessing   |   vreject 

A request for /.env does not need Laravel to boot, middleware to run, a database query, or another internal service to receive it.

If the path clearly belongs to a forbidden set, the web server can terminate the request earlier.

This does not replace application security. It is simply a better boundary for a request that should never enter the application.

Why a Regex Location Alone Was Not Enough

Simplified example of conflicting rules
location ^~ /app/ {    proxy_pass http://realtime_upstream;} location ~ /\. {    deny all;}

A general hidden-file rule looked sufficient until a higher-priority prefix location existed alongside it.

location ^~ /app/ changes location selection. Once selected, ordinary regex locations are no longer evaluated.

Because of that, /app/.env could bypass the general hidden-file rule.

I decided not to make sensitive-path protection depend on which application location wins.

How the Protection Flow Works

Two protection layers
HTTP request    |    vNginx    |    | sensitive path?    |    +---- no ----> normal processing    |    yes    |    v403    |    vdedicated security log
Protection flow: the request reaches Nginx, a dangerous probe is written to a dedicated security log, Fail2ban reads the log, and nftables temporarily blocks the IP.
Protection flow: Request -> Nginx -> Security Log -> Fail2ban -> nftables.

There are two separate responsibilities here.

Nginx decides what to do with the current request and immediately returns 403.

Fail2ban observes the resulting event in the log and decides whether the source should be temporarily blocked from making further requests.

Fail2ban does not replace the Nginx rule. It adds a second layer after the request has already been rejected correctly.

I Detect Sensitive Paths Before Proxying

Simplified map for high-confidence paths
map $uri $sensitive_path {    default 0;     ~*^/\.env(?:$|/)       1;    ~*^/app/\.env(?:$|/)   1;    ~*^/\.git(?:$|/)       1;    ~*^/wp-config\.php$    1;}

map $uri provides a dedicated sensitive-path signal independent of an application location.

I intentionally do not encode every suspicious URL that can appear on the internet.

For automatic banning, a short list of high-confidence indicators is safer than an aggressive rule with many false positives.

I Write Dangerous Requests to a Dedicated Security Log

Dedicated log and early rejection
log_format sensitive_path    '$remote_addr $time_iso8601 '    '"$request" $status'; server {    access_log        /var/log/nginx/security-sensitive.log        sensitive_path        if=$sensitive_path;     if ($sensitive_path) {        return 403;    }     # Normal locations continue below.}

A dedicated log simplifies the next protection layer.

Fail2ban does not need to inspect every ordinary Nginx access-log entry and distinguish a normal 404 from an .env probe.

The dedicated file contains requests that already matched the narrow sensitive-path rule.

I Validate Nginx Before Applying Changes

Validate before applying
sudo nginx -t sudo systemctl reload nginx

I do not blindly reload Nginx after editing its configuration.

First, nginx -t must confirm that the configuration is valid.

Only then do I reload Nginx.

I Verify Nginx Before Enabling Automatic Bans

Local server-side check
DOMAIN=example.com curl -k -I \  --resolve "$DOMAIN:443:127.0.0.1" \  "https://$DOMAIN/app/.env"

Using --resolve, the domain can be directed to 127.0.0.1 without changing DNS.

The expected result for a sensitive path is 403.

I perform this before enabling automatic banning or only when loopback addresses are guaranteed to be ignored by Fail2ban.

Fail2ban Reads Only the High-Confidence Security Log

Install Fail2ban on Ubuntu
sudo apt update sudo apt install fail2ban

I do not ban an address merely because it generated many views, used a mobile browser, or came from a particular country.

For this rule, the event is an already-recognized sensitive-path probe.

The Fail2ban Filter Can Stay Simple

/etc/fail2ban/filter.d/nginx-sensitive-paths.conf
[Definition] failregex = ^<HOST>\s+\S+\s+".*"\s+403$ ignoreregex =

The filter does not try to determine whether the URL is dangerous again.

Nginx already made that decision.

Fail2ban reads the dedicated log and extracts the source IP from an event that ended with 403.

One High-Confidence Event Is Enough

Example dedicated jail
[nginx-sensitive-paths] enabled = true filter = nginx-sensitive-paths logpath =    /var/log/nginx/security-sensitive.log maxretry = 1 findtime = 600 bantime = 86400 port = http,https

maxretry = 1 is deliberate here.

I would not use that threshold for broad heuristics. But an ordinary reader has no reason to request /.env or /.git.

After one such event, the address is banned for 86400 seconds, or 24 hours.

The Ban Applies Only to HTTP and HTTPS

This rule is about web traffic.

The ban is therefore limited to ports 80 and 443.

I do not add SSH to this rule. Administrative-access protection is a separate concern.

Fail2ban Applies the Temporary Ban Through nftables

The next connection is blocked before Nginx
Nginx   |   vsecurity log   |   vFail2ban   |   vnftables   |   Xnext HTTP connection

The first dangerous request reaches Nginx, receives 403, and creates a log event.

Fail2ban then installs a temporary nftables rule.

Subsequent web connections from the banned address no longer reach Nginx.

UFW and nftables Can Serve Different Roles

UFW can remain responsible for persistent server access policy while Fail2ban adds temporary rules through nftables.

I care about verifying the actual result after a ban rather than relying only on one tool name.

I do not change general firewall or SSH policy as part of this task.

I Validate Fail2ban Before Relying on It

Validate configuration and status
sudo fail2ban-client -t sudo fail2ban-client status sudo fail2ban-client status \  nginx-sensitive-paths

fail2ban-client -t is as important to me as nginx -t.

After startup I verify both the overall Fail2ban status and the dedicated sensitive-path jail.

I Verify the Actual Ban in nftables

Inspect network rules
sudo nft list table inet f2b-table

Fail2ban status shows Fail2ban state itself.

I also inspect nftables to confirm that the corresponding network rule actually exists.

I Test Both Dangerous and Normal Paths

Four dangerous and four control examples
Should match: /.env/app/.env/.git/config/wp-config.php  Should not match: //posts/example/assets/app.css/app/socket

I test more than whether dangerous URLs match.

It is equally important to prove that ordinary application paths do not match the same rule.

In the verification, four dangerous examples matched while four control examples did not produce a false match.

I Use a Reserved Test Address Instead of a Real Visitor IP

Reserved documentation address
192.0.2.123

For synthetic verification I use an address from a documentation-reserved range.

This allows ban and unban behavior to be tested without copying a real visitor address into commands or a public article.

I Test the Full Automatic Ban Path Separately

Synthetic security-log event
TEST_IP=192.0.2.123 printf '%s %s "%s" %s\n' \  "$TEST_IP" \  "$(date --iso-8601=seconds)" \  'GET /.env HTTP/1.1' \  '403' \  | sudo tee -a \      /var/log/nginx/security-sensitive.log

Manually adding an IP to nftables proves only that a network ban is technically possible.

I wanted to verify the full path: log -> Fail2ban -> nftables.

In my verification, the synthetic event produced an automatic ban in about two seconds.

I Remove the Test Ban After Verification

Cleanup after the test
sudo fail2ban-client set \  nginx-sensitive-paths \  unbanip 192.0.2.123 sudo fail2ban-client status \  nginx-sensitive-paths

A successful test is not complete until its test state has been cleaned up.

After verification, the test address was unbanned and no active test bans remained.

During normal operation Fail2ban removes a temporary ban automatically after bantime expires.

I Apply Protection Changes Through Check, Apply, and Status Steps

Example guarded application flow
sudo ./server-protection.sh --check sudo ./server-protection.sh --apply sudo ./server-protection.sh --status

I do not want web-server and network-protection changes applied through a collection of ad hoc manual commands.

I first back up the relevant configuration, run a separate check, review the result, and only then allow the change to be applied.

A dedicated status mode makes the resulting state easy to verify.

A clear rollback path is equally important.

What I Deliberately Do Not Ban with This Rule

I do not ban:

  • all addresses from a particular country;
  • mobile devices;
  • a particular browser;
  • every client with a high request count;
  • ordinary 404 responses;
  • SSH through this web-specific rule.

Each of these signals can have legitimate explanations.

I reserve automatic banning for events where the false-positive risk is much lower.

What I Avoid During This Kind of Server Change

I keep several operational safety rules separate from the configuration itself:

  • I do not probe dangerous URLs from my real public IP after automatic banning is enabled;
  • I do not change SSH without a separate reason and separate verification;
  • I do not rebuild general firewall policy as part of a small Nginx change;
  • I do not run apt autoremove merely because Fail2ban was installed;
  • I do not reboot the whole server when the change does not require it;
  • I do not rewrite historical analytics because a new protection rule was introduced.

What I Verified Before Leaving the Protection Enabled

Full-chain verification
Path detection 4 dangerous-> detected 4 controls-> not detected  Nginx sensitive path-> 403  Fail2ban

One Successful Test Does Not Prove There Will Be No False Positives

The automated test proved that the technical chain works.

After enabling it, I still observe the dedicated log, ban counts, and the URLs that caused those bans.

This is especially important for a rule using maxretry = 1.

If a legitimate flow ever matches the rule, I would rather narrow the rule than dismiss a false positive as the cost of protection.

My Checklist Before Enabling Automatic Banning

I verify:

  • Does Nginx match only high-confidence sensitive paths?
  • Do normal URLs remain unmatched?
  • Does a forbidden path return 403 before proxying?
  • Does the event appear in the dedicated security log?
  • Does nginx -t pass?
  • Does fail2ban-client -t pass?
  • Is only the expected dedicated jail active?
  • Are only ports 80/443 affected?
  • Is SSH untouched?
  • Does the ban actually appear in nftables?
  • Does the automatic test work from log event to nftables?
  • Is the test address unbanned after verification?
  • Is there a backup and a clear rollback path?

Protection Works in Layers, Not Through One Giant Rule

After this change, a dangerous request no longer depends on accidentally matching the right Nginx location.

Nginx recognizes a narrow set of sensitive paths and returns 403 before Laravel or another internal service is involved.

A dedicated log turns that rejection into a clear security event.

Fail2ban observes the event, while nftables temporarily blocks subsequent web connections from the same source.

I still do not treat the system as a universal bot detector or block broad visitor groups based on weak signals.

A dangerous request is better rejected at the web-server layer, while automatic network blocking should be reserved for signals precise enough to justify it.

Nginx stops the dangerous request now. Fail2ban and nftables reduce the next requests from the same source.