Skip to content

Denied paths and .htaccess ​

nginx.deniedPaths declares directories or URL patterns NGINX refuses for every caller. Rules render into a ConfigMap mounted at /etc/nginx/denied-paths.d/10-chart.conf and are enforced in the rewrite phase — before any location is chosen and before the PHP execution allowlist answers. They are versioned with your values, not written onto the volume.

yaml
nginx:
  deniedPaths:
    - path: /wp-content/uploads/dlm_uploads
      action: deny              # 403 (default)
      reason: Download Monitor - force downloads through the permission check
    - path: ~* ^/wp-content/plugins/retired-plugin/
      action: honeypot          # 418
      reason: plugin no longer installed here - only scanners ask for it
  htaccessPolicy: warn          # warn (default) | fail
ParameterDescriptionDefault
nginx.deniedPaths[].pathDirectory path (prefix match on the directory — /a/b does not match /a/bc) or, starting with ~, a verbatim NGINX regex—
nginx.deniedPaths[].actiondeny answers 403, honeypot answers 418deny
nginx.deniedPaths[].reasonRendered as a comment above the rule—
nginx.htaccessPolicywarn logs .htaccess directories not covered by deniedPaths; fail refuses to start the pod while any is uncoveredwarn

Rendered, the example above becomes:

nginx
# Download Monitor - force downloads through the permission check
"~*^/wp-content/uploads/dlm_uploads(/|$)" 403;

# plugin no longer installed here - only scanners ask for it
"~*^/wp-content/plugins/retired-plugin/" 418;

Why not a location in the per-site nginx.conf ​

The obvious rule — location /wp-content/uploads/dlm_uploads { deny all; } — does not do what it looks like it does. A plain prefix location loses to any matching regex location, and the image has one for static files covering zip|doc|xls|ppt|tar|…. So that rule refuses a .pdf (not in the list) and serves a .zip (in the list), which is precisely backwards for a download plugin. It takes location ^~ to win, and even that cannot produce a honeypot status on a .php path, because the PHP allowlist has already answered 403 in the rewrite phase. deniedPaths runs in that same phase, ahead of the allowlist, and never enters location matching at all.

Why honeypot ​

A 418 is a deliberate signal, not an error. If your edge runs a CrowdSec scenario that bans any source receiving a 418 (http-teapot-ban or similar), a honeypot rule on a path that no visitor has a reason to request, such as the directory of a plugin the site no longer has, makes probers ban themselves, whatever throwaway IP they use next — no per-IP rules to maintain. Without such a scenario, honeypot is just a more conspicuous deny.

.htaccess files: the plugin thinks it is protected ​

Plugins that write a .htaccess into their directory assume Apache. NGINX never reads the file, so whatever it was meant to do is not in effect, the plugin reports nothing wrong, and nothing else does either. The init container looks for such files under wp-content on every start and reports each one — classified by content, because "an .htaccess means deny the directory" is wrong often enough to do damage:

classwhat the file sayswhat the report does
denydeny from all / Require all denied, no exceptionsnarrowed by what it denies (below); the only class that can block the start under fail
partialdeny plus <Files> allow exceptions (e.g. Contact Form 7's captcha directory, which must serve its images)reports it; a directory deny would break the exception, so nothing is proposed — and if you list it anyway, the report warns
allowallow from all / Require all granted, in particular for .php in an upload directoryflags it as SUSPICIOUS: that is not a protection but the usual companion of a webshell. NGINX ignores it; find out who wrote it and when
otherrewrites, headers, mime, Optionsinformational
plugin-managedthe directory already has a location or rewrite in the site's /wordpress/nginx.confreported as such, whatever the class — W3 Total Cache writes its NGINX rules there itself, and a deniedPaths entry would run first and override them
Checking wp-content for .htaccess files …
  plugin-managed: /wp-content/plugins/w3-total-cache/pub/.htaccess - translated in the site's nginx.conf
  SUSPICIOUS:     /wp-content/uploads/2014/05/.htaccess (<Files .*>)
                  This file ALLOWS access - PHP or dotfiles - rather than denying it.
                  In an upload directory that is the usual companion of a webshell.
                  NGINX ignores it, but find out who wrote it and when.
  partial:        /wp-content/uploads/wpcf7_captcha/.htaccess (Order deny,allow) - deny with exceptions; not a candidate for deniedPaths
  UNCOVERED:      /wp-content/ai1wm-backups/.htaccess (deny from all)
    NGINX does not read this file; the directory is open. To close it:
      - path: /wp-content/ai1wm-backups
        action: deny
        reason: .htaccess found (deny from all)

With htaccessPolicy: fail the pod refuses to start while a deny-class file is uncovered. That is the strict reading — a site should not come up with a protection its plugin explicitly asked for silently missing — but it is an opt-in: the .htaccess typically appears at runtime, on a plugin update, and a start-time check only sees it on the next rollout, possibly weeks later. Failing an unrelated deploy then protects nothing that was not already exposed. Enable fail for new sites, quarantined sites, or where a compliance rule demands it. partial, allow and other never block, because there is nothing a deniedPaths entry could correctly add for them.

A deny is narrowed by what it actually denies, because proposing a directory deny for every one of them breaks sites:

the deny coversexamplewhat the report does
the whole directorywflogs/, uploads/wp-statistics/proposes the directory
file extensions<Files *.php> in uploads/; Wordfence's <FilesMatch "\.php$"> in lib/, views/, waf/; <FilesMatch "\.(php|phtml)$">judged per extension. php is covered with phpExecutionAllowlist.mode: enforce — NGINX answers 403 for that PHP — unless an extraAllowedPaths entry lets a PHP file there run. Every other extension (phtml, phps, phar, log) NGINX serves as a plain file, so the report proposes a regex closing exactly those extensions — never the directory: for uploads/ that would block every image
named files<Files debug.log>proposes one regex per name, covering the directory and its subdirectories — <Files> governs both
dotfiles<Files .ht*>, <FilesMatch "^\.">covered: NGINX denies dotfiles everywhere

Any other pattern counts as a whole-directory deny, including a <FilesMatch> that looks like a file name: <FilesMatch "debug.log"> is an unanchored regex in which . is any character, so it also guards debug.log.1. The report proposes nothing narrower than it can state exactly. A plugin-managed directory (above) stays plugin-managed whatever the deny covers.

Coverage tests paths against every deniedPaths entry in the form NGINX gets it — a case-insensitive regex, plain paths included — with busybox grep -iE. A directory (or an extension or name in it) counts as closed only when a new file — in the directory and in a subdirectory, which an .htaccess governs too — and every file it holds now are denied, so an entry that matches only the directory URL, only the top level, or only some names, does not count. Busybox reads ERE, NGINX PCRE; only entries in the subset both read alike count — one that does not compile, or that uses \d, \w, (?…), {,n} or a possessive quantifier, covers nothing in the report, so its directory shows as uncovered rather than being assumed closed. extraAllowedPaths errs the other way: when an entry cannot be read as NGINX reads it, or lets PHP run in that directory — PHP that exists or PHP added later — no PHP deny there is credited to the allowlist. The check matches file content, never evaluates it; the first directive line goes into the log as data.

Distributed under the Apache-2.0 license. WordPress, NGINX, Bitnami and other names are trademarks of their owners; this project is not affiliated with or endorsed by them.