Security

Diminuendo implements a defense-in-depth security architecture. No single layer is trusted exclusively; each defense operates independently and provides protection even if adjacent layers are compromised or misconfigured. The model is concentric: authentication establishes identity, authorization constrains action, transport secures the channel, input validation rejects malformed data, and error sanitization prevents information leakage on the way out. A failure in any one ring does not collapse the others.

Authentication

Auth0 JWT Verification

In production, every WebSocket connection must authenticate by sending an authenticate message with a JWT token. The gateway verifies tokens against Auth0’s JWKS endpoint using the jose library, which handles key rotation automatically — when a key ID in the token does not match the cached keyset, it fetches the latest keys from the endpoint.
const jwksUrl = new URL("/.well-known/jwks.json", authUrl)
const jwks = jose.createRemoteJWKSet(jwksUrl)

const { payload } = await jose.jwtVerify(jwt, jwks, {
  issuer: expectedIssuer,
  ...(expectedAudience ? { audience: expectedAudience } : {}),
})

JWT Verification Cache

Asymmetric JWT verification (RS256) is computationally expensive — each verification involves parsing the token, fetching the public key from the JWKS set, and performing a cryptographic signature check. To avoid paying this cost on every message from a previously authenticated connection, the gateway maintains an LRU cache of verified tokens:
  • Cache key: SHA-256 hash of the raw JWT string (via Bun.CryptoHasher)
  • Cache size: 10,000 entries maximum
  • TTL: Derived from the token’s exp claim, capped at 5 minutes
  • Eviction: FIFO when at capacity; expired entries cleaned every 60 seconds
const JWT_CACHE_MAX = 10_000
const jwtCache = new Map<string, { identity: AuthIdentity; expiresAt: number }>()

// Cache with TTL from exp claim (default 5min)
const ttlMs = result.exp ? (result.exp * 1000 - Date.now()) : 5 * 60 * 1000
if (ttlMs > 0) {
  if (jwtCache.size >= JWT_CACHE_MAX) {
    const firstKey = jwtCache.keys().next().value
    if (firstKey !== undefined) jwtCache.delete(firstKey)
  }
  jwtCache.set(cacheKey, {
    identity,
    expiresAt: Date.now() + Math.min(ttlMs, 5 * 60 * 1000),
  })
}
The 5-minute TTL cap is a security tradeoff: it limits the window during which a revoked token could be accepted from cache. For a gateway whose primary transport is a long-lived WebSocket connection, this window is acceptable — the token is verified in full on connection establishment, and the cache serves only subsequent re-authentications within the same session.

Dev Mode Bypass

When DEV_MODE=true, authentication is bypassed entirely. All connections are automatically authenticated as developer@example.com with tenant ID dev. This exists exclusively for local development.
Dev mode must never be enabled in production. The gateway logs a clear message at startup: "Auth: Dev mode enabled -- all requests authenticated as developer@example.com". Monitor for this message in production logs as a misconfiguration signal.

Role-Based Access Control

Roles and Permissions

The RBAC system defines 5 roles with 12 granular permissions. The permission matrix is intentionally conservative — each role receives the minimum set of capabilities required for its function:
Rolesession:createsession:readsession:writesession:deletesession:archivesession:steermember:readmember:writemember:deletebilling:readbilling:writetenant:admin
ownerYYYYYYYYYYYY
adminYYYYYYYY-Y--
billing_adminYYY-YY---YY-
memberYYY-YY------
viewer-Y----------

MembershipService

Role assignments are stored in per-tenant SQLite databases (tenants/{tenantId}/registry.db). The MembershipService provides CRUD operations on the tenant_members table:
CREATE TABLE IF NOT EXISTS tenant_members (
  tenant_id TEXT NOT NULL,
  user_id TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'member',
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  PRIMARY KEY (tenant_id, user_id)
);

Owner Bootstrap

The first user to authenticate against a tenant is automatically bootstrapped as owner. Subsequent users receive the member role by default. This eliminates the chicken-and-egg problem of tenant provisioning — no admin console is required to create the first privileged account:
const members = yield* membership.memberCount(identity.tenantId)
const bootstrapRole: Role = members === 0 ? "owner" : "member"
yield* membership.setRole(identity.tenantId, identity.userId, bootstrapRole)

Last Owner Protection

The system prevents removing or demoting the last owner of a tenant. Both removeMember and set_role check the owner count before proceeding — a tenant must always have at least one owner, or the organization becomes unmanageable:
if (existingMember?.role === "owner" && message.role !== "owner") {
  const ownerCount = members.filter((m) => m.role === "owner").length
  if (ownerCount <= 1) {
    return { kind: "respond", data: {
      type: "error",
      code: "LAST_OWNER_PROTECTED",
      message: "Cannot demote the last owner of a tenant",
    }}
  }
}

Permission Enforcement

The requirePermission function is the authorization checkpoint, called before every sensitive operation. It is deliberately simple — a pure function from role and permission to success or failure, with no side effects and no exceptions:
export function requirePermission(
  identity: AuthIdentityWithRole,
  permission: Permission,
): Effect.Effect<void, Unauthorized> {
  if (hasPermission(identity.role, permission)) {
    return Effect.void
  }
  return Effect.fail(new Unauthorized({ tenantId: identity.tenantId, resource: permission }))
}

CSRF Protection

Cross-Site Request Forgery protection uses a three-layer defense. Each layer is checked in order; the request passes if any layer succeeds. The layering accommodates the diversity of client types — browsers, CLIs, SDKs — each of which provides different ambient headers:
1

Sec-Fetch-Site Header

If the browser sends Sec-Fetch-Site: same-origin or Sec-Fetch-Site: none, the request is same-origin and passes immediately. This header cannot be spoofed by JavaScript running in the browser — it is set by the browser itself, making it the strongest signal available.
2

Origin Header

If the Origin header is present, it is checked against the ALLOWED_ORIGINS configuration list. If absent (non-browser clients such as CLIs and SDKs do not send Origin), the request passes — non-browser clients are not CSRF vectors.
3

Referer Header Fallback

If the Origin check fails, the Referer header is parsed and its origin component is checked against the same allowlist. This handles edge cases where some browsers strip the Origin header on certain redirect chains.
export function checkCsrf(
  req: Request,
  allowedOrigins: readonly string[],
  devMode = false,
): CsrfCheckResult {
  if (devMode) return { ok: true }

  const secFetchSite = req.headers.get("sec-fetch-site")
  if (secFetchSite === "same-origin" || secFetchSite === "none") return { ok: true }

  const origin = req.headers.get("origin")
  if (!origin) return { ok: true }  // Non-browser client

  if (allowedOrigins.includes(origin)) return { ok: true }

  // Fallback: check Referer
  const referer = req.headers.get("referer")
  if (referer) {
    try {
      if (allowedOrigins.includes(new URL(referer).origin)) return { ok: true }
    } catch { /* malformed referer */ }
  }

  return { ok: false, reason: `Origin '${origin}' is not in the allowed list` }
}

SSRF Guard

The assertSafeUrl function validates that outbound HTTP requests do not target internal networks. It inspects the URL’s hostname and blocks requests to private infrastructure:

Private IPv4 Ranges

0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16

IPv6 Addresses

Loopback (::1), unique local (fc00::/7), link-local (fe80::/10), IPv4-mapped (::ffff:127.0.0.1)

Cloud Metadata

169.254.169.254 (AWS/GCP), metadata.google.internal, metadata.google

Obfuscation Techniques

Bare integer IPs (http://2130706433), octal notation (0177.0.0.1), hex notation (0x7f.0.0.1)
The guard also restricts URL schemes to http: and https: only, blocking file:, ftp:, gopher:, and other protocol handlers.

IPv4-Mapped IPv6 Detection

A sophisticated attacker might attempt to bypass IPv4 range checks by using IPv4-mapped IPv6 addresses. The guard handles both dotted-quad form and hexadecimal form:
// Dotted-quad form
const v4MappedMatch = cleaned.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (v4MappedMatch) {
  return isPrivateIPv4(v4MappedMatch[1])
}

// Hex form -- reconstruct the IPv4 address from hex groups
const hexMappedMatch = cleaned.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
if (hexMappedMatch) {
  const hi = parseInt(hexMappedMatch[1], 16)
  const lo = parseInt(hexMappedMatch[2], 16)
  const ip = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`
  return isPrivateIPv4(ip)
}
The SSRF guard validates the hostname string, not the resolved IP address. It cannot protect against DNS rebinding attacks, where a hostname initially resolves to a public IP but is later re-resolved to a private IP. For full protection in high-security environments, validate the resolved IP at connect time using a custom DNS resolver or connect-time hook. The automation system’s egress proxy addresses this gap for unattended runs by performing connect-time IP validation.

Security Headers

Every HTTP response — including WebSocket upgrade responses, health checks, and 404s — includes a comprehensive set of security headers. These are applied unconditionally, not selectively:
export function securityHeaders(): Record<string, string> {
  return {
    "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
    "Content-Security-Policy": "default-src 'self'; connect-src 'self' wss: ws:",
    "X-Frame-Options": "DENY",
    "X-Content-Type-Options": "nosniff",
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
    "X-DNS-Prefetch-Control": "off",
    "X-XSS-Protection": "0",
  }
}
HeaderPurpose
Strict-Transport-SecurityForces HTTPS for 1 year, including subdomains
Content-Security-PolicyRestricts resource loading to same-origin; allows WebSocket connections
X-Frame-Options: DENYPrevents clickjacking via iframes
X-Content-Type-Options: nosniffPrevents MIME type sniffing
Referrer-PolicySends origin only on cross-origin requests
Permissions-PolicyDenies access to camera, microphone, and geolocation APIs
X-DNS-Prefetch-Control: offDisables DNS prefetching to prevent information leakage
X-XSS-Protection: 0Disables the legacy XSS Auditor, whose known bypass quirks can introduce vulnerabilities worse than the ones it ostensibly prevents

Error Sanitization

All error messages sent to clients pass through sanitizeErrorMessage, which applies three transformations in sequence. The goal is to be helpful without being confessional — the client learns what went wrong, but not how the system is built:
1

Strip Stack Traces

Removes lines matching the pattern \n\s*at\s+... — standard V8 stack trace lines that would expose internal file paths and module structure.
2

Redact API Keys

Replaces matches for five secret patterns with [REDACTED]:
const SECRET_PATTERNS: RegExp[] = [
  /sk-ant-[a-zA-Z0-9-]+/g,   // Anthropic API keys
  /sk-[a-zA-Z0-9-]+/g,       // OpenAI API keys
  /ghp_[a-zA-Z0-9]+/g,       // GitHub personal access tokens
  /Bearer [a-zA-Z0-9._-]+/g, // Bearer tokens
  /token=[a-zA-Z0-9._-]+/g,  // Query string tokens
]
3

Truncate

Messages exceeding 500 characters are truncated with an ellipsis. This bounds the size of error responses and prevents verbose internal errors from leaking excessive detail.
Additionally, the message router maps known error tags to safe, generic messages. The internal error taxonomy is rich; the client-facing vocabulary is deliberately sparse:
const safeMessages: Record<string, string> = {
  Unauthenticated: "Authentication required",
  Unauthorized: "Insufficient permissions",
  SessionNotFound: "Session not found",
  PodiumConnectionError: "Failed to connect to agent",
  DbError: "Database operation failed",
  InsufficientCredits: "Insufficient credits",
}

Rate Limiting

Per-Connection Rate Limiter

Every WebSocket connection receives its own sliding-window rate limiter. The window is local to the connection — there is no cross-connection coordination, and the limiter is removed from the tracking map when the WebSocket closes:
  • Limit: 60 messages per 10-second window
  • Enforcement: Checked before message parsing; exceeding the limit returns an error event with code RATE_LIMITED
  • Cleanup: Removed when the WebSocket closes
class RateLimiter {
  private timestamps: number[] = []
  constructor(
    private readonly maxMessages: number,  // 60
    private readonly windowMs: number,     // 10,000
  ) {}
  allow(): boolean {
    const now = Date.now()
    this.timestamps = this.timestamps.filter((t) => t > now - this.windowMs)
    if (this.timestamps.length >= this.maxMessages) return false
    this.timestamps.push(now)
    return true
  }
}

Authentication Rate Limiter

A separate, IP-based rate limiter protects the authentication endpoint from brute-force attacks:
ParameterValue
Max attempts10 per IP
Window60 seconds
Lockout duration5 minutes
Max tracked IPs10,000
The 10,000-IP bound prevents memory exhaustion from spoofed source addresses. When the map reaches capacity, the oldest entry is evicted (FIFO). On successful authentication, the IP’s record is cleared entirely:
recordSuccess(ip: string): void {
  this.records.delete(ip)
}
The auth rate limiter runs a cleanup() method every 60 seconds to prune expired entries and release lockouts. This periodic maintenance ensures the tracking map does not grow unbounded even if recordSuccess is never called for some IPs.

Input Validation

Schema Validation

Every incoming WebSocket message is validated against Effect Schema definitions before processing. The ClientMessage schema is a union of all message types, each with its own field requirements. Messages that do not match any variant are rejected immediately — they never reach the message router:
const decoded = Schema.decodeUnknownEither(ClientMessage)(parsed)
if (decoded._tag === "Left") {
  ws.send(JSON.stringify({
    type: "error",
    code: "INVALID_MESSAGE",
    message: "Message does not match any known schema",
  }))
  return
}

Message Size Limit

Raw messages exceeding 1 MB are rejected before JSON parsing, preventing denial-of-service via oversized payloads. The check operates on the raw byte length, not the parsed structure:
if (raw.length > 1_048_576) {
  ws.send(JSON.stringify({
    type: "error",
    code: "MESSAGE_TOO_LARGE",
    message: "Message exceeds maximum allowed size (1MB)",
  }))
  return
}

Session ID Path Traversal Prevention

Session IDs are used to construct file paths for per-session SQLite databases. The resolveSessionDir function validates that the resolved path stays within the sessions base directory — a traversal attempt like ../../etc/passwd would resolve to a path outside sessionsBaseDir and be rejected:
export function resolveSessionDir(sessionsBaseDir: string, sessionId: string): string {
  const resolved = path.resolve(sessionsBaseDir, sessionId)
  if (!resolved.startsWith(sessionsBaseDir + path.sep)) {
    throw new Error("Invalid session ID")
  }
  return resolved
}

Tenant ID Validation

Tenant IDs extracted from JWT claims are validated against a strict pattern before use. Only alphanumeric characters, hyphens, and underscores are permitted. This prevents directory traversal and SQL injection through the tenant ID:
const TENANT_ID_PATTERN = /^[a-zA-Z0-9_-]+$/

export function isValidTenantId(tenantId: string): boolean {
  return TENANT_ID_PATTERN.test(tenantId)
}

Transport Security

WebSocket Upgrade Validation

The WebSocket upgrade path (/ws) validates the Origin header before upgrading the connection. Non-browser clients (which do not send Origin) are allowed through, but browser-based connections from unauthorized origins are rejected with a 403 response.

Connection Lifecycle

ParameterValue
Max payload length2 MB (maxPayloadLength)
Idle timeout120 seconds
Per-message deflateDisabled (avoids CRIME-class compression attacks)
Heartbeat interval30 seconds
Per-message deflate is deliberately disabled. The CRIME and BREACH attack families exploit compression ratios to infer plaintext content from encrypted streams. Since WebSocket messages may contain sensitive data (authentication tokens, session content), disabling compression eliminates this entire class of attack at the cost of modest bandwidth overhead.

WAL Mode SQLite

All SQLite databases are opened with PRAGMA journal_mode = WAL. WAL mode prevents database corruption from concurrent access — reader and writer workers accessing the same file — and provides crash recovery: incomplete transactions are rolled back automatically on the next open.
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
synchronous = NORMAL provides a balance between durability and performance: data is safe after a process crash but may be lost on an OS crash or power failure. For a gateway managing ephemeral agent sessions, this tradeoff is appropriate — the Podium coordinator retains the authoritative event log, and session data can be reconstructed from upstream.