Skip to content

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):

bash
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.local

Staging (repo /opt/onchain-credit-staging, pm2 process onchain-credit-staging on :3002):

bash
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-env

Verify. 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):

bash
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):

nginx
# 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):

nginx
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:

bash
ln -s /etc/nginx/sites-available/00-default-drop /etc/nginx/sites-enabled/00-default-drop
nginx -t && nginx -s reload

Verify the origin now refuses a spoofed Host and a direct-IP probe (run from off the box):

bash
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):

bash
# 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 numbered

iptables equivalent (same intent; the last two rules drop everyone else):

bash
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 DROP

Cloudflare 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:

nginx
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.

PathSuggested per-IP limitWhy
/api/auth/nonce30 requests / minuteNonce minting; throttle nonce farming.
/api/auth/verify10 requests / minuteSign-in verification; throttle signature-relay / brute attempts.
/api/sim/swap-cost60 requests / minuteCalls an external aggregator; cap to protect the upstream quota.
/api/newsletter/subscribe5 requests / minuteUnauthenticated write; throttle signup spam.
/api/notify-capacity10 requests / minuteUnauthenticated 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.local and pm2 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-drop and restore the prod vhost server_name to its prior value, then nginx -t && nginx -s reload.
  • Firewall: ufw delete the added rules (or flush the iptables INPUT rules) to reopen 80/443. Do this before touching SSH so you never lock yourself out.

Private documentation. creddit.xyz