Skip to content

Security Model

envGo starts from one rule: a secret must never be needed by the browser. Everything else follows from that. This page explains the layers that enforce it.

Design principles

  1. Secrets stay server-side — the browser receives variable names, never values
  2. Deny by default — no allowlist means no proxy; unmatched routes are 404
  3. Defence in depth — independent guards, each of which alone blocks an attack class
  4. Fail closed — an unknown placeholder is a hard error, not a silent pass-through

Layer 1 — Secret isolation

┌─────────────────────────────────────────┐
│ Browser │
│ <div id="MY_SECRET"></div> │
│ <script src="/__env.js"></script> │
│ │
│ Receives: variable NAMES only │
└─────────────────────────────────────────┘
│ HTTP
┌─────────────────────────────────────────┐
│ envGo Server │
│ .env → in-memory map (RWMutex) │
│ {NAME} substituted here, then forwarded│
│ Values never serialised to the browser │
└─────────────────────────────────────────┘

The dashboard data endpoint returns env_path, env_count, vars (names), and request metadata. It never returns a value. That is the entire payload available to client-side code.

Layer 2 — Host validation

// enabled addresses come from the bound address, not from the request
func allowedHosts(addr string) []string {
_, port, _ := net.SplitHostPort(addr)
return []string{
addr,
net.JoinHostPort("localhost", port),
net.JoinHostPort(host, port),
}
}

Every request whose Host header is not in that set is rejected with 403. This defeats DNS rebinding: an attacker’s domain can resolve to 127.0.0.1, but the Host header still carries the attacker’s name and is refused.

Layer 3 — Origin validation

func (s *Server) originAllowed(origin string) bool {
if origin == "" {
return true // same-origin requests often omit Origin
}
for _, o := range s.origins {
if o == origin {
return true
}
}
return false
}

Only http://localhost:<port> and http://<bound-addr> are accepted. Any other Origin — including a page the user happens to have open on another site — is rejected with 403.

Layer 4 — Session token

tok := r.Header.Get("X-EnvGo-Token")
return subtle.ConstantTimeCompare([]byte(tok), []byte(empty)) != 1 &&
subtle.ConstantTimeCompare([]byte(tok), []byte(s.opts.Token)) == 1

A 256-bit token from crypto/rand, regenerated on every process start and served only by /__envgo_token. Comparison is constant-time and an empty token is explicitly rejected, so there is no “empty matches empty” edge case.

The three guards are applied together in authorized(): Host, then Origin, then token. All three must pass for /proxy.

Layer 5 — Outbound allowlist

func hostAllowed(host string, allow []string) bool {
if len(allow) == 0 {
return false // DENY BY DEFAULT
}
// exact match, or "*", or a subdomain of an allowlisted host
}

With no --allow, the proxy is disabled outright and envGo says so at startup. Matching is exact or subdomain-based, so --allow openai.com also covers api.openai.com. Only https:// targets are accepted.

Layer 6 — Rate limiting

type Limiter struct {
mu sync.Mutex
windows map[string]*window
}
type window struct {
count int
reset time.Time
}

A fixed-window counter keyed by route name plus client IP. Worth being precise about, because the properties differ from a token bucket:

  • A client can issue limit requests at the end of one window and limit again at the start of the next — a burst of up to 2 × limit across the boundary.
  • State is per process. Multiple instances each enforce their own limit.
  • Without trust_proxy: true behind a proxy, all clients share one bucket, because they all appear to come from the proxy’s IP.
  • An empty rate limit means unlimited. There is no default.

Exceeding the limit returns 429 with {"error":"rate limit exceeded"} and Retry-After: 60.

Layer 7 — Response and log redaction

There is no separate scrubResponse helper. Redaction is centralised in the logger, which is taught every loaded secret value at load time (excluding HOST and PORT):

func (l *Logger) Redact(s string) string {
// replace every known secret with [REDACTED], longest value first
return s
}

Three things flow through it:

  1. every log line (Info, Warn, Error, Debug)
  2. response bodies when scrub_response is enabled
  3. the request history shown on the dashboard

Replacing longest-first makes overlapping secrets deterministic — if one secret is a substring of another, the longer one is redacted first.

Layer 8 — Config and dotfile protection

The static handler refuses to serve:

  • any path segment beginning with . — so .env is unreachable even if it sits in the web root
  • envgo.routes.json, routes.json, and the exact path passed to --config
  • paths containing .. or \, or resolving outside the web root

Layer 9 — PHP containment

  • a 30-second timeout per request, enforced with exec.CommandContext
  • .env variables are passed through the process environment
  • source is scanned for undefined keys (typo detection) and for echo/print/var_dump/print_r around a secret access (security warning)
  • only the variables present in .env are injected

Layer 10 — TLS

Terminal window
envgo --tls
  • RSA 2048-bit self-signed certificate generated at startup
  • valid for 1 year
  • covers localhost and 127.0.0.1
  • MinVersion pinned to TLS 1.2

The certificate is generated fresh on each start and never written to disk. For anything public, terminate TLS at Caddy or nginx instead.

What envGo does not do

Not providedWhy it matters
User authentication or sessionsThe token is per-process, not per-user
Encryption of .env at restProtect the file with OS permissions
Tamper protection on upstream responsesenvGo forwards what upstream returns
Redaction of streaming responsesSee the caveat above
Multi-instance rate limitingCounters are in-process
Request/response body loggingDeliberately excluded from history

Next steps