Skip to content

Security & Anti-Abuse

For file-and-line-level detail on rate-limiting protocols — see dixu_proxy/README.md (sections "TCP tunnel protection" and "HTTP interstitial protection"). This page is a project-level overview of the threat model and what's implemented.

What data passes through the server

HTTP/HTTPS tunnels (default mode): TLS is terminated at the proxy — traffic is decrypted on the dix.su server and re-encrypted between server and client. The server can technically read request/response bodies in transit. If that's a concern, use private E2E mode (see below).

TCP tunnels (SSH, RDP, VNC, databases): the proxy forwards bytes without reading them — it uses the TLS SNI field in the ClientHello only for routing, then relays the raw TCP stream. The actual SSH/RDP/database session is encrypted end-to-end between the client and your device.

E2E private mode: TLS is not terminated on the server at all — the proxy is a raw byte relay. The private key never leaves the device. See the Private Mode section below.

TCP tunnel protection

TCP ports accept an arbitrary slug in the connection — connecting to a random slug is itself a suspicious signal, since legitimate clients connect to their own one or two slugs. Therefore:

  • DixuProxyWeb.Tcp.RateLimiter — three independent ETS tables:
  • Handshake attempt rate limit per IP (soft/hard limit → ban).
  • Slug diversity detector ("diversity ban") — many different slugs from one IP within a time window → long ban (strong signal of scanning/enumeration).
  • Concurrent connection limit per IP (protects against process/FD exhaustion).
  • Unified deny messages — no difference in the response between "slug doesn't exist" and "slug exists but device is offline" (eliminates account enumeration via response text).
  • TCP ports are listened to directly by dixu_proxy via Docker port-forwarding, not through nginx stream-proxy — otherwise the proxy would see nginx's IP for all clients and the rate limiter would not work at all.

HTTP protection (catalog-aware)

For HTTP, a "diversity ban" (TCP-style) doesn't work: the tunnel catalog implies legitimate browsing of 10–20+ different public tunnels in a few minutes. Two separate mechanisms with different goals:

  • DixuProxyWeb.Http.VisitGuard.allow?/1 — request volume limit per IP (anti-scraping/anti-DDoS), independent of slug diversity.
  • DixuProxyWeb.Http.VisitGuard.record_slug/2 — a narrower detector for enumeration of private (non-catalog) slugs only. Before counting a slug toward the "suspicious diversity" counter, it checks DixuProxy.CatalogRegistry.approved?/1 — catalog slugs are excluded, otherwise the catalog feature itself would be broken.

Incident log

Every ban (TCP RateLimiter or HTTP VisitGuard) is written to:

  1. File logINCIDENT_LOG_DIR/YYYY-MM-DD.log, lines: timestamp\tcategory\tip\tduration\treason. Mounted as a volume on the host (./INCIDENT/logs), survives container rebuilds.
  2. Postgres table tunnel_incidents (migration V36) — structured, with target_slug when a real account was found among the enumerated slugs.

Admin panel — "Incidents" tab at /admin — sees all platform incidents, filterable by IP/slug/category.

Email alert to owner — if the scan targeted a real tunnel slug and the owner's plan is VIP or above, an email notification is sent (30-minute cooldown per slug).

Dashboard — "Security" section (/auth/protected#security, VIP+ plan), API GET /api/incidents/my — only incidents on your own slug.

Private mode: HTTP/HTTPS access gate

A private account (users.private_mode=true, see Billing) should be accessible only to the owner, not to anyone who knows the URL.

  1. Session cookie visible on subdomainsspring.webflux.session.cookie.domain=.{BASE_DOMAIN} (previously host-only on dix.su, invisible on {slug}.dix.su). SameSite=Lax doesn't block this (same registrable domain).
  2. GET /internal/session/whoamidixu_proxy forwards the Cookie header to modulauth to check whether the request comes from the tunnel owner or an invitee. Unauthorized visitors get a stub page, not the actual content.

Private mode: E2E encryption (Phase 2, deployed)

A separate entry point e2e-<token>.{BASE_DOMAIN} where TLS is not terminated on the server.

  • device_client runs an ACME v2 (DNS-01) challenge via Cloudflare to obtain a Let's Encrypt certificate for the E2E subdomain — the private key is generated on the device and never uploaded.
  • dixu_proxy has a raw-relay listener (DixuProxyWeb.Tcp.E2eRawListener, port 9443) that forwards bytes without decryption — routing is done via SNI in the ClientHello.
  • nginx stream{} block routes by SNI — E2E subdomains go to the raw relay, regular subdomains to the TLS-terminating proxy.

Verified on production in Chrome and Firefox.

Defense-in-depth: INTERNAL_API_SECRET

modulauth and dixu_proxy communicate with each other over internal HTTP endpoints (/internal/*). These are blocked at the nginx level (location ^~ /internal/ { return 404; }) on all public vhosts. In addition, dixu_proxy requires a secret header X-Internal-Secret on all /internal/* routes, checked via constant-time comparison (Plug.Crypto.secure_compare/2).

This means two independent protection layers: even if someone removes the nginx guard, calls without the secret get 401. And if the secret is somehow missing from the environment, dixu_proxy logs a warning at startup and an admin banner appears in the dashboard.

Fixed vulnerability: X-Forwarded-For spoofing

DixuProxyWeb.ClientIp was taking the first value from X-Forwarded-For, but nginx appends the real client IP at the end of the chain. A client could bypass all IP-based protections with a single header: curl -H 'X-Forwarded-For: 1.2.3.4'. Confirmed on production (forged header lifted an active ban); fixed — now takes the last value (the only segment added by nginx, the rest is client-controlled).

Lesson: when trusting headers from a reverse proxy, always verify exactly how nginx forms the header (set vs add_x_forwarded_for) instead of assuming.