The Starting Point
When we replaced WordPress with a static Astro site on our GCP Compute Engine instance, we eliminated PHP, MySQL, and the entire dynamic application layer. That alone removed the most common attack vectors — SQL injection, authentication bypass, plugin vulnerabilities, and XML-RPC abuse.
But a static site behind nginx still has a meaningful attack surface. The default nginx configuration leaks server information, sends no security headers, and does nothing to limit abusive traffic. Here's what we hardened and why each change matters.
1. Hide the Server Version
server_tokens off;
By default, nginx includes the full version string in every response header:
Server: nginx/1.24.0 (Ubuntu). This tells an attacker exactly which CVEs to check.
With server_tokens off, the header becomes just Server: nginx —
still identifies the technology, but removes the version fingerprint.
2. Security Headers
Seven headers, each addressing a specific browser-side attack vector:
| Header | Value | Purpose |
|---|---|---|
X-Frame-Options | SAMEORIGIN | Prevents clickjacking by blocking iframe embedding from other origins |
X-Content-Type-Options | nosniff | Stops browsers from MIME-sniffing responses away from declared content type |
X-XSS-Protection | 1; mode=block | Enables legacy browser XSS filters (redundant with CSP but still useful for older browsers) |
Referrer-Policy | strict-origin-when-cross-origin | Limits referrer information sent to external sites — origin only, no path |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Explicitly denies access to device APIs the site never needs |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Forces HTTPS for one year — browsers will refuse HTTP connections entirely |
Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline'; ... | Restricts resource loading to same origin — blocks injected scripts, styles, and iframes |
3. TLS Configuration
The default nginx.conf shipped with TLS 1.0 and 1.1 enabled. Both are deprecated
(RFC 8996) and vulnerable to BEAST and POODLE attacks. We restricted to TLS 1.2 and 1.3 only:
ssl_protocols TLSv1.2 TLSv1.3;
Let's Encrypt's options-ssl-nginx.conf already enforced this at the server
block level, but we also fixed the global nginx.conf to prevent any other virtual
host from falling back to insecure protocols.
4. Rate Limiting
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req zone=general burst=20 nodelay; Without rate limiting, a single client can hammer the server with thousands of requests per second — useful for scraping, enumeration, or simple resource exhaustion. The rate limit allows 10 requests per second per IP with a burst buffer of 20, which is generous for legitimate browsing but stops automated abuse.
5. Block WordPress Attack Paths
location ~* (wp-admin|wp-login|wp-content|wp-includes|xmlrpc|\.php|\.env|\.git) {
return 444;
}
The server previously ran WordPress. Automated scanners will continue probing for
wp-login.php, xmlrpc.php, and .env files for months or
years. Instead of returning a 404 (which confirms nginx is running and processing requests),
we return 444 — nginx's special "close connection with no response" code.
The scanner gets a connection reset, as if the port were closed.
6. Static Asset Caching
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
Astro generates hashed filenames for all bundled assets (CSS, JS) in the /_astro/
directory. Since the filename changes when the content changes, these can be cached indefinitely.
We also disable access logging for static assets to keep the Splunk dashboard focused on
meaningful page hits rather than asset requests.
7. fail2ban Jails
fail2ban monitors log files and automatically bans IPs that match attack patterns:
| Jail | Watches | Ban Time |
|---|---|---|
| sshd | Failed SSH logins | 2 hours (3 attempts) |
| nginx-http-auth | Failed nginx auth | 1 hour (5 attempts) |
| nginx-botsearch | 404 scanning patterns | 2 hours (5 attempts) |
| nginx-badbots | wp-login, .env, .git, phpmyadmin probes | 24 hours (3 attempts) |
The nginx-badbots jail uses a custom filter that matches requests for common
attack paths. Three hits from the same IP and they're banned for 24 hours at the firewall
level — before nginx even processes the request.
8. Custom 404 Page
The default nginx 404 page includes the server version and looks unprofessional. We replaced it with a branded page that matches the site design and includes navigation back to the home page and blog. Small detail, but it matters for both professionalism and information disclosure.
What Was Already in Place
- Let's Encrypt TLS with automatic renewal via Certbot
- HTTP to HTTPS redirect for all traffic
- UFW firewall allowing only ports 22, 80, and 443
- SSH restricted to key-based authentication only
- Static site architecture — no PHP, no database, no dynamic attack surface
- Nginx access logs forwarded to Splunk via rsyslog over WireGuard tunnel for centralized monitoring
DAST Validation
After applying all hardening changes, we ran automated Dynamic Application Security Testing using two industry-standard scanners from an internal Docker host:
Nuclei (ProjectDiscovery) — 9,863 Templates
Nuclei ran its full template library against the site: CVE checks, misconfiguration detection, exposure scanning, and technology fingerprinting. Results: zero critical, high, medium, or low findings. All 19 detections were informational:
- WAF detected (nginx rate limiting)
- TLS certificate and version detection (TLS 1.2/1.3 confirmed)
- Technology detection (nginx)
- SSH service detection
Remediated during the scan cycle: SSH SHA-1 HMAC algorithms were disabled, and five missing
cross-origin security headers were added (X-Permitted-Cross-Domain-Policies,
Cross-Origin-Embedder-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy).
The CSP was also tightened — unsafe-inline removed from script-src,
default-src changed from 'self' to 'none',
and explicit directives added for worker-src, object-src,
manifest-src, and media-src.
OWASP ZAP Baseline — Spider + Passive Scan
ZAP spidered 35 URLs and ran passive security checks. Results:
0 FAIL, 6 WARN, 60 PASS. The warnings were exclusively about nginx's
add_header behavior: child location blocks (for static asset caching) clear parent
headers. Remediated by duplicating all security headers in every location block.
Automated Pipeline
Both scanners are deployed as ephemeral Docker containers on our internal infrastructure, scheduled to run weekly via cron. Results are output as JSON to a monitored directory and ingested into Splunk Enterprise via Universal Forwarder for continuous audit trail compliance. No scanner runs as root.
Next Steps
- Run external validation via Mozilla Observatory, SSL Labs, and SecurityHeaders.com
- Integrate a dedicated security scanning agent ("Loki") into the AI Agent Swarm for continuous internal and external assessment
- Set up Splunk alerts for fail2ban ban events and DAST findings above informational severity
- Add Subresource Integrity (SRI) hashes if external scripts are ever added