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.
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| Parameter | Description | Default |
|---|---|---|
nginx.deniedPaths[].path | Directory path (prefix match on the directory — /a/b does not match /a/bc) or, starting with ~, a verbatim NGINX regex | — |
nginx.deniedPaths[].action | deny answers 403, honeypot answers 418 | deny |
nginx.deniedPaths[].reason | Rendered as a comment above the rule | — |
nginx.htaccessPolicy | warn logs .htaccess directories not covered by deniedPaths; fail refuses to start the pod while any is uncovered | warn |
Rendered, the example above becomes:
# 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:
| class | what the file says | what the report does |
|---|---|---|
deny | deny from all / Require all denied, no exceptions | narrowed by what it denies (below); the only class that can block the start under fail |
partial | deny 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 |
allow | allow from all / Require all granted, in particular for .php in an upload directory | flags 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 |
other | rewrites, headers, mime, Options | informational |
| plugin-managed | the directory already has a location or rewrite in the site's /wordpress/nginx.conf | reported 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 covers | example | what the report does |
|---|---|---|
| the whole directory | wflogs/, 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.