Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,13 @@

# Observability (optional)
# AGENT_VAULT_LOG_LEVEL=info # info (default) | debug — debug emits one line per proxied request (no secret values)

# Rate limiting (optional) — tiered limits with sensible defaults.
# Profile: default | strict (≈0.5×) | loose (≈2×) | off (disable all limits).
# AGENT_VAULT_RATELIMIT_PROFILE=default
# When true, the owner UI cannot override rate-limit settings (operator pin).
# AGENT_VAULT_RATELIMIT_LOCK=false
# Fine-grained overrides (rare): AGENT_VAULT_RATELIMIT_<TIER>_<KNOB>
# where TIER ∈ AUTH | PROXY | AUTHED | GLOBAL
# and KNOB ∈ RATE | BURST | WINDOW | MAX | CONCURRENCY. Example:
# AGENT_VAULT_RATELIMIT_PROXY_BURST=50
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ See the [installation guide](https://docs.agent-vault.dev/installation) for full
### Script (macOS / Linux)

```bash
curl -fsSL https://raw.githubusercontent.com/Infisical/agent-vault/main/install.sh | sh
curl -fsSL https://get.agent-vault.dev | sh
agent-vault server -d
```

Expand Down Expand Up @@ -117,6 +117,12 @@ const caCert = session.containerConfig!.caCertificate;

See the [TypeScript SDK README](sdks/sdk-typescript/README.md) for full documentation.

## Rate limiting

Agent Vault ships with a **tiered, in-memory rate limiter** keyed on the principal appropriate for each endpoint (client IP for anonymous auth, hashed token for invite/approval redemption, `(actor, vault)` scope for the proxy path, global in-flight ceiling for the server). Defaults are tuned for normal use — agents doing realistic bursts of proxy calls don't trip anything — and 429 responses carry a `Retry-After` header so clients can back off politely.

Pick a preset via `AGENT_VAULT_RATELIMIT_PROFILE={default,strict,loose,off}`, or fine-tune per tier in **Manage Instance → Settings → Rate Limiting** (owner-only). Set `AGENT_VAULT_RATELIMIT_LOCK=true` on PaaS to pin limits to env vars and disable the UI. See [docs/self-hosting/environment-variables.mdx](docs/self-hosting/environment-variables.mdx) for the full knob list.

## Development

```bash
Expand Down
13 changes: 8 additions & 5 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,14 @@ func attachMITMIfEnabled(srv *server.Server, host string, mitmPort int, masterKe
}
srv.AttachMITM(mitm.New(
net.JoinHostPort(host, strconv.Itoa(mitmPort)),
caProv,
srv.SessionResolver(),
srv.CredentialProvider(),
srv.BaseURL(),
srv.Logger(),
mitm.Options{
CA: caProv,
Sessions: srv.SessionResolver(),
Credentials: srv.CredentialProvider(),
BaseURL: srv.BaseURL(),
Logger: srv.Logger(),
RateLimit: srv.RateLimit(),
},
))
return nil
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/skill_cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ Prints the raw value to stdout (pipe-friendly). Useful for configuration tasks w
- 401: Invalid or expired token -- check `AGENT_VAULT_SESSION_TOKEN`
- 403 `forbidden`: Host not allowed -- create a proposal
- 403 `service_disabled`: Host is configured but currently disabled by an operator. Don't create a new proposal; surface the error to the user so they can re-enable it (UI toggle, or `agent-vault vault service enable <host>`)
- 429: Too many pending proposals -- wait for review
- 429: Rate limited. The response carries a `Retry-After` header (seconds) and a JSON body `{"error":"too_many_requests", ...}`. Respect `Retry-After` — wait that many seconds before retrying. Don't tight-loop or switch to a different Agent Vault ingress to bypass it (MITM + explicit `/proxy/` share one budget). If this trips on normal work, ask the instance owner to raise the limit in **Manage Instance → Settings → Rate Limiting**.
- 502: Missing credential or upstream unreachable, tell user a credential may need to be added

## Rules
Expand Down
2 changes: 1 addition & 1 deletion cmd/skill_http.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ Content-Type: application/json
- 401: Invalid or expired token -- check `AGENT_VAULT_SESSION_TOKEN`
- 403 `forbidden`: Host not allowed -- create a proposal
- 403 `service_disabled`: Host is configured but currently disabled by an operator. Don't create a new proposal; surface the error to the user so they can re-enable it
- 429: Too many pending proposals -- wait for review
- 429: Rate limited. The response carries a `Retry-After` header (seconds) and a JSON body `{"error":"too_many_requests", ...}`. Respect `Retry-After` — wait that many seconds before retrying. Do **not** tight-loop or switch to a different Agent Vault ingress to bypass the limit; the MITM and explicit `/proxy/` paths share one budget. If the limit trips repeatedly on normal work, ask the instance owner to raise the limit in **Manage Instance → Settings → Rate Limiting**.
- 502: Missing credential or upstream unreachable, tell user a credential may need to be added

## Rules
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ description: "Complete reference for all Agent Vault CLI commands."
| `AGENT_VAULT_SMTP_FROM_NAME` | Sender display name (default `Agent Vault`) |
| `AGENT_VAULT_SMTP_TLS_MODE` | TLS mode: `opportunistic` (default), `required`, or `none` |
| `AGENT_VAULT_SMTP_TLS_SKIP_VERIFY` | Skip TLS certificate verification (default `false`) |
| `AGENT_VAULT_RATELIMIT_PROFILE` | Rate-limit profile: `default`, `strict`, `loose`, or `off`. Affects anonymous auth, token-redeem, proxy, authenticated CRUD, and the global in-flight / RPS ceilings. |
| `AGENT_VAULT_RATELIMIT_LOCK` | When `true`, the rate-limit section in the Manage Instance UI is read-only and UI overrides are ignored. Use when you want limits pinned to env vars on PaaS. |
| `AGENT_VAULT_RATELIMIT_<TIER>_<KNOB>` | Fine-grained per-tier overrides. `TIER` ∈ `AUTH`, `PROXY`, `AUTHED`, `GLOBAL`. `KNOB` ∈ `RATE`, `BURST`, `WINDOW`, `MAX`, `CONCURRENCY`. Env-set knobs always beat UI overrides. |
</Accordion>

<Accordion title="agent-vault server stop">
Expand Down
3 changes: 3 additions & 0 deletions docs/self-hosting/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ description: "All environment variables used to configure Agent Vault"
| `AGENT_VAULT_NETWORK_MODE` | `public` | Proxy network restriction mode. `public` blocks connections to private/reserved IP ranges (RFC-1918, link-local, cloud metadata). `private` allows all outbound connections including private ranges — use this for local/private deployments where the proxy needs to reach internal services. |
| `AGENT_VAULT_TRUSTED_PROXIES` | (unset) | Comma-separated CIDR ranges of trusted reverse proxies (e.g. `10.0.0.0/8,172.16.0.0/12`). When set, `X-Forwarded-For` is only trusted if the direct connection comes from a listed proxy. Used for rate limiting and audit logging behind a load balancer. |
| `AGENT_VAULT_LOG_LEVEL` | `info` | Log level for the server. `info` (default) keeps startup banners and warnings only. `debug` adds one structured line per proxied request (ingress path, method, host, path, matched service, injected credential **key names**, upstream status, duration). Credential values are never logged. The `--log-level` flag takes precedence when set. |
| `AGENT_VAULT_RATELIMIT_PROFILE` | `default` | Rate-limit profile: `default`, `strict` (≈0.5× the defaults), `loose` (≈2×), or `off` (disable all limits). Affects every tier — anonymous auth, token-redeem, proxy, authenticated CRUD, global in-flight. Owners can override per-tier in **Manage Instance → Settings → Rate Limiting** unless `AGENT_VAULT_RATELIMIT_LOCK=true`. |
| `AGENT_VAULT_RATELIMIT_LOCK` | `false` | When `true`, the rate-limit UI in **Manage Instance** is read-only and UI overrides are ignored. Use on PaaS deployments (Fly.io, Cloud Run) when the operator wants limits pinned to env vars. |
| `AGENT_VAULT_RATELIMIT_<TIER>_<KNOB>` | — | Fine-grained per-tier overrides. `TIER` is one of `AUTH` (unauthenticated endpoints), `PROXY` (proxy + MITM), `AUTHED` (everything behind requireAuth), `GLOBAL` (server-wide backstop). `KNOB` is one of `RATE` (tokens/sec), `BURST` (bucket depth), `WINDOW` (duration like `5m`), `MAX` (sliding-window event cap), `CONCURRENCY` (semaphore slots). Env-set knobs always take precedence over UI overrides. |

Master password resolution order:

Expand Down
17 changes: 17 additions & 0 deletions internal/brokercore/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import (
"github.com/Infisical/agent-vault/internal/store"
)

// MaxProxyBodyBytes caps forwarded request bodies on both proxy
// ingresses. Distinct from the generic 1 MB limitBody wrapper used
// on control-plane endpoints: proxy bodies are legitimately larger
// (file uploads, bulk API payloads) but must still be bounded to
// protect RAM under the proxy concurrency semaphore.
const MaxProxyBodyBytes = 64 << 20

// ProxyScope is the resolved identity + vault context for a proxy request.
// It is produced once per ingress (per request for /proxy, per CONNECT for
// MITM) and carried through to credential injection.
Expand All @@ -18,6 +25,16 @@ type ProxyScope struct {
VaultRole string
}

// ActorID returns the non-empty principal ID — UserID for user
// sessions, AgentID for agent tokens. Used as the actor dimension in
// per-scope rate-limit keys.
func (s *ProxyScope) ActorID() string {
if s.UserID != "" {
return s.UserID
}
return s.AgentID
}

// SessionResolver collapses bearer-token validation and vault selection into
// one call. Both ingresses use the same resolver; MITM passes a vault hint
// parsed from Proxy-Authorization, /proxy passes r.Header.Get("X-Vault").
Expand Down
28 changes: 27 additions & 1 deletion internal/mitm/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,34 @@ import (
"time"

"github.com/Infisical/agent-vault/internal/brokercore"
"github.com/Infisical/agent-vault/internal/ratelimit"
)

// mitmConnectIPKey is the rate-limit key for the CONNECT-flood
// limiter. X-Forwarded-For doesn't exist at this layer (the HTTP
// request is tunnelled); only the direct peer IP is meaningful.
func mitmConnectIPKey(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil || host == "" {
host = r.RemoteAddr
}
return "mitm:" + host
}

// handleConnect terminates a CONNECT tunnel and serves HTTP/1.1 off the
// resulting TLS connection. The upstream target is taken from the
// CONNECT request line (r.Host) and captured in a closure so subsequent
// Host-header rewrites by the client cannot redirect the tunnel.
func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
// Gate before ParseProxyAuth + session lookup so a bad-auth flood
// can't burn CPU. Per-IP on the raw TCP peer.
if p.rateLimit != nil {
if d := p.rateLimit.Allow(ratelimit.TierAuth, mitmConnectIPKey(r)); !d.Allow {
ratelimit.WriteDenial(w, d, "Too many CONNECT attempts")
return
}
}

target := r.Host
host, _, err := net.SplitHostPort(target)
if err != nil {
Expand Down Expand Up @@ -87,8 +108,13 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
// closes the listener so Serve returns.
listener := newOneShotListener(tlsConn)
srv := &http.Server{
Handler: p.forwardHandler(target, host, scope),
Handler: p.forwardHandler(target, host, scope),
// Slow-loris defense: without these the tunnel can drip bytes
// forever and pin a proxy concurrency slot.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 5 * time.Minute, // upstream streaming can be legit
IdleTimeout: 2 * time.Minute,
ConnState: func(c net.Conn, state http.ConnState) {
if state == http.StateClosed || state == http.StateHijacked {
_ = listener.Close()
Expand Down
12 changes: 12 additions & 0 deletions internal/mitm/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/Infisical/agent-vault/internal/brokercore"
"github.com/Infisical/agent-vault/internal/ratelimit"
)

// forwardHandler returns an http.Handler that forwards each request to
Expand All @@ -28,6 +29,17 @@ func (p *Proxy) forwardHandler(target, host string, scope *brokercore.ProxyScope
event.Emit(p.logger, start, status, errCode)
}

// Shares one budget with /proxy so switching ingress can't bypass.
enf := p.rateLimit.EnforceProxy(r.Context(), scope.ActorID(), scope.VaultID)
if !enf.Allowed {
ratelimit.WriteDenial(w, enf.Decision, enf.Message)
emit(http.StatusTooManyRequests, enf.ErrCode)
return
}
defer enf.Release()

r.Body = http.MaxBytesReader(w, r.Body, brokercore.MaxProxyBodyBytes)

outURL := &url.URL{
Scheme: "https",
Host: target,
Expand Down
47 changes: 30 additions & 17 deletions internal/mitm/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/Infisical/agent-vault/internal/brokercore"
"github.com/Infisical/agent-vault/internal/ca"
"github.com/Infisical/agent-vault/internal/netguard"
"github.com/Infisical/agent-vault/internal/ratelimit"
)

// Proxy is a transparent MITM proxy. It is safe to start at most once;
Expand All @@ -41,15 +42,27 @@ type Proxy struct {
isListening atomic.Bool
baseURL string // externally-reachable control-plane URL for help links
logger *slog.Logger
rateLimit *ratelimit.Registry // shared with the HTTP server; nil = no-op
}

// New builds a Proxy bound to addr using caProv for leaf certificates and
// the brokercore sessions/creds for authentication and credential injection.
// baseURL is the externally-reachable control-plane URL (e.g.
// "http://127.0.0.1:14321") used to build help links in error responses.
// The returned Proxy does not begin listening until ListenAndServe is
// called. logger must be non-nil; tests can pass slog.New(slog.DiscardHandler).
func New(addr string, caProv ca.Provider, sessions brokercore.SessionResolver, creds brokercore.CredentialProvider, baseURL string, logger *slog.Logger) *Proxy {
// Options carries the dependencies a Proxy needs. BaseURL is the
// externally-reachable control-plane URL used in help-link error
// responses. Logger must be non-nil; tests can pass
// slog.New(slog.DiscardHandler). RateLimit is shared with the HTTP
// server so proxy limits apply uniformly across both ingresses; nil
// disables rate limiting on the MITM path.
type Options struct {
CA ca.Provider
Sessions brokercore.SessionResolver
Credentials brokercore.CredentialProvider
BaseURL string
Logger *slog.Logger
RateLimit *ratelimit.Registry
}

// New builds a Proxy bound to addr. The returned Proxy does not begin
// listening until ListenAndServe is called.
func New(addr string, opts Options) *Proxy {
upstream := &http.Transport{
DialContext: netguard.SafeDialContext(netguard.ModeFromEnv()),
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
Expand All @@ -61,12 +74,13 @@ func New(addr string, caProv ca.Provider, sessions brokercore.SessionResolver, c
}

p := &Proxy{
ca: caProv,
sessions: sessions,
creds: creds,
upstream: upstream,
baseURL: baseURL,
logger: logger,
ca: opts.CA,
sessions: opts.Sessions,
creds: opts.Credentials,
upstream: upstream,
baseURL: opts.BaseURL,
logger: opts.Logger,
rateLimit: opts.RateLimit,
}

p.tlsConfig = &tls.Config{
Expand All @@ -75,16 +89,15 @@ func New(addr string, caProv ca.Provider, sessions brokercore.SessionResolver, c
sni := hello.ServerName
if sni == "" {
// No SNI (IP-literal connection per RFC 6066). Use the
// actual local address the client connected to so the
// cert SAN matches regardless of IPv4/IPv6 or which
// interface was used on a wildcard bind.
// local address the client connected to so the cert
// SAN matches regardless of IPv4/IPv6 or wildcard bind.
if host, _, err := net.SplitHostPort(hello.Conn.LocalAddr().String()); err == nil && host != "" {
sni = host
} else {
sni = "127.0.0.1"
}
}
return caProv.MintLeaf(sni)
return opts.CA.MintLeaf(sni)
},
}

Expand Down
8 changes: 7 additions & 1 deletion internal/mitm/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ func setupProxy(t *testing.T, sr brokercore.SessionResolver, cp brokercore.Crede
t.Fatal("failed to load CA root PEM into pool")
}

p = New("127.0.0.1:0", caProv, sr, cp, "http://127.0.0.1:14321", slog.New(slog.DiscardHandler))
p = New("127.0.0.1:0", Options{
CA: caProv,
Sessions: sr,
Credentials: cp,
BaseURL: "http://127.0.0.1:14321",
Logger: slog.New(slog.DiscardHandler),
})

l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
Expand Down
Loading