· Rong Zhu · 10 min read

Defense in Depth for Session Replay: Building Anti-Bot Infrastructure in Go

Rate limiting, behavioral analysis, SDK fingerprinting, and WebSocket auth — how PinConsole protects self-hosted session replay from bots and scrapers.

SecurityAnti-BotEngineeringSelf-Hosted

Why Session Replay Tools Are Prime Bot Targets

Session replay tools sit in a unique security position. They serve a JavaScript SDK to every visitor, maintain persistent WebSocket connections, and stream DOM data back to the server. For a bot operator, this is a goldmine — the SDK is public, the WebSocket protocol can be reverse-engineered, and the server accepts event data from anyone.

A naive session replay deployment can be abused in multiple ways:

• Event injection: a bot sends fake events to pollute analytics data • Data exfiltration: a scraper uses the SDK to capture page content at scale • Credential stuffing: if rate limits are weak, the login endpoint gets hammered • Session hijacking: if WebSocket auth is weak, an attacker can listen to live visitor streams

Because PinConsole is self-hosted, the security posture is different from a SaaS tool. There's no vendor-managed WAF, no Cloudflare in front, no security team monitoring traffic patterns. The security must be baked into the application itself.

This is how we built defense-in-depth — four layers, each catching what the previous one misses.

Layer 1: HTTP Rate Limiting (Fixed Window per IP)

The outermost defense is HTTP-level rate limiting. Every request to the PinConsole API passes through a Gin middleware that enforces a per-IP rate cap.

The implementation is straightforward: for each request, we INCR a Redis key (ratelimit:{ip}:{window}) and set EXPIRE to the window duration (60 seconds). If the count exceeds the limit (60 requests per minute by default), we return 429 Too Many Requests with standard rate limit headers.

A few design decisions worth noting:

• Redis failure is handled gracefully. If Redis is unreachable, the middleware allows the request through. We'd rather have a brief period of unlimited requests than knock the entire admin panel offline due to a Redis blip.

• The rate limiter is production-only. In development mode, it's disabled so e2e tests don't trip over rate caps.

• Health check endpoints (GET /healthz, GET /readyz) bypass rate limiting entirely.

• The response includes Retry-After and X-RateLimit-Remaining headers, so well-behaved clients can back off without waiting for errors.

This layer stops naive scrapers and misconfigured bots. But it won't stop a distributed attack with rotating IPs — that requires deeper analysis.

// Simplified HTTP rate limiter middleware
func RateLimitMiddleware(rdb *redis.Client) gin.HandlerFunc {
  return func(c *gin.Context) {
    ip := c.ClientIP()
    now := time.Now().Unix() / 60  // 1-minute window
    key := fmt.Sprintf("ratelimit:%s:%d", ip, now)

    count, err := rdb.Incr(ctx, key).Result()
    if err != nil {
      c.Next()  // Redis down: fail open
      return
    }
    if count == 1 {
      rdb.Expire(ctx, key, 60*time.Second)
    }
    if count > 60 {
      c.AbortWithStatusJSON(429, gin.H{"error": "rate_limit_exceeded"})
      return
    }
    c.Next()
  }
}

Layer 2: WebSocket Rate Limiting (Sliding Window per Session)

HTTP rate limiting doesn't apply to WebSocket connections — once the upgrade succeeds, the connection is persistent and messages flow freely. So we need a separate rate limiter for the WebSocket message stream.

PinConsole applies two sliding-window limits per session:

• 500 messages per 10-second window • 50 MiB of data per 10-second window

Both limits are enforced via a single Redis Lua script that atomically INCR (for message count) and INCRBY (for byte count) with EXPIRE. The key is scoped to the session ID, not the IP — this prevents a bot from rotating IPs to bypass the limit.

When either limit is exceeded, the server:

1. Flags the session via antiscrape.FlagSession() (persisted to Redis with a 10-minute TTL) 2. Immediately closes the WebSocket connection with a 1011 (Internal Error) status code 3. Logs the violation with the session ID for operator review

The flag propagates to the admin panel — any operator monitoring the live feed sees a warning that the session has been flagged for suspicious activity.

Crucially, the rate check happens before the event is written to the event stream. A flagged message is never persisted to MinIO or Redis Stream — it's rejected before it enters the pipeline. This prevents pollution of the event data even if the limiter is triggered mid-stream.

-- Lua: atomic WebSocket rate check
local countKey = KEYS[1]       -- ws:rate:count:{session}
local bytesKey = KEYS[2]       -- ws:rate:bytes:{session}
local window = ARGV[1]         -- 10 (seconds)
local maxMsg = ARGV[2]         -- 500
local maxBytes = ARGV[3]       -- 52428800 (50 MiB)
local msgSize = ARGV[4]

local msgCount = redis.call('INCR', countKey)
if msgCount == 1 then
  redis.call('EXPIRE', countKey, window)
end
if msgCount > tonumber(maxMsg) then
  return {0, "rate_exceeded"}
end

local byteCount = redis.call('INCRBY', bytesKey, msgSize)
if byteCount == msgSize then
  redis.call('EXPIRE', bytesKey, window)
end
if byteCount > tonumber(maxBytes) then
  return {0, "rate_exceeded"}
end

return {1, "ok"}

Layer 3: SDK Fingerprinting and Behavioral Analysis

HTTP rate limiting catches simple scrapers. WebSocket rate limiting catches aggressive event injection. But a sophisticated bot — one that sends human-like event patterns at human-like speeds — will pass both.

For that, we need behavioral analysis.

When the visitor SDK initializes, it collects a browser fingerprint via canvas hashing, WebGL vendor/rendering information, screen resolution, and timezone. This fingerprint isn't used for cross-site tracking — it's used for session identification and as an input to behavior analysis.

The server-side BehaviorTracker maintains per-session statistics:

• Mouse event count • Click position distribution • Event type distribution (mouse, scroll, input, resize, etc.) • Inter-event timing (min, max, first, last)

Every 100 events, the tracker runs three heuristic checks:

1. Zero mouse events with >50 total events → pure script generation. A human always generates at least some mouse movement.

2. More than 20 clicks at the same (x, y) coordinate → machine pattern. Humans don't click the same pixel 20 times.

3. Max interval / min interval < 2.0 with >100 events → machine-generated timing. Human event timing is irregular; bots are consistent.

If any heuristic triggers, the session is flagged in Redis (flagged:session:{id}, 10-minute TTL). The admin panel surfaces flagged sessions with a visible indicator and the flag reason.

These heuristics are simple by design. They have near-zero false positives for real visitors (humans always move their mouse and vary their timing) while catching the most common bot patterns. We don't need 99.9% bot detection accuracy — we need to make bot operation clearly more expensive than operating on a competitor's platform.

// Behavioral analysis: three heuristic checks
func (bt *BehaviorTracker) CheckAndFlag(sessionID string) (flagged bool, reason string) {
  bt.mu.Lock()
  defer bt.mu.Unlock()

  // Heuristic 1: no mouse movement
  if bt.totalEvents > 50 && bt.mouseEvents == 0 {
    return true, "no_mouse_movement"
  }

  // Heuristic 2: repeated clicks at same position
  for _, count := range bt.clickPositions {
    if count > 20 {
      return true, "repeated_clicks"
    }
  }

  // Heuristic 3: uniform event timing
  if bt.totalEvents > 100 && bt.minInterval > 0 {
    ratio := float64(bt.maxInterval) / float64(bt.minInterval)
    if ratio < 2.0 {
      return true, "uniform_timing"
    }
  }

  return false, ""
}

Layer 4: WebSocket Authentication — No Tokens in URLs

WebSocket connections are particularly vulnerable to auth leaks. The most common pattern — passing a JWT token as a query parameter — leaks the token to server logs, referrer headers, and browser history.

PinConsole takes a different approach for operator WebSocket connections. The operator logs in via a standard form POST, receiving an HttpOnly session cookie (mm_session). This cookie:

• Has SameSite=Lax — prevents CSRF from external origins • Is HttpOnly — inaccessible to JavaScript, preventing XSS theft • Is Secure in production — only sent over HTTPS • Has a 24-hour MaxAge — automatic session expiry

When the admin SPA opens a WebSocket connection, the mm_session cookie is automatically included by the browser (same-origin). The server validates the cookie against Redis before accepting the WebSocket upgrade. If invalid, the connection is rejected with a 401 before any data exchange.

Visitor WebSocket connections use a different mechanism. After the SDK calls POST /api/session/init, the server returns a session_id. This ID is sent in the hello message after the WebSocket connection is established. The server validates session existence and visitor-session binding before accepting events.

For both paths, the key principle is: authentication happens before the WebSocket connection is established. There's no window where an unauthenticated connection can observe traffic.

User-Agent Blacklist: Honest About Its Limitations

We include a User-Agent blocklist as a lightweight first filter. It catches the lowest-effort scrapers:

• curl/, wget/, python-requests/, Go-http-client/ • HeadlessChrome, PhantomJS, jsdom • scrapy, bot, crawler, spider (substring match) • Empty User-Agent

The check is case-insensitive substring matching — deliberately simple to avoid false negatives from creative UA formatting. Blocked requests get a 403 with a descriptive error code (blocked_user_agent or empty_user_agent).

But the code comment is honest: "UA blacklist only stops low-effort scrapers; modern Puppeteer/Playwright headless=new UAs are nearly identical to real browsers." This layer catches the dumb bots. The smart ones require the deeper layers.

Notably, the UA middleware runs even in development mode. It's the only security layer that does — because if you're developing with curl scripts against the API, you should know it won't work without setting a real browser UA.

URL Safety: Preventing Operator-Initiated Attacks

Co-browsing gives operators the ability to navigate the visitor's browser and show popups. This is powerful — and dangerous if not constrained.

Navigation commands go through isURLAllowed():

• Same-origin URLs are always allowed • localhost URLs are allowed (for development and internal tools) • Cross-origin URLs are blocked unless the operator has explicitly whitelisted them

Popup URLs go through isURLSchemeAllowed():

• Only https: and relative URLs are permitted • javascript:, data:, vbscript:, file: are explicitly rejected

These checks happen server-side, in the command handler. Even if a compromised admin client sends a malicious command, the server rejects it before it reaches the visitor's SDK.

The command type itself is also whitelisted — only 8 types are accepted. Any unknown type is silently dropped at the API layer.

Login Brute-Force Protection and Password Policies

The authentication system has its own dedicated protection layer.

Login attempts are rate-limited per (email, IP) pair: 5 failed attempts within 15 minutes triggers a lockout. The counter is maintained in Redis via an atomic Lua script, so concurrent requests from the same credentials can't race past the limit.

Redis failure is handled gracefully here too — if Redis is down, login attempts bypass the rate check. We'd rather have a brief window of unlimited login attempts than permanently lock all operators out of the admin panel.

Passwords are hashed with bcrypt at cost 12. The minimum cost is enforced at startup — if someone sets BCRYPT_COST below 12, the server refuses to start. This prevents accidental weakening of the hash cost in production.

The admin account creation flow requires the operator to change the default password on first login. The SYSTEM_EMAIL and SYSTEM_PASSWORD environment variables are validated at startup to ensure they're not default values in production mode.

Configuration Fail-Secure: Preventing Deployment Mistakes

Security misconfigurations are the most common cause of breaches. We built a startup validation system that checks critical configuration before the server binds to any port:

• SERVER_ENV must be one of: development, staging, production. Typos like "produciton" are caught. • In production mode, SYSTEM_PASSWORD must not be the default value. • In production mode, MinIO credentials must not be the default minioadmin/minioadmin. • If PostgreSQL is not on localhost, SSL mode must be enabled. • If MinIO endpoint is not localhost, TLS must be enabled (https://).

Each check produces a specific error message: "SERVER_ENV=produciton is invalid; did you mean production?" — not a generic "configuration error". This reduces debugging time when deploying.

Some checks are environment-conditional. For example, local development can use plain PostgreSQL without SSL, but production deployments to a remote database must have SSL enabled. The server adapts its strictness to the deployment context.

We also enforce the release build flag at the code level. Bypass mechanisms (like dev-mode authentication skip) are gated by //go:build !release build tags. Even if someone sets SERVER_ENV=development in production, the release binary's bypass functions always return false. The compiler enforces this — it's not a runtime check that could be misconfigured.

What We Don't Do (And Why)

Honesty about security is important. Here's what we deliberately don't implement:

No TLS fingerprinting (JA3). Passive JA3 fingerprinting of WebSocket connections is a powerful anti-bot technique, but it requires maintaining a fingerprint database and handling false positives from browser version churn. We chose behavioral analysis instead — simpler, more transparent, and harder to evade without fundamentally changing bot behavior.

No Content Security Policy. Because the admin panel, visitor SDK, and API all share the same origin (single-binary deployment), CSP adds complexity without proportional benefit. The main attack vector CSP would prevent — XSS — is already mitigated by HttpOnly cookies and input validation.

No WAF. Self-hosted deployments have their own reverse proxy (Nginx, Caddy), and adding a WAF would create deployment friction. Security should be easy to get right.

These gaps are filled by the deployment environment: TLS termination at the reverse proxy, network-level IP blocking at the firewall, and monitoring at the infrastructure level.

For a self-hosted tool, "defense in cooperation with the deployment environment" is more realistic than "defense in a single application." The four layers we built handle application-level threats. Infrastructure threats are handled by the operator's existing security stack — because they know their network better than we do.

Security by Audit: How We Found and Fixed a Vulnerability

No security system is perfect on day one. Our erasure API (DELETE /api/privacy/visitor/:fingerprint) initially had no role check — any authenticated operator could trigger a GDPR erasure. This was caught in a routine code audit (slice 1ac).

The fix was a single guard: require the admin role before processing erasure requests. The commit message notes: "role check: only admin can delete visitors."

This is a pattern we follow deliberately. Rather than trying to build a perfect security model upfront and freezing it, we ship early with reasonable guards and audit continuously. The audit log of our own fixes is transparent — there's no security-through-obscurity.

If you're deploying PinConsole, you can see every security fix in the git log. You can run the test suite to verify the fix works. And if you find a vulnerability, you can submit a fix — it's open source.

Try Self-Hosted Session Replay

Defense-in-depth baked in. AGPL-3.0. Your data, your servers.