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.
The lock-out guarantee
Section titled “The lock-out guarantee”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.
IP rules
Section titled “IP rules”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:
- Loopback/private address → always allow (see above)
- Explicit
allowrule → allow (this also shields the IP from ban enforcement) - Active auto-ban →
429 - Explicit
blockrule →403(inenforcemode) STREAMHUB_IP_ALLOWLIST_ONLY=trueand no matchingallowrule →403(inenforcemode)- 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" } }. |
Auto-ban (in-app fail2ban)
Section titled “Auto-ban (in-app fail2ban)”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.
Tuning
Section titled “Tuning”- 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 servesindex.html, so real 404s come from the API surface). - Shared/NAT’d networks — raise
MAX_OFFENSESor 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, thenSTREAMHUB_IP_ACCESS_MODE=log+STREAMHUB_AUTOBAN_ENABLED=true, watchGET /security/offensesand theip-accesslog lines, then switch toenforce.
Environment variables
Section titled “Environment variables”| 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. |
Admin API
Section titled “Admin API”Everything below lives under /api/v1/security/* and uses the same
global-scope gate as /cluster and /system — superadmin / 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 |
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"Operational notes
Section titled “Operational notes”- 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 proxyenabled (the default behind Caddy/nginx), the client IP is the firstX-Forwarded-Forhop. 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
429on 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
allowedIpsfield onsk_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.