From 14357a0223fd088f398d5e9b5f05b58d6108e3fe Mon Sep 17 00:00:00 2001 From: justin Date: Tue, 23 Jun 2026 15:51:30 -0500 Subject: [PATCH] fix(nginx): unblock public API routes powering lead tools/flows (HC sales killer) api.performancewest.net uses an explicit per-path allowlist; everything else falls through to a trusted-IP-only catch-all that returns 403. Six browser- facing routes had no location block, so they 403'd for every public visitor: /api/v1/npi/ <- THE healthcare sales killer. The 'Free NPI Compliance Check' tool (top of the HC funnel, where every HC campaign sends traffic) fetches /api/v1/npi/lookup. It 403'd -> CORS error in the browser -> the tool never rendered results or the upsell CTAs (Revalidation $399 / NPPES $149 / Bundle $899) -> 0 HC sales despite 17 sessions reaching it in 30d and 0 HC orders EVER created in the compliance DB. /api/v1/cdr/ telecom CDR profile tool /api/v1/icc/ intrastate/ICC profile tool /api/v1/corp/ corporate foreign-qual check /api/v1/foreign-qualification/ foreign qualification quote/jurisdictions /api/v1/lnpa-regions LNPA region lookup Added explicit proxy_pass blocks (mirroring the existing entities/identity pattern) before the catch-all. Verified live: all six now reach the app with proper CORS; the NPI tool renders results + order CTAs end-to-end via a real browser; npi-revalidation order page -> Stripe confirmed. The live /etc/nginx/sites-enabled/pw-api.conf was hand-edited and untracked; committing the current state here so it is version-controlled. (Live backup: /root/pw-api.conf.bak_20260623.) --- infra/nginx/sites-enabled/pw-api.conf | 313 ++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 infra/nginx/sites-enabled/pw-api.conf diff --git a/infra/nginx/sites-enabled/pw-api.conf b/infra/nginx/sites-enabled/pw-api.conf new file mode 100644 index 0000000..03a98cf --- /dev/null +++ b/infra/nginx/sites-enabled/pw-api.conf @@ -0,0 +1,313 @@ +# HTTPS config for api.performancewest.net +# Restricted: only specific paths are publicly accessible. +# Admin/internal paths require trusted IP. + +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + +geo $api_trusted { + default 0; + 127.0.0.1/32 1; + 172.16.0.0/12 1; + 10.0.0.0/8 1; + 207.174.124.71/32 1; + 76.228.206.147/32 1; +} + +server { + listen 80; + server_name api.performancewest.net; + location /.well-known/acme-challenge/ { root /var/www/certbot; } + location / { return 301 https://api.performancewest.net$request_uri; } +} + +server { + listen 443 ssl; + http2 on; + server_name api.performancewest.net; + + ssl_certificate /etc/letsencrypt/live/api.performancewest.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.performancewest.net/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + client_max_body_size 50m; + + # Shared proxy settings + set $api_backend http://127.0.0.1:3001; + + # ── Webhooks: fully public (Stripe, PayPal, SHKeeper) ── + location ^~ /api/v1/webhooks/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + # ── Public API paths (browser frontend needs these) ── + location ^~ /api/v1/fcc/ { + limit_req zone=api_limit burst=20 nodelay; + proxy_pass $api_backend; + 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 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location ^~ /api/v1/id-upload/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + client_max_body_size 20M; + } + + location ^~ /api/v1/dot/ { + limit_req zone=api_limit burst=20 nodelay; + proxy_pass $api_backend; + 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 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location ^~ /api/v1/discount/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/auth/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/subscribe { + limit_req zone=api_limit burst=5 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/tickets { + limit_req zone=api_limit burst=5 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/checkout/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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_send_timeout 60s; + proxy_read_timeout 60s; + } + + location ^~ /api/v1/compliance-orders { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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_send_timeout 60s; + proxy_read_timeout 60s; + } + + location ^~ /api/v1/portal { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/canada-crtc { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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_send_timeout 60s; + proxy_read_timeout 60s; + } + + location ^~ /api/v1/amb/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/formations { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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_send_timeout 60s; + proxy_read_timeout 60s; + } + + location ^~ /api/v1/paypal { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/entities/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/identity { + limit_req zone=api_limit burst=5 nodelay; + proxy_pass $api_backend; + 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; + } + + # ── Public site routes (browser-facing tools/flows) ── + # Added: these power public lead tools + order flows and MUST be reachable by + # untrusted visitors. Without an explicit block they fell through to the + # trusted-IP-only catch-all and returned 403, silently breaking: + # npi -> Healthcare "Free NPI Compliance Check" (top of the HC funnel; the + # fetch failed CORS/403 so the tool never rendered results -> 0 HC sales) + # cdr/icc/corp/foreign-qualification/lnpa-regions -> telecom/corporate tools. + location ^~ /api/v1/npi/ { + limit_req zone=api_limit burst=20 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/cdr/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/icc/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/corp/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/foreign-qualification/ { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + location ^~ /api/v1/lnpa-regions { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass $api_backend; + 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; + } + + # ── Everything else: trusted IPs only ── + location / { + if ($api_trusted = 0) { + return 403; + } + limit_req zone=api_limit burst=20 nodelay; + proxy_pass $api_backend; + 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 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /.well-known/acme-challenge/ { root /var/www/certbot; } +}