Origin & SIWE hardening
Operator runbook for closing the SIWE Host-spoofing / phishing-relay path and locking the origin down behind Cloudflare. These are manual, box-local steps: they are not part of the auto-deploy pipeline and must be applied by hand on the server (and in the Cloudflare dashboard). Apply them in order: pin the SIWE domain first (a) so sign-in stops depending on the client Host, then lock the origin (b), then add the edge rate limits (c).
Context: the production nginx is a catch-all vhost (server_name _; with proxy_set_header Host $host;) and the origin IP is directly reachable, so a client can send an arbitrary Host header straight to the app. The code now refuses to treat an unpinned, non-allowlisted Host as the SIWE domain (see src/lib/auth/session.ts expectedDomain and the Host allowlist), but pinning SIWE_DOMAIN and taking the origin off the public internet are the durable controls.
(a) Pin SIWE_DOMAIN in prod and staging
SIWE_DOMAIN binds every SIWE verification to a fixed domain, so verification no longer derives from the request Host at all. .env.local is preserved across deploys and re-read on pm2 restart --update-env.
Prod (repo /opt/onchain-credit, pm2 process onchain-credit on :3001):
ssh root@dexhq.io
cd /opt/onchain-credit
# Idempotent: append only if the key is not already present.
grep -q '^SIWE_DOMAIN=' .env.local || echo 'SIWE_DOMAIN=creddit.xyz' >> .env.local
grep '^SIWE_DOMAIN=' .env.local # confirm: SIWE_DOMAIN=creddit.xyz
pm2 restart onchain-credit --update-env # re-reads .env.localStaging (repo /opt/onchain-credit-staging, pm2 process onchain-credit-staging on :3002):
cd /opt/onchain-credit-staging
grep -q '^SIWE_DOMAIN=' .env.local || echo 'SIWE_DOMAIN=staging.creddit.xyz' >> .env.local
grep '^SIWE_DOMAIN=' .env.local # confirm: SIWE_DOMAIN=staging.creddit.xyz
pm2 restart onchain-credit-staging --update-envVerify. Sign in on https://creddit.xyz (and staging) and confirm the session cookie is issued. In the prod logs, confirm the unpinned-fallback warning is gone (it fires at most once per process when neither SIWE_DOMAIN nor CHAT_SIWE_DOMAIN is set):
pm2 logs onchain-credit --lines 200 | grep -i 'SIWE_DOMAIN' || echo 'no unpinned warning (good)'Note: CHAT_SIWE_DOMAIN is the legacy name and is honored as the fallback. Set SIWE_DOMAIN; do not set both to different values.
(b) Origin lockdown (finding #2)
The origin should answer only the canonical host and should accept 80/443 only from Cloudflare. This removes the direct-to-origin Host: evil.com path entirely and stops trivial origin discovery.
b.1 nginx: answer only the canonical host, reject the rest
Give the real site an explicit server_name and add a default server that drops anything else with 444 (nginx closes the connection with no response). Put the default server in its own file so it applies box-wide.
/etc/nginx/sites-available/00-default-drop (new):
# Catch-all: any request whose Host does not match a real server_name below
# (direct-to-IP probes, spoofed Host headers) gets the connection dropped.
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
# Reuse an existing cert so the TLS handshake can complete before the drop;
# a self-signed snakeoil cert is fine here since we never serve real content.
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
return 444;
}Then make the real prod vhost non-default and host-specific (edit the existing onchain-credit vhost):
server {
listen 80;
listen 443 ssl;
server_name creddit.xyz; # was: server_name _; (no longer default_server)
# ... existing ssl_certificate / location / proxy_pass blocks unchanged ...
}Keep the staging.creddit.xyz vhost host-specific in the same way (server_name staging.creddit.xyz;). Enable and reload:
ln -s /etc/nginx/sites-available/00-default-drop /etc/nginx/sites-enabled/00-default-drop
nginx -t && nginx -s reloadVerify the origin now refuses a spoofed Host and a direct-IP probe (run from off the box):
curl -s -o /dev/null -w '%{http_code}\n' --resolve evil.com:443:178.104.90.59 https://evil.com/ # expect 000 (connection dropped)
curl -sk -o /dev/null -w '%{http_code}\n' https://178.104.90.59/ # expect 000 (connection dropped)b.2 Firewall 80/443 to Cloudflare only
Restrict the origin so only Cloudflare's edge can reach the web ports. Cloudflare publishes its ranges at https://www.cloudflare.com/ips/ (https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6). Pull them live rather than pasting a stale list.
ufw example (leave SSH as-is; deny 80/443 by default, then allow each Cloudflare range):
# First make sure SSH stays open, or you will lock yourself out.
ufw allow 22/tcp
for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
ufw allow proto tcp from "$ip" to any port 80,443
done
ufw deny 80/tcp
ufw deny 443/tcp
ufw reload
ufw status numberediptables equivalent (same intent; the last two rules drop everyone else):
for ip in $(curl -s https://www.cloudflare.com/ips-v4); do
iptables -A INPUT -p tcp -s "$ip" -m multiport --dports 80,443 -j ACCEPT
done
for ip in $(curl -s https://www.cloudflare.com/ips-v6); do
ip6tables -A INPUT -p tcp -s "$ip" -m multiport --dports 80,443 -j ACCEPT
done
iptables -A INPUT -p tcp -m multiport --dports 80,443 -j DROP
ip6tables -A INPUT -p tcp -m multiport --dports 80,443 -j DROPCloudflare rotates these ranges occasionally, so schedule a refresh (a small cron that re-pulls the lists and rebuilds the allow rules) rather than treating this as one-and-done.
Alternative / complement: Authenticated Origin Pulls. Instead of (or in addition to) IP allowlisting, enable Cloudflare Authenticated Origin Pulls so nginx only accepts TLS connections presenting Cloudflare's client certificate. Enable it for the zone in the Cloudflare dashboard (SSL/TLS, Origin Server, Authenticated Origin Pulls), install Cloudflare's origin-pull CA on the box, and add to the real vhost:
ssl_client_certificate /etc/nginx/cloudflare/authenticated_origin_pull_ca.pem;
ssl_verify_client on;b.3 Move staging.creddit.xyz off the origin IP
staging.creddit.xyz currently resolves straight to 178.104.90.59, which discloses the origin. Put staging behind Cloudflare too: in the DNS dashboard set the staging record to Proxied (orange cloud) so its public A record points at Cloudflare, not the origin. Keep the basic-auth + noindex on the staging vhost. After this, no public DNS record should resolve to the origin IP.
b.4 Rotate the exposed origin IP
The current origin IP has been publicly reachable and may already be indexed by origin-discovery services, so IP allowlisting alone does not fully hide it. After (b.1)-(b.3) are in place and verified, request a new origin IP from the host, repoint the Cloudflare origin records to it, confirm traffic flows, then release the old 178.104.90.59. Re-verify that a direct request to the old IP no longer serves the app.
(c) Cloudflare rate limiting (finding #4, handled at the edge)
Rate limiting for the abuse-sensitive endpoints is enforced at the Cloudflare edge, not in the app. Add per-IP rate-limit rules (Security, WAF, Rate limiting rules) for the routes below. These are suggested starting points; tune from real traffic and prefer a managed challenge or a short block as the action.
| Path | Suggested per-IP limit | Why |
|---|---|---|
/api/auth/nonce | 30 requests / minute | Nonce minting; throttle nonce farming. |
/api/auth/verify | 10 requests / minute | Sign-in verification; throttle signature-relay / brute attempts. |
/api/sim/swap-cost | 60 requests / minute | Calls an external aggregator; cap to protect the upstream quota. |
/api/newsletter/subscribe | 5 requests / minute | Unauthenticated write; throttle signup spam. |
/api/notify-capacity | 10 requests / minute | Unauthenticated notify endpoint; throttle abuse. |
Match on the request path (and method POST where the route is POST-only), count by client IP, and set a counting window of 60s with a mitigation timeout long enough to deter scripted abuse (for example a 10 minute block or a managed challenge). Keep the auth endpoints (nonce, verify) the tightest.
Rollback
- SIWE pin: remove the
SIWE_DOMAIN=line from the relevant.env.localandpm2 restart <process> --update-env. Sign-in then falls back to the Host allowlist (prod/staging/localhost still work; a spoofed Host still fails). - nginx default-drop:
rm /etc/nginx/sites-enabled/00-default-dropand restore the prod vhostserver_nameto its prior value, thennginx -t && nginx -s reload. - Firewall:
ufw deletethe added rules (or flush the iptables INPUT rules) to reopen 80/443. Do this before touching SSH so you never lock yourself out.