Skip to content

Add nginx vhosts, so hardening is not Apache-only - #97

Merged
HafizMMoaz merged 1 commit into
mainfrom
feat/88-nginx-hardening
Jul 27, 2026
Merged

Add nginx vhosts, so hardening is not Apache-only#97
HafizMMoaz merged 1 commit into
mainfrom
feat/88-nginx-hardening

Conversation

@HafizMMoaz

Copy link
Copy Markdown
Member

Fixes #88.

Hardening lived entirely in the two .htaccess files. nginx reads neither, and there was nothing in the repo to copy from, so an nginx deploy served /.env and /.git/config to anyone who asked.

What's here

deploy/nginx/ with one vhost per deployment shape:

File Document root Use when
zerp-public-docroot.conf <repo>/public You control the vhost. Preferred: the backend tree is not under the webroot at all, so nothing has to hold for it to stay private.
zerp-repo-docroot.conf <repo> The document root is fixed at the repo root by a panel, which is the shape the Hostinger deploy uses. Here the deny rules are load-bearing, exactly as the root .htaccess is.

Plus README.md with a directive-by-directive mapping from .htaccess, and verify-hardening.sh, which runs the RELEASE_README checklist against a live host and exits non-zero if anything is exposed, so it can gate a deploy. It works against Apache too.

README.md and deploy/RELEASE_README.md now say plainly that the .htaccess check does not apply on nginx, which was the trap.

Gaps found while mapping the rules

Three rules go beyond what the .htaccess does today:

  • vendor/ is denied. Not in the .htaccess list, and public/vendor/ does not exist, so nothing legitimate is served from that prefix while every request to it reached Composer package source under the webroot.
  • packages/*.{php,json,lock,md,env,yml,sh} is denied. public/packages is a tree of symlinks into vendor/zerp/<pkg>, so each module's own composer.json and source sit behind that prefix. The .htaccess rule covering those filenames is anchored at the root (^(composer\.(json|lock)|...)$) and never reached them. Confirmed: /packages/Pos/composer.json returned 200 before this rule. Module images still serve.
  • Only the front controller executes PHP. Everything else matching \.php$ returns 403, so a stray .php under the webroot (an upload, a leftover installer) cannot run. Under Apache any such file executes.

deploy/ was in neither deny list, so it is added to the .htaccess too and Apache and nginx stay in step. publish.sh strips deploy/ from the release tree, so Hostinger is unaffected either way; a deploy straight from this repo was not.

The vhosts are deliberately not published into zerp-release: a .conf sitting in public_html would itself be fetchable. RELEASE_README links to them here instead.

Verification

Not just nginx -t. Both configs were run under a real nginx (1.31.3) against a tree representing the deployed layout, including the public/storage symlink into storage/app/public and the public/packages module symlinks. Every path was requested against both vhosts:

--- must be denied ---                    repo   public
/.env                                     403    403
/.git/config                              403    403
/storage/logs/laravel.log                 403    403
/config/database.php                      403    403
/app/Http/Kernel.php                      403    403
/vendor/autoload.php                      403    403
/uploaded-shell.php                       403    403
/packages/Pos/composer.json               403    403
/storage/app/public/media/avatar.png      403    403
/deploy/deploy.sh                         403    404*
/composer.lock                            403    404*
/README.md                                403    404*

--- must be served ---
/build/manifest.json                      200    200
/storage/media/avatar.png                 200    200
/packages/Pos/logo.png                    200    200
/.well-known/acme-challenge/token         200    200

--- must reach the app ---
/                                       front  front
/login                                  front  front
/index.php                              front  front
/build/  /js/                           front  front

* these are not under the webroot on the public-docroot vhost, so the app answers 404, which is as private as a deny.

Worth noting from that table: /index.php reaches the front controller rather than being served as source (the naive try_files $uri ... would have leaked the repo-root shim), directory URLs fall through to Laravel's 404 rather than 403-ing on autoindex and confirming the directory exists, and .well-known stays reachable so ACME renewals keep working.

No application code is touched.

Web server hardening lived entirely in the two .htaccess files. nginx reads
neither: served from an nginx webroot with no configuration of its own, the
app hands out /.env and /.git/config to anyone who asks, and there was
nothing in the repo to copy from.

deploy/nginx/ adds the equivalent for both deployment shapes: a document root
at public/ (preferred, and safe by construction) and a document root fixed at
the repo root, which is what the Hostinger deploy uses and where the deny
rules are load-bearing. README.md maps each .htaccess directive to what
replaces it, and notes the nginx location-precedence rule that decides
whether a deny fires at all.

Three rules go beyond the .htaccess, and mapping the rules surfaced the gaps:

- vendor/ is denied. It is not in the .htaccess list and public/vendor/ does
  not exist, so every request to that prefix reached Composer package source
  under the webroot.
- packages/*.{php,json,lock,md,env,yml,sh} is denied. public/packages is a
  tree of symlinks into vendor/zerp/<pkg>, so each module's own composer.json
  sits behind it; the .htaccess rule covering those filenames is anchored at
  the root and never reached them. Module images still serve.
- Only the front controller executes PHP, so a stray .php under the webroot
  cannot be run.

deploy/ was missing from both deny lists and is added to the .htaccess too,
so Apache and nginx stay in step.

verify-hardening.sh runs the RELEASE_README checklist against a live host and
exits non-zero if anything is exposed, for either web server.

Both vhosts were checked with nginx -t and then exercised against a running
nginx over a representative tree: every sensitive path 403s, module images,
uploads and the build manifest still serve, and the front controller still
routes.
@HafizMMoaz
HafizMMoaz requested a review from a team as a code owner July 27, 2026 19:50
@HafizMMoaz
HafizMMoaz merged commit 7e12392 into main Jul 27, 2026
1 check passed
@HafizMMoaz
HafizMMoaz deleted the feat/88-nginx-hardening branch July 27, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Web Server Hardening Limited to Apache — No Nginx Equivalent

1 participant