diff --git a/docs/deployment/PHASE_A_INFRASTRUCTURE.md b/docs/deployment/PHASE_A_INFRASTRUCTURE.md index 497ff65..990eb04 100644 --- a/docs/deployment/PHASE_A_INFRASTRUCTURE.md +++ b/docs/deployment/PHASE_A_INFRASTRUCTURE.md @@ -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) --- @@ -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: diff --git a/infra/nginx/nginx.prod.conf b/infra/nginx/nginx.prod.conf index 66d78ae..ce396fb 100644 --- a/infra/nginx/nginx.prod.conf +++ b/infra/nginx/nginx.prod.conf @@ -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."}'; } } }