Skip to content

Network security & IP access control

StreamHub ships a second, application-level line of defense on top of the reverse proxy and OS firewall: a global IP allow/blocklist (IPv4 + IPv6, CIDR) and an in-app fail2ban that auto-bans abusive client IPs. Both run in one early middleware, ahead of every guard and route handler, so a matched rule or an active ban is decided before the request reaches auth, rate limiting, or app logic.

Loopback and private addresses are always permitted and never auto-banned, in every mode, regardless of any rule:

  • IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16
  • IPv6: ::1, fc00::/7 (ULA), fe80::/10 (link-local), plus IPv4-mapped addresses (::ffff:a.b.c.d) folded to their IPv4 range

So the box itself, the Docker healthcheck (127.0.0.1:3020/api/v1/health), local /metrics scrapes, and LAN/cluster peers can never be locked out — even by a 0.0.0.0/0 block rule or STREAMHUB_IP_ALLOWLIST_ONLY=true.

Rules are managed through the admin API (or the Network security card in Server Settings) and compiled into memory, so the per-request check is an in-memory CIDR match with no database round trip.

Precedence, evaluated per request:

  1. Loopback/private address → always allow (see above)
  2. Explicit allow rule → allow (this also shields the IP from ban enforcement)
  3. Active auto-ban → 429
  4. Explicit block rule → 403 (in enforce mode)
  5. STREAMHUB_IP_ALLOWLIST_ONLY=true and no matching allow rule → 403 (in enforce mode)
  6. Otherwise → allow

Rejections are deliberately generic — a small JSON envelope plus a structured log line — the client never learns which rule or ban matched.

STREAMHUB_IP_ACCESS_MODE gates whether rules are actually enforced:

Mode Behavior
off (default) Rules are not evaluated. Auto-ban still applies if it’s enabled independently.
log Would-be blocks are logged (ip-access would_block …) and the request is annotated, but never rejected — use this to trial a rule set before enforcing it.
enforce A blocked request gets 403 { error: { code: "forbidden" } }.

With STREAMHUB_AUTOBAN_ENABLED=true, every client IP accumulates offenses in a sliding window. The offense kinds are wired into the real failure sites, not synthesized:

Kind Recorded on
login_failed Bad password, unknown user, or bad TOTP on POST /auth/login
magic_verify_failed A bogus, expired, or replayed magic link on POST /auth/magic/verify
invalid_token A presented-but-unknown sk_ token, or a forged/expired JWT — a missing bearer is not an offense
rate_limited The auth rate limiter’s 429 on the login/magic-link paths
not_found 404s, opt-in via STREAMHUB_AUTOBAN_404_ENABLED

Offense recording is fire-and-forget — it never throws, so a bug in reputation tracking can never break a real request.

Ban trigger: STREAMHUB_AUTOBAN_MAX_OFFENSES offenses (default 10) within STREAMHUB_AUTOBAN_WINDOW_S seconds (default 300) bans the IP for STREAMHUB_AUTOBAN_BASE_TTL_S seconds (default 900). Each repeat ban doubles the previous TTL, capped at 7 days. A banned IP gets a generic 429 on every route. Bans are persisted (written on ban, refreshed by a periodic sweep) so they survive a core restart; expired bans are kept 7 days for the “recent” list, then purged.

Never banned: loopback/private IPs, and any IP matching an explicit allow rule — though their offenses are still counted, so they still show up under recent offenders. Unbanning (API or dashboard) is a clean slate: it lifts the ban, clears the offense window, and resets the escalation level.

  • Brute-force focus — lower MAX_OFFENSES (e.g. 5) and keep the default window.
  • Noisy scanners — enable STREAMHUB_AUTOBAN_404_ENABLED=true, but only if nothing legitimate polls unknown paths (the SPA fallback serves index.html, so real 404s come from the API surface).
  • Shared/NAT’d networks — raise MAX_OFFENSES or allowlist the range; one abusive tenant behind a NAT can otherwise ban an entire office.
  • Rollout — same pattern as STREAMHUB_AUTHZ_ENFORCE: start with everything off, then STREAMHUB_IP_ACCESS_MODE=log + STREAMHUB_AUTOBAN_ENABLED=true, watch GET /security/offenses and the ip-access log lines, then switch to enforce.
Variable Default Description
STREAMHUB_IP_ACCESS_MODE off off | log | enforce — rule evaluation mode.
STREAMHUB_IP_ALLOWLIST_ONLY false Strict allowlist: public IPs with no explicit allow rule are rejected (in enforce). Loopback/private always pass regardless.
STREAMHUB_AUTOBAN_ENABLED false Master switch for offense recording and ban enforcement.
STREAMHUB_AUTOBAN_MAX_OFFENSES 10 Offenses within the window that trigger a ban.
STREAMHUB_AUTOBAN_WINDOW_S 300 Sliding offense window, in seconds.
STREAMHUB_AUTOBAN_BASE_TTL_S 900 First-ban duration, in seconds; doubles per repeat ban, capped at 7 days.
STREAMHUB_AUTOBAN_404_ENABLED false Count 404 responses from public IPs as offenses.

Everything below lives under /api/v1/security/* and uses the same global-scope gate as /cluster and /systemsuperadmin / global-scope token only; an app-scope token or non-superadmin session gets 403. Rule and ban mutations take effect immediately (in-memory reload), no restart needed. The Network security card in Server Settings (dashboard → Settings) is the UI over exactly this API.

Method Path Purpose
GET /security/status Mode, allowlist-only flag, auto-ban config, and rule/ban/offender counts
GET /security/ip-rules List rules, newest first
POST /security/ip-rules { cidr, action: allow|block, note? }400 on invalid CIDR or duplicate
DELETE /security/ip-rules/{id} Remove a rule (404 if unknown)
GET /security/bans { active, recent } bans
POST /security/bans/{ip}/unban Lift a ban — clean slate (404 if not banned)
GET /security/offenses Recent offenders: per-IP counts plus a kind breakdown
Terminal window
curl -s -X POST $BASE/security/ip-rules \
-H "Authorization: Bearer $STREAMHUB_TOKEN" -H 'Content-Type: application/json' \
-d '{"cidr":"203.0.113.0/24","action":"block","note":"credential-stuffing source"}'
curl -s $BASE/security/bans -H "Authorization: Bearer $STREAMHUB_TOKEN"
# { "data": { "active": [ { "ip": "198.51.100.7", "level": 1, "expiresAt": "..." } ], "recent": [...] } }
curl -s -X POST $BASE/security/bans/198.51.100.7/unban -H "Authorization: Bearer $STREAMHUB_TOKEN"
  • The middleware is registered for every route (forRoutes('*')), covering the API, the SPA, and every Nest-served surface. The Express static mounts registered ahead of the Nest router (/hls, /samples, /sdk, /live) are not covered — media-level protection there stays the firewall’s job, another reason to keep both layers.
  • With trust proxy enabled (the default behind Caddy/nginx), the client IP is the first X-Forwarded-For hop. If you expose core directly with no proxy in front, XFF can be spoofed — one more reason this is a complement, not a substitute, for perimeter controls.
  • A banned public IP gets 429 on every route, including /api/v1/health — public health probing from an abusive IP isn’t a supported case; loopback/private liveness is unaffected.
  • This is separate from the per-token allowedIps field on sk_ API tokens (see Authentication → API tokens): that’s a per-credential allowlist, this is a platform-wide gate that also auto-bans based on live abuse signals.