Short, truthful summary of what the shipped defaults do and do not protect against, and how to move from dev defaults to something safer.
Otter has two operating modes selected via dev_mode (YAML) or DEV_MODE
(env). dev_mode defaults to true if unset.
- If
ws_tokenis unset, Otter generates a random token at startup and logs it once to stdout with an explicit warning. Restarting rotates it. - If
ws_allowed_originsis empty, the WebSocketCheckOriginaccepts every origin. A warning is logged at startup. - Suitable for local development, CI, and the shipped Docker example.
- Otter refuses to start unless both are explicit:
ws_token(orWS_TOKENenv) is non-empty.ws_allowed_origins(orWS_ALLOWED_ORIGINSenv) has at least one entry.
- This is the contract that makes the dev-only defaults safe: they only apply when the operator has explicitly asked for dev mode.
Turn off dev mode like this:
dev_mode: false
ws_token: "replace-me-with-a-real-secret"
ws_allowed_origins:
- https://app.example.com
- "*.internal.example.com"Or via environment:
export DEV_MODE=false
export WS_TOKEN="$(openssl rand -hex 24)"
export WS_ALLOWED_ORIGINS="https://app.example.com,*.internal.example.com"The HTTP proxy and the WebSocket upgrade endpoint both run on
http.Server instances with explicit timeouts:
| Field | Value | Notes |
|---|---|---|
ReadHeaderTimeout |
5s | mitigates slow-loris |
ReadTimeout |
30s (HTTP only) | bounds the full request read |
WriteTimeout |
60s (HTTP only) | long enough for batched upserts |
IdleTimeout |
120s | closes idle keep-alive connections |
On SIGINT or SIGTERM, Otter calls srv.Shutdown on both servers with a
10-second grace period before forcing exit.
Request bodies on the HTTP proxy are capped with http.MaxBytesReader.
The cap defaults to 1 MiB. Oversized bodies are rejected with HTTP 413.
Override with:
max_body_bytes: 4194304 # 4 MiBor
export MAX_BODY_BYTES=4194304WebSocket messages are capped separately, also defaulting to 1 MiB:
ws_max_message_bytes: 4194304 # 4 MiBor
export WS_MAX_MESSAGE_BYTES=4194304- Token comparison uses
crypto/subtle.ConstantTimeCompareviaConstantTimeTokenEqualto avoid leaking the token length through timing. - Origin matching supports exact URL strings and
*.example.comsubdomain wildcards. A literal*pattern is accepted only as a deliberate escape hatch in dev mode. validate.goalready rejects auth messages without a token.
The forwardGraphQL path uses a dedicated http.Client with a 30s
total timeout, so a hanging Dgraph backend cannot pin a handler
goroutine indefinitely.
The startup "Final Loaded Configuration" log dump redacts both
dgraph_password and ws_token before logging. Neither is ever
printed in plain text after LoadConfig returns.
The following are called out explicitly so operators know what they are picking up:
- There is no per-client rate limit on either the HTTP or WebSocket surface. A single abusive client can still saturate a backend.
- There is no mTLS or TLS termination in Otter itself; the shipped setup assumes a reverse proxy in front for that.
- The WebSocket token is a shared secret, not per-user auth. Rotation requires restarting Otter. A multi-user auth story is not in scope for Phase 3.
internal/proxy/proxy.go:95still derives the HTTP port asgrpcPort - 1000. It works for canonical Dgraph ports and will silently misbehave otherwise.- Health checks in
docker-compose.ymlare replaced by the host-sidescripts/wait-for-otter.sh; there is no liveness/readiness probe baked into Otter itself.
Each of these is a tracked follow-up in docs/phase1_backlog.md
under the "Next" and "Later" sections.