morphit/ops/nginx/web.conf

290 lines
17 KiB
Text
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Morphit web frontend — nginx server block.
#
# This serves the SvelteKit-built static files from apps/web/build/
# AND reverse-proxies the API paths to the loopback services, so the
# whole instance lives under ONE hostname — the recommended single-host
# ("colocated") topology (docs/RUN-A-MORPHIT-NODE.md §9):
# /v1/* -> indexer (127.0.0.1:8081) read-only API + SSE streams
# /rss/* -> indexer (127.0.0.1:8081) orderbook RSS/Atom feeds
# /relay/* -> relay (127.0.0.1:8080) account-creation funding
# The frontend ships built for same-origin (MORPHIT_INDEXER_ORIGIN='',
# MORPHIT_RELAY_ORIGIN='/relay'), so it calls these as same-origin
# paths — you do NOT need separate indexer.* / relay.* DNS records or
# server blocks. (A split-subdomain topology is possible but
# discouraged; see the header of ops/nginx/indexer.conf for the extra
# steps it requires.)
#
# What this config buys you:
# 1. Strict Content-Security-Policy. Self-only — no Google
# Fonts, no CDN, no analytics, no third-party anything.
# Per Morphit's privacy-first philosophy. Defense-in-depth
# against any future XSS regression.
# 2. Other security headers (HSTS, X-Frame-Options, etc.)
# consistent with relay.conf and indexer.conf.
# 3. Long-cache for hashed bundle files (immutable), no-cache
# for HTML (so deploys are seen immediately).
# 4. /404 / 403 / 500 pages map to the same SvelteKit index so
# client-side routing sees the original URL.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name morphit.io;
root /var/www/morphit-frontend;
index index.html;
# ─── TLS ──────────────────────────────────────────────────
ssl_certificate /etc/letsencrypt/live/morphit.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/morphit.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# ─── Security headers ─────────────────────────────────────
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
# ─── Content-Security-Policy ──────────────────────────────
# Strict default — nothing loads from anywhere except this
# origin. In the single-host topology the indexer + relay are
# reached via same-origin paths (/v1/, /rss/, /relay/), so they
# need NO connect-src entry — 'self' already covers them.
# connect-src only needs the cross-origin endpoints the BROWSER
# talks to directly:
# - The Blurt RPC nodes (the six DEFAULT_RPC_ENDPOINTS from
# apps/web/src/lib/net/config.ts are pre-listed below; edit
# to match whatever RPC pool you ship).
# - Any altcoin price feed / block-explorer endpoints the
# browser fetches directly, only if you enable them.
# - Split-subdomain topology ONLY: add your indexer/relay
# subdomains here (and see ops/nginx/indexer.conf first).
#
# script-src needs more than 'self' for two reasons:
# - 'unsafe-inline': this is a static build with no meta CSP,
# so SvelteKit's inline hydration bootstrap (and the ?lang=
# preflight hint in app.html) run without per-page hashes.
# The frontend renders no user-supplied HTML, so the
# inline-script surface is minimal.
# - 'wasm-unsafe-eval' (+ 'unsafe-eval'): the keystore's
# argon2 KDF and signing run as in-browser WebAssembly,
# which CSP blocks without one of these. Non-optional —
# drop them and login / keystore unlock breaks. You may
# narrow to just 'wasm-unsafe-eval' if you confirm no
# dependency uses JS eval/Function.
# style-src 'unsafe-inline' — SvelteKit emits inline <style>
# for component-scoped styles; standard SvelteKit requirement.
# worker-src 'self' blob: — the altcha proof-of-work worker is
# built from an in-memory blob: URL; without blob: the anti-bot
# challenge on registration fails to run.
#
# frame-ancestors 'none' duplicates X-Frame-Options DENY for
# browsers that prefer the modern CSP directive.
#
# If you serve over Tor (.onion mirror), that mirror needs
# its own server block; CSP applies per-host.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
# ─── Compression (cp165 byte budget) ──────────────────────
# The SvelteKit build emits pre-compressed `.br` and `.gz`
# alongside every `.js` / `.css` / `.svg` / `.html` file.
# WITHOUT these directives, nginx happily serves the raw
# uncompressed file and ignores the pre-compressed siblings —
# so every page load ships 4-6× more bytes than necessary.
#
# `gzip_static on` makes nginx prefer `foo.js.gz` over
# `foo.js` when the client's `Accept-Encoding` includes gzip.
# Zero CPU cost — the compression happened once at build time.
#
# `brotli_static on` requires the ngx_brotli module
# (apt: nginx-module-brotli on most distros, or build from
# https://github.com/google/ngx_brotli). Brotli compresses
# ~15-20% smaller than gzip on JS/CSS. If you don't have the
# module, comment out the `brotli_static` line — the
# `gzip_static` line keeps you in good shape on its own.
gzip_static on;
brotli_static on;
# ─── Caching strategy ─────────────────────────────────────
# SvelteKit emits hashed bundle files under /_app/immutable/.
# These names change on every deploy, so they're safe to
# cache forever. Everything else (HTML, manifests) gets a
# short cache so deploys are seen quickly.
#
# NGINX FOOTGUN: when a `location` block has any `add_header`,
# nginx completely drops inheritance of the server-level
# `add_header` directives. We re-add EVERY security header
# in every location that uses add_header at all, otherwise
# those responses ship without HSTS/CSP/X-Frame/etc.
location /_app/immutable/ {
expires 1y;
access_log off;
add_header Cache-Control "public, immutable, max-age=31536000";
# Full security-header re-emission (server-block inheritance lost).
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
}
# Service worker — must NOT be cached or it pins old clients.
location = /service-worker.js {
add_header Cache-Control "no-cache" always;
# Full security-header re-emission (server-block inheritance lost).
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
}
# verify.json — this build's version + asset-hash manifest. Must NOT
# be cached: the app's update-version poll and the "About this
# instance" auto-verify both read it, and a stale copy makes the app
# think it is already up to date (so the update snackbar never shows)
# and makes auto-verify compare against the wrong manifest. Same
# add_header inheritance footgun as above — re-emit the security
# headers. (If you gate the beta site behind HTTP Basic Auth, add
# `auth_basic off;` in THIS block so the poll + auto-verify can read
# verify.json without a credential prompt.)
location = /verify.json {
add_header Cache-Control "no-cache" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
}
# Defense against attackers probing for .git, .env, etc.
# Even though apps/web/build/ shouldn't contain any of these,
# a misconfiguration shouldn't expose them. Returns 404
# (not 403) to give no signal about whether the path exists.
location ~ /\.(?!well-known) {
return 404;
}
location ~* \.(env|git|sql|bak|old|orig|swp|tmp)$ {
return 404;
}
# ─── API reverse-proxy (single-host topology) ────────────
# The indexer + relay run on loopback; these blocks expose them
# under the SAME hostname as the SPA, so the frontend's same-origin
# /v1/, /rss/, /relay/ calls reach them. If you run the indexer or
# relay on a different machine, replace 127.0.0.1 with its address.
# (Longest-prefix match means these win over the `location /`
# fallback below regardless of ordering.) These blocks add no
# add_header, so they inherit the server-level security headers.
# Relay — fund-spending endpoints. The frontend calls same-origin
# /relay/v1/* (e.g. /relay/v1/account/create); the rewrite strips
# the /relay prefix so the relay process sees /v1/account/create.
location /relay/ {
rewrite ^/relay/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 64k;
}
# Indexer — read-only public API. Serves /v1/* as-is, so forward
# unchanged (no rewrite). /relay/v1/* is caught by the longer
# /relay/ prefix above, so it never falls into this block.
location /v1/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Operator-only per-source price-feed health (morphit-ops health)
# is gated on X-Morphit-Local-Health, which the local ops-cli
# sends over the bridge. Clear any client-supplied value so a
# public caller can never forge it and read the per-source block.
proxy_set_header X-Morphit-Local-Health "";
client_max_body_size 4k;
}
# SSE live-update streams (orderbook / chat / instances). The
# indexer sends a `:keepalive` comment every 25s; proxy buffering
# MUST be off or events arrive in laggy batches instead of live.
# This regex matches the .../stream paths ahead of the /v1/ prefix.
location ~ ^/v1/.*/stream$ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Connection "";
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_buffering off;
proxy_read_timeout 1h;
}
# RSS/Atom orderbook feeds — same loopback indexer, under /rss/*
# (footer pill + <head> links to /rss/orderbook.xml, plus the
# by-asset / by-account feeds). Forward unchanged, like /v1/.
location /rss/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# MCP — read-only AI-agent orderbook surface (/mcp), the loopback
# MCP HTTP transport on 8124. It is NOT virtual-hosted; it uses the
# Host header only for DNS-rebinding defense, whose default allowlist
# includes the loopback form — so present `Host: 127.0.0.1:8124`
# upstream (forwarding $host would be rejected 403). Responses can be
# long-running for federation queries.
location /mcp {
proxy_pass http://127.0.0.1:8124;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host 127.0.0.1:8124;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 256k;
proxy_read_timeout 120s;
}
# SvelteKit SPA fallback — any unknown path returns the
# index so client-side routing handles it.
location / {
try_files $uri $uri.html $uri/index.html /index.html;
add_header Cache-Control "no-cache" always;
# Full security-header re-emission (server-block inheritance lost).
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
}
# ─── Body cap at the edge ─────────────────────────────────
# Static files only — no POST surface here.
client_max_body_size 1k;
}
# HTTP → HTTPS redirect.
server {
listen 80;
listen [::]:80;
server_name morphit.io;
location / {
return 301 https://$host$request_uri;
}
}