Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions docs/deployment/PHASE_A_INFRASTRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- [ ] Step 9 — S3 bucket
- [ ] Step 10 — CloudFront distribution
- [ ] Step 11 — Route 53 DNS records
- [ ] Step 12 — Cloudflare DNS migration (free DDoS + edge protection)

---

Expand Down Expand Up @@ -431,6 +432,104 @@ mkdir -p /app

---

## Step 12 — Cloudflare DNS Migration

Moves DNS from Route 53 to Cloudflare so flood attacks are absorbed at Cloudflare's edge before CloudFront or ALB ever see the traffic — eliminating the billing impact of DDoS attacks. All AWS services stay completely unchanged.

### 12a. Sign up and add domain

1. Go to **cloudflare.com → Sign up → Free plan**
2. Click **Add a Site → `buffden.com` → Free plan**
3. Select **Import DNS records automatically** — Cloudflare scans Route 53 records

### 12b. Fix imported DNS records

Cloudflare's auto-import is incomplete. Manually verify and correct all records:

| Type | Name | Content | Proxy |
| --- | --- | --- | --- |
| CNAME | `go` | `dualstack.tinyurl-alb-xxx.us-east-1.elb.amazonaws.com` | Orange (Proxied) |
| CNAME | `tinyurl` | `d1anlbbmfo4elu.cloudfront.net` | Orange (Proxied) |
| A | `ems` | `100.25.10.178` | Orange (Proxied) |
| CNAME | `portfolio` | `buffden.github.io` | Grey (DNS only) |
| CNAME | `_2a3ec5a40d53220a744f6e248e46f22b` | `_bf955b229028da621bc467ed086b9ddc.jkddzztszm.acm-validations.aws` | Grey (DNS only) |

> The `go` record is likely imported as two raw A records (IPs). Delete them and add a single CNAME pointing to the ALB hostname — ALB IPs change, the hostname does not.
>
> The ACM validation CNAME (`_2a3ec5a...`) **must be grey cloud (DNS only)**. If proxied, AWS cannot reach it to auto-renew your SSL certificate.

### 12c. Change nameservers at Namecheap

Only do this after 12b is complete and all records are verified.

1. Log in to **namecheap.com → Domain List → Manage → buffden.com**
2. Under **Nameservers → Custom DNS**
3. Replace all 4 `awsdns` nameservers with the 2 Cloudflare nameservers shown in your dashboard
4. Save — propagation takes 5–30 minutes

**Rollback:** restore the 4 Route 53 nameservers at Namecheap at any time. The Route 53 hosted zone is never deleted.

### 12d. Configure SSL and security settings

**SSL/TLS → Overview:** set mode to **Full**

> Do not use Flexible — CloudFront enforces HTTPS and Flexible causes an infinite redirect loop.

**SSL/TLS → Edge Certificates:**

- Always Use HTTPS → **On**
- Minimum TLS Version → **TLS 1.2**

**Security → Settings:**

- Security Level → **Medium**
- Browser Integrity Check → **On**

### 12e. Add rate limit rule

**Security → WAF → Rate limiting rules → Create rule:**

```text
Rule name: Protect URL creation endpoint
Field: URI Path equals /api/urls
Characteristics: IP address
Rate: 20 requests per 10 seconds
Action: Block
Duration: 10 seconds (free plan maximum)
```

> Free plan allows 1 rate limiting rule. The SPA (`tinyurl.buffden.com`) does not need one — it is served from CloudFront edge cache and flood traffic never reaches S3 or EC2.

### 12f. Verify Cloudflare is active

```bash
# Nameservers should show Cloudflare
nslookup -type=NS buffden.com

# cf-ray header confirms traffic flows through Cloudflare
curl -I https://go.buffden.com/actuator/health
# Look for: cf-ray: xxxxxxxx-XXX
```

### Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| Redirect loop on `tinyurl.buffden.com` | SSL mode set to Flexible | Change SSL mode to Full |
| ACM certificate fails to renew | ACM validation CNAME is proxied | Set `_2a3ec5a...` record to grey cloud |
| `cf-ray` header missing | Propagation pending or record is grey cloud | Wait 30 min; verify orange cloud on `go` and `tinyurl` records |
| Site unreachable after nameserver switch | Record missing in Cloudflare | Restore Route 53 nameservers at Namecheap; add missing record; switch again |

### Under Attack Mode

If you detect an active flood: **Cloudflare dashboard → your domain → Quick Actions → Enable Under Attack Mode**. Disables automatically when toggled off.

### Optional cleanup

Once Cloudflare has been running without issues for 24 hours, delete the Route 53 hosted zone to stop the $0.50/month fee. Cloudflare is now the authoritative DNS.

---

## Verification

After all steps above, verify the network path is working:
Expand Down
243 changes: 237 additions & 6 deletions infra/nginx/nginx.prod.conf
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,277 @@ events {
}

http {
# Rate limiting: 40 requests per minute per IP on POST /api/urls
limit_req_zone $binary_remote_addr zone=create_url:10m rate=40r/m;
# -----------------------------------------------------------------------
# Real IP Resolution
#
# Traffic path: Client → Cloudflare → ALB → nginx
# Without this, $remote_addr = ALB private IP (10.x.x.x) for every request.
# Rate limiting keyed on ALB IP = all users share one limit = broken.
#
# set_real_ip_from: trust the VPC (ALB) to forward the real client IP.
# real_ip_header: read real client IP from Cloudflare's CF-Connecting-IP.
# real_ip_recursive: strip known proxy IPs from the chain to find the origin.
#
# After this, $remote_addr = actual client IP for all directives below.
# -----------------------------------------------------------------------
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;

# -----------------------------------------------------------------------
# Rate Limiting Zones
# Defines memory pools to track request rates per IP.
# 10MB zone can track ~160K unique IPs.
# Uses $binary_remote_addr which now = real client IP (after real_ip above).
# -----------------------------------------------------------------------
limit_req_zone $binary_remote_addr zone=create_url:10m rate=40r/m; # URL creation: 40 req/min
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s; # General API: 20 req/sec
limit_req_zone $binary_remote_addr zone=redirect:10m rate=30r/m; # Short URL redirects: 30 req/min
limit_conn_zone $binary_remote_addr zone=conn_limit:10m; # Connection tracking

# Return 429 (not nginx default 503) when limits are exceeded
limit_req_status 429;
limit_conn_status 429;

# -----------------------------------------------------------------------
# Malicious Scanner / Attack Tool User-Agent Blocking
#
# Blocks requests from known vulnerability scanners and attack tools.
# These tools identify themselves in the User-Agent header.
#
# Does NOT block legitimate bots:
# Googlebot, Bingbot, DuckDuckBot, Baiduspider, YandexBot are all
# unaffected — they are not listed here and pass through freely.
# This has zero impact on SEO crawling.
# -----------------------------------------------------------------------
map $http_user_agent $bad_bot {
default 0;
~*sqlmap 1; # SQL injection scanner
~*nikto 1; # Web vulnerability scanner
~*masscan 1; # Port/banner scanner
~*zgrab 1; # Banner grabber used in mass internet scans
~*nuclei 1; # Vulnerability scanner
~*dirbuster 1; # Directory brute forcer
~*gobuster 1; # Directory/DNS brute forcer
~*wfuzz 1; # Web fuzzer
~*acunetix 1; # Commercial web vulnerability scanner
~*w3af 1; # Web app attack framework
~*nessus 1; # Network vulnerability scanner
~*openvas 1; # Open source vulnerability scanner
~*metasploit 1; # Exploit framework
~*havij 1; # Automated SQL injection tool
~*burpsuite 1; # Web security testing proxy
~*nmap 1; # Network scanner (HTTP user agent variant)
~*zmeu 1; # Romanian bot known for WordPress attacks
~*morfeus 1; # Scanner known for CMS exploitation
}

# -----------------------------------------------------------------------
# Gzip Compression
# Compresses API responses before sending. Reduces bandwidth and speeds
# up response times. JSON compresses well (~70% smaller).
# -----------------------------------------------------------------------
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;

server {
listen 80;
server_name go.buffden.com;

# Security headers
# -------------------------------------------------------------------
# Block malicious scanner tools by User-Agent
# These are attack tools, not browsers or legitimate bots.
# return 444 = TCP connection closed instantly, zero bytes sent.
# -------------------------------------------------------------------
if ($bad_bot) {
return 444;
}

# -------------------------------------------------------------------
# Drop non-standard HTTP methods
#
# Real browsers only send: GET POST PUT DELETE OPTIONS HEAD PATCH
# Scanners send: CONNECT TRACE PROPFIND SEARCH etc. to fingerprint
# the server and find exploitable endpoints.
# return 444 closes the connection with no response sent.
# -------------------------------------------------------------------
if ($request_method !~ ^(GET|POST|PUT|DELETE|OPTIONS|HEAD|PATCH)$) {
return 444;
}

# -------------------------------------------------------------------
# Global Connection and Body Limits
#
# limit_conn: max 10 simultaneous open connections per IP.
# Stops Slowloris attacks that hold connections open indefinitely.
#
# client_max_body_size: reject request bodies over 1MB.
# A URL shortener only needs a few hundred bytes per request.
# Large bodies = oversized payload attack.
#
# client_body/header_timeout: cut slow connections after 30s.
# Slow HTTP attacks drip data byte-by-byte to hold connections open.
# -------------------------------------------------------------------
limit_conn conn_limit 10;
client_max_body_size 1m;
client_body_timeout 30s;
client_header_timeout 30s;

# -------------------------------------------------------------------
# Strip Spring Security 6 default headers before nginx adds its own.
#
# Spring Boot 3.x / Spring Security 6 automatically sends:
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff
# X-XSS-Protection: 0 ← Spring Security 6 DISABLES this by default
#
# Without proxy_hide_header, the response contains both the backend's
# version AND nginx's version — duplicate headers, and a direct conflict
# on X-XSS-Protection (0 vs 1; mode=block = undefined browser behavior).
#
# proxy_hide_header strips the backend's version. nginx's add_header
# below becomes the single authoritative source for each header.
# -------------------------------------------------------------------
proxy_hide_header X-Frame-Options;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-XSS-Protection;

# -------------------------------------------------------------------
# Security Headers
#
# X-Frame-Options DENY: prevents your site being embedded in iframes.
# Stops clickjacking attacks where a malicious site overlays yours.
#
# X-Content-Type-Options nosniff: browsers must use declared MIME type.
# Stops MIME-sniffing attacks where a browser runs a file as JS.
#
# X-XSS-Protection 0: explicitly disables the browser XSS auditor.
# Modern best practice — browser XSS filters are deprecated in
# Chrome/Edge and can introduce vulnerabilities. Spring Security 6
# made the same change. Use CSP for XSS protection instead.
#
# Referrer-Policy: controls what URL is sent in the Referer header.
# strict-origin-when-cross-origin = sends origin only on HTTPS→HTTPS.
#
# HSTS: tells browsers to always use HTTPS for this domain for 1 year.
# Prevents SSL stripping attacks even if a user types http://.
# -------------------------------------------------------------------
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

# Allow ALB health check through to the app
# -------------------------------------------------------------------
# Block common vulnerability scan paths
#
# Automated bots constantly probe these paths on every server they
# find, looking for: exposed .env files with credentials, git repos
# with source code, WordPress/PHP admin panels, database tools.
#
# Your app has none of these — but bots don't know that and probe
# blindly. return 444 = no response, no egress, TCP closed instantly.
# -------------------------------------------------------------------
location ~* ^/(\.env|\.git|\.htaccess|wp-admin|wp-login|phpmyadmin|adminer|laravel|backup|config\.php|xmlrpc\.php) {
return 444;
}

# -------------------------------------------------------------------
# ALB Health Check — must be before the /actuator/ block
# ALB targets port 80 on EC2 and checks this path every 30s.
# If this returns anything other than 200, the target goes Unhealthy.
# -------------------------------------------------------------------
location = /actuator/health {
proxy_pass http://app:8080;
proxy_set_header Host $host;
}

# Block all other actuator endpoints from external access
# Block all other actuator endpoints (metrics, env, beans etc.)
# These expose internal app details — never expose externally.
location /actuator/ {
return 403;
}

# -------------------------------------------------------------------
# Rate-limited URL creation endpoint
# 40 requests/min per IP, burst of 10.
# Anyone flooding POST /api/urls gets 429 before hitting the database.
# -------------------------------------------------------------------
location = /api/urls {
limit_req zone=create_url burst=10 nodelay;
limit_conn conn_limit 5;

proxy_pass http://app:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;

error_page 429 = @rate_limit_error;
}

# -------------------------------------------------------------------
# All other API requests
# -------------------------------------------------------------------
location /api/ {
limit_req zone=api_limit burst=15 nodelay;
limit_conn conn_limit 5;

proxy_pass http://app:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;

error_page 429 = @rate_limit_error;
}

# All other requests (short URL redirects, health)
# -------------------------------------------------------------------
# Short URL redirects and everything else
#
# Rate limited at 30 requests/min per IP, burst of 10.
# A real user clicking short links never exceeds this.
# A bot enumerating short codes (000000 → zzzzzz) sends hundreds
# per minute from the same IP and gets blocked with 429.
# -------------------------------------------------------------------
location / {
limit_req zone=redirect burst=10 nodelay;
error_page 429 = @rate_limit_error;

proxy_pass http://app:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}

# -------------------------------------------------------------------
# Rate Limit Error Handler
# Returns JSON instead of nginx's default HTML error page.
# API clients expect JSON — an HTML response breaks their parsing.
# -------------------------------------------------------------------
location @rate_limit_error {
default_type application/json;
return 429 '{"status":429,"error":"Too Many Requests","message":"Rate limit exceeded. Please try again later."}';
}
}
}
Loading