fix: lock down REST session API against CSRF and DNS rebinding

Require --auth-token on non-loopback listeners, enforce JSON
content-type and origin checks on every method (not just mutations),
and add --remote-auth-token so `run --remote` can talk to a
token-protected listener.

Assisted-By: docker-agent
This commit is contained in:
David Gageot committed 2026-10-18 19:31:55 +02:00
1 parent e1027bd878
commit ca4254f2d8
18 files changed
+656 -54

No files matched your search

+5 -22
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"os"
"time"
@@ -14,6 +13,7 @@ import (
"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/sources"
"github.com/docker/docker-agent/pkg/httpsec"
pathx "github.com/docker/docker-agent/pkg/path"
"github.com/docker/docker-agent/pkg/profiling"
"github.com/docker/docker-agent/pkg/server"
@@ -54,7 +54,7 @@ func newAPICmd() *cobra.Command {
cmd.PersistentFlags().IntVar(&flags.pullIntervalMins, "pull-interval", 0, "Auto-pull OCI reference every N minutes (0 = disabled)")
cmd.PersistentFlags().StringVar(&flags.fakeResponses, "fake", "", "Replay AI responses from cassette file (for testing)")
cmd.PersistentFlags().StringVar(&flags.recordPath, "record", "", "Record AI API interactions to cassette file")
cmd.PersistentFlags().StringVar(&flags.authToken, "auth-token", "", "Bearer token required for API requests (empty = no authentication)")
cmd.PersistentFlags().StringVar(&flags.authToken, "auth-token", "", "Bearer token required for API requests (required for non-loopback TCP listeners)")
cmd.PersistentFlags().Int64Var(&flags.maxRequestSize, "max-request-size", 1<<20, "Maximum request body size in bytes (default 1 MiB). Requests exceeding this limit are rejected with HTTP 413.")
cmd.PersistentFlags().StringVar(&flags.sessionWorkingDirRoot, "session-workingdir-root", "", "Restrict the working_dir of sessions created via POST /api/sessions to this directory and its descendants (empty = no restriction; recommended for multi-user deployments)")
cmd.PersistentFlags().StringVar(&flags.pprofAddr, "pprof-addr", "", "TCP host:port to expose Go pprof endpoints at /debug/pprof/ (e.g. 127.0.0.1:6060); also set via DOCKER_AGENT_PPROF_ADDR")
@@ -120,8 +120,10 @@ func (f *apiFlags) runAPICommand(cmd *cobra.Command, args []string) (commandErr
}
defer lnCleanup()
if err := httpsec.ValidateListenerAuth(ln.Addr(), f.authToken); err != nil {
return err
}
out.Println("Listening on", ln.Addr().String())
warnIfNotLoopback(out, ln.Addr())
slog.DebugContext(ctx, "Starting server", "agents", agentsPath, "addr", ln.Addr().String())
@@ -154,22 +156,3 @@ func (f *apiFlags) runAPICommand(cmd *cobra.Command, args []string) (commandErr
return s.Serve(ctx, ln)
}
// warnIfNotLoopback prints a security warning when the API server is bound to
// an address other than loopback. The default --listen value is 127.0.0.1, so
// reaching this code path means the operator was explicit about exposing the
// API; we just remind them that the API has no authentication.
func warnIfNotLoopback(out *cli.Printer, addr net.Addr) {
tcpAddr, ok := addr.(*net.TCPAddr)
if !ok {
// Unix sockets and named pipes rely on filesystem permissions.
return
}
if tcpAddr.IP.IsLoopback() {
return
}
out.Println("WARNING: API server is listening on a non-loopback address.")
out.Println(" The API has no authentication; anyone able to reach")
out.Println(" this address can run agents and access all sessions.")
slog.Warn("API server bound to non-loopback address", "addr", tcpAddr.String())
}
+3 -1
View File
@@ -1,6 +1,7 @@
package root
import (
"cmp"
"context"
"fmt"
"log/slog"
@@ -206,7 +207,8 @@ func (b *remoteBackend) CreateSessionRequest(workingDir string) runtime.CreateSe
}
func (b *remoteBackend) CreateSession(ctx context.Context, _ *teamloader.LoadResult, req runtime.CreateSessionRequest) (runtime.Runtime, *session.Session, func(), error) {
client, err := runtime.NewClient(b.flags.remoteAddress)
token := cmp.Or(b.flags.remoteAuthToken, os.Getenv("DOCKER_AGENT_REMOTE_AUTH_TOKEN"))
client, err := runtime.NewClient(b.flags.remoteAddress, runtime.WithAuthToken(token))
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to create remote client: %w", err)
}
+76
View File
@@ -0,0 +1,76 @@
package root
import (
"bytes"
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/server"
"github.com/docker/docker-agent/pkg/session"
)
func TestRemoteBackendAuthentication(t *testing.T) {
store := session.NewInMemorySessionStore()
sm := server.NewSessionManager(t.Context(), nil, store, 0, &config.RuntimeConfig{})
srv := server.NewWithManager(sm, "secret")
ln, err := server.Listen(t.Context(), "127.0.0.1:0")
require.NoError(t, err)
done := make(chan error, 1)
go func() { done <- srv.Serve(t.Context(), ln) }()
t.Cleanup(func() {
_ = ln.Close()
<-done
})
for _, tc := range []struct {
name, flag, env string
wantError bool
}{
{name: "flag", flag: "secret"},
{name: "environment", env: "secret"},
{name: "flag overrides environment", flag: "secret", env: "wrong"},
{name: "missing token", wantError: true},
{name: "wrong token", flag: "wrong", wantError: true},
{name: "wrong flag overrides environment", flag: "wrong", env: "secret", wantError: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DOCKER_AGENT_REMOTE_AUTH_TOKEN", tc.env)
flags := &runExecFlags{remoteAddress: "http://" + ln.Addr().String(), remoteAuthToken: tc.flag}
b := &remoteBackend{flags: flags, agentFileName: "agent"}
rt, sess, cleanup, err := b.CreateSession(t.Context(), nil, runtime.CreateSessionRequest{})
if tc.wantError {
require.ErrorContains(t, err, "401")
assert.Nil(t, rt)
assert.Nil(t, sess)
assert.Nil(t, cleanup)
return
}
require.NoError(t, err)
defer cleanup()
stored, err := store.GetSession(t.Context(), sess.ID)
require.NoError(t, err)
assert.Equal(t, sess.ID, stored.ID)
// Reads use the same authenticated client as session creation.
sessions, err := rt.SessionStore().GetSessions(t.Context())
require.NoError(t, err)
assert.True(t, slices.ContainsFunc(sessions, func(s *session.Session) bool { return s.ID == sess.ID }))
})
}
}
func TestRemoteAuthTokenRequiresRemote(t *testing.T) {
t.Parallel()
cmd := newRunCmd()
cmd.SetContext(t.Context())
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--remote-auth-token", "secret"})
require.ErrorContains(t, cmd.Execute(), "--remote-auth-token requires --remote")
}
+5
View File
@@ -79,6 +79,7 @@ type runExecFlags struct {
workingDirChanged bool
attachmentPath string
remoteAddress string
remoteAuthToken string
modelOverrides []string
promptFiles []string
dryRun bool
@@ -187,6 +188,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) {
cmd.PersistentFlags().StringArrayVar(&flags.modelOverrides, "model", nil, "Override agent model: [agent=]provider/model (repeatable)")
cmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Initialize the agent without executing anything")
cmd.PersistentFlags().StringVar(&flags.remoteAddress, "remote", "", "Use remote runtime with specified address")
cmd.PersistentFlags().StringVar(&flags.remoteAuthToken, "remote-auth-token", "", "Bearer token for --remote (defaults to DOCKER_AGENT_REMOTE_AUTH_TOKEN)")
cmd.PersistentFlags().StringVarP(&flags.sessionDB, "session-db", "s", "", "Path to the session database (default: <data-dir>/session.db)")
cmd.PersistentFlags().StringVar(&flags.sessionID, "session", "", "Continue from a previous session by ID or relative offset (e.g., -1 for last session). An explicit ID that does not exist yet is created with that ID.")
cmd.PersistentFlags().StringVar(&flags.fakeResponses, "fake", "", "Replay AI responses from cassette file (for testing)")
@@ -277,6 +279,9 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command
}()
}
if f.remoteAuthToken != "" && f.remoteAddress == "" {
return errors.New("--remote-auth-token requires --remote")
}
if f.last && !f.exec {
return errors.New("--last requires --exec")
}
+5 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/docker/docker-agent/pkg/app"
"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/httpsec"
"github.com/docker/docker-agent/pkg/runregistry"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/server"
@@ -82,6 +83,10 @@ func (f *runExecFlags) startSessionCoordinator(ctx context.Context, out *cli.Pri
if err != nil {
return nil, fmt.Errorf("listen %s: %w", f.listenAddr, err)
}
if err := httpsec.ValidateListenerAuth(ln.Addr(), ""); err != nil {
_ = ln.Close()
return nil, err
}
context.AfterFunc(ctx, func() { _ = ln.Close() })
cleanup, err := runregistry.Default().Write(runregistry.Record{
@@ -98,7 +103,6 @@ func (f *runExecFlags) startSessionCoordinator(ctx context.Context, out *cli.Pri
}
out.Println("Control plane listening on", ln.Addr().String())
warnIfNotLoopback(out, ln.Addr())
srv := server.NewWithManager(sm, "")
go func() {
+13
View File
@@ -70,3 +70,16 @@ func TestStartSessionCoordinatorWithoutListenRoutesIdleRecallToApp(t *testing.T)
}
})
}
func TestStartSessionCoordinatorRejectsNonLocalListener(t *testing.T) {
t.Parallel()
store := session.NewInMemorySessionStore()
sess := session.New()
require.NoError(t, store.AddSession(t.Context(), sess))
flags := &runExecFlags{listenAddr: "0.0.0.0:0"}
opt, err := flags.startSessionCoordinator(t.Context(), nil, &recallRuntime{store: store}, sess)
require.ErrorContains(t, err, "require --auth-token")
assert.Nil(t, opt)
}
+32 -3
View File
@@ -17,7 +17,7 @@ The `docker agent serve api` command starts an HTTP server that exposes your age
$ docker agent serve api agent.yaml
# Custom listen address
$ docker agent serve api agent.yaml --listen 0.0.0.0:8080
$ docker agent serve api agent.yaml --listen 0.0.0.0:8080 --auth-token "$AGENT_TOKEN"
# With session persistence
$ docker agent serve api agent.yaml --session-db ./sessions.db
@@ -245,6 +245,35 @@ $ curl -N -X POST http://localhost:8080/api/sessions/abc-123/agent/my-agent \
-d '{"messages":[{"role":"user","content":"What files are in the current directory?"}]}'
```
## HTTP security
The REST API rejects cross-origin browser requests using Fetch Metadata and
Origin checks, including bodyless approval toggles and OAuth callback posts.
Reads are protected too: fetching an agent's tool count can start tool processes.
JSON endpoints require `Content-Type: application/json` (optional media-type
parameters are accepted); form and `text/plain` bodies are rejected with HTTP
415. Native clients without browser headers remain supported.
Unauthenticated TCP listeners accept only loopback IP addresses and `localhost`
in the Host header to prevent DNS rebinding. Unix sockets and named pipes retain
support for synthetic hostnames and rely on local access permissions. All
non-loopback TCP listeners require `--auth-token`; clients must send it as
`Authorization: Bearer <token>`. The `/health` and `/ready` probes remain exempt
from bearer authentication. Reverse proxies must preserve these protections;
forwarded headers do not authorize browser requests. For network deployments,
use TLS or a trusted TLS-terminating proxy to protect the bearer token in transit.
### Connecting with the CLI
Use `--remote-auth-token` with `run --remote`, or set
`DOCKER_AGENT_REMOTE_AUTH_TOKEN` to avoid putting the token in command-line
arguments. The flag takes precedence over the environment variable.
```bash
$ export DOCKER_AGENT_REMOTE_AUTH_TOKEN='<server-token>'
$ docker agent run --remote https://agent.example.com agent.yaml
```
## CLI Flags
```bash
@@ -254,7 +283,7 @@ docker agent serve api <agent-file>|<agents-dir> [flags]
See the [CLI reference](../cli/index.md#docker-agent-serve-api) for all flags, defaults, and shared runtime options.
> [!WARNING]
> Set `--auth-token` when listening on a network-reachable interface. For multi-user or network-exposed deployments, also set `--session-workingdir-root` to confine the host directories callers can use for sessions.
> `--auth-token` is required for non-loopback TCP listeners, including inherited TCP listeners. For multi-user or network-exposed deployments, also set `--session-workingdir-root` to confine the host directories callers can use for sessions.
> [!NOTE]
> **What `--max-request-size` does and doesn't cover**
@@ -325,7 +354,7 @@ $ curl -X POST http://127.0.0.1:8080/api/sessions/$SID/followup \
> [!WARNING]
> **This control plane has a fixed 1 MiB request-body cap and no built-in authentication**
>
> Unlike the standalone `docker agent serve api` process above, an attached run's `--listen` control plane exposes neither `--max-request-size` nor `--auth-token`: every request is capped at a fixed, non-configurable 1 MiB body (returning HTTP 413 above it — see [Troubleshooting: HTTP 413](../../guides/troubleshooting/index.md#http-413-request-body-too-large)), and there is no bearer token to require. If you need a configurable cap, built-in bearer auth, or a network-reachable listener, run a standalone `docker agent serve api` deployment instead. Otherwise, keep `--listen` on loopback, a unix socket, or behind an authenticating reverse proxy.
> Unlike the standalone `docker agent serve api` process above, an attached run's `--listen` control plane exposes neither `--max-request-size` nor `--auth-token`: every request is capped at a fixed, non-configurable 1 MiB body (returning HTTP 413 above it — see [Troubleshooting: HTTP 413](../../guides/troubleshooting/index.md#http-413-request-body-too-large)), and there is no bearer token to require. If you need a configurable cap, built-in bearer auth, or a network-reachable listener, run a standalone `docker agent serve api` deployment instead. `--listen` only supports loopback TCP addresses or local sockets; non-loopback TCP listeners are rejected. An authenticating proxy can connect to a local listener.
## Session event stream and reconnection
+3 -2
View File
@@ -38,6 +38,7 @@ $ docker agent run [config] [message...] [flags]
| `--attach <path>` | Attach an image file to the initial message |
| `--dry-run` | Initialize the agent without executing anything (useful for validating a config) |
| `--remote <addr>` | Use a remote runtime at the given address instead of running the agent locally. Mutually exclusive with `--sandbox`, `--worktree`, `--worktree-pr`, `--worktree-base`, `--session`, `--session-db`, `--record`, and `--fake` — a remote runtime owns its own session storage and execution environment, so these local-only concerns don't apply. |
| `--remote-auth-token <token>` | Bearer token for `--remote`. Defaults to `DOCKER_AGENT_REMOTE_AUTH_TOKEN`; the flag takes precedence. Prefer the environment variable to keep the token out of command-line arguments. |
| `--listen <addr>` | Expose this run's control plane over HTTP so an external process can drive the running TUI (send follow-ups, stream events, read the title). Accepts `host:port` or `unix://`, `npipe://`, `fd://`. Hidden from `docker agent run --help` — it's a stable but advanced/automation-oriented flag rather than a day-to-day one. See the [API Server](../api-server/index.md#listen) guide for the full walkthrough. |
| `--session-workingdir-root <path>` | Confine the `working_dir` of sessions created through the `--listen` control plane to this directory and its descendants (default: no restriction — any clean host directory is accepted, though raw values containing `..` are rejected). Recommended when the control plane is reachable by other users. Hidden from `--help`, like `--listen`. |
| `--lean` | Use a simplified, non-alternate-screen TUI. Unlike the default full-screen TUI, this renders inline in the normal terminal buffer — useful in environments where an alternate screen is unwanted (e.g. inside tmux panes, CI with a tty, or log-friendly pipelines). Like the full TUI, displays an ASCII art banner on startup when the chat is empty (configurable via `show_banner` in [user settings](../../configuration/user-settings/index.md)). |
@@ -303,7 +304,7 @@ $ docker agent serve api <agent-file>|<agents-dir>|<registry-ref> [flags]
| Flag | Default | Description |
| -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------- |
| `-l, --listen <addr>` | `127.0.0.1:8080` | Address to listen on. |
| `--auth-token <token>` | (none) | Bearer token required for all API requests. When set, every request must include `Authorization: Bearer <token>`. Leave empty to disable authentication (safe when listening on loopback interfaces only). |
| `--auth-token <token>` | (none) | Require `Authorization: Bearer <token>` for API requests. Mandatory for non-loopback TCP listeners; optional for loopback and local sockets. The `/health` and `/ready` probes are exempt. |
| `--max-request-size <bytes>` | `1048576` (1 MiB) | Maximum request body size. Requests exceeding this limit are rejected with HTTP 413 — see [Troubleshooting: HTTP 413](../../guides/troubleshooting/index.md#http-413-request-body-too-large). |
| `--session-workingdir-root <path>` | (none) | Confine the `working_dir` of sessions created via `POST /api/sessions` to this directory and its descendants (symlinks are resolved before the check). Unrestricted by default — any clean host directory is accepted (raw values containing `..` are rejected), as local single-user daemons rely on. Recommended for multi-user or network-exposed deployments. |
| `-s, --session-db <path>` | `session.db` | Path to the SQLite session database (relative paths resolve against the working directory). |
@@ -319,7 +320,7 @@ All [runtime configuration flags](#runtime-configuration-flags) (`--working-dir`
```bash
# Examples
$ docker agent serve api agent.yaml
$ docker agent serve api agent.yaml --listen :8080
$ docker agent serve api agent.yaml --listen :8080 --auth-token "$AGENT_TOKEN"
$ docker agent serve api ./agents/ # directory of agent YAML/HCL configs
$ docker agent serve api ociReference --pull-interval 10 # auto-refresh
```
+1 -1
View File
@@ -194,7 +194,7 @@ A per-toolset `callbackRedirectURL` (in the YAML) overrides the runtime-wide `--
> [!WARNING]
> **Security note**
>
> The `POST /api/mcp-oauth/callback` route is open by default (no auth required) when `--auth-token` is unset. State values are 128-bit opaque tokens, so brute-force is infeasible, but a state value that leaks (e.g. via debug logs or a compromised host) could be exploited by an attacker to inject a code. Set `--auth-token` when `docker agent serve api` listens on a network-reachable interface. When set, `--auth-token` enforces Bearer-token authentication on all API routes including this callback endpoint.
> The `POST /api/mcp-oauth/callback` route is open by default (no auth required) when `--auth-token` is unset. State values are 128-bit opaque tokens, so brute-force is infeasible, but a state value that leaks (e.g. via debug logs or a compromised host) could be exploited by an attacker to inject a code. `--auth-token` is required for non-loopback TCP listeners. Browser cross-origin posts are rejected even when bearer authentication is unset. When set, `--auth-token` enforces Bearer-token authentication on all API routes including this callback endpoint.
## Project Management &amp; Collaboration
+1 -1
View File
@@ -38,7 +38,7 @@ Docker Agent has several unrelated mechanisms for getting a large document, file
| A document collection you'll query repeatedly, or one too large to inline at all | The [`rag` toolset](../../tools/rag/index.md) | Indexed once in the background; each query retrieves only the relevant chunks into context, instead of the whole collection. |
| An OpenAI-compatible client driving Docker Agent over HTTP | [Chat Server](../../features/chat-server/index.md) | Accepts OpenAI-style `text` and [`image_url`](../../features/chat-server/index.md#image-inputs) content parts. The whole request travels as a single HTTP body capped by that server's `--max-request-size`; a data URL's bytes count toward it, while a remote `http(s)://` image URL is passed to the provider rather than fetched by the chat server, and works only if that provider/model supports it. |
| A native integration driving Docker Agent's own session/control protocol | [API Server](../../features/api-server/index.md) | Supports the documented session, run, and event-streaming flows over a single capped HTTP body; see the note below on the upload contract this does *not* provide. |
| A supervisor process driving an already-running interactive session | An attached run's [`--listen`](../../features/api-server/index.md#listen) control plane | Exposes the same session/follow-up/event-streaming API as the API server, but with a fixed, non-configurable 1 MiB request-body cap and no `--auth-token` — there's no `--max-request-size` or `--auth-token` flag for this surface. Keep it on loopback, a unix socket, or behind an authenticating reverse proxy if it must be reachable from elsewhere. |
| A supervisor process driving an already-running interactive session | An attached run's [`--listen`](../../features/api-server/index.md#listen) control plane | Exposes the same session/follow-up/event-streaming API as the API server, but with a fixed, non-configurable 1 MiB request-body cap and no `--auth-token` — there's no `--max-request-size` or `--auth-token` flag for this surface. Only loopback TCP and local sockets are accepted. An authenticating reverse proxy can connect to that local listener if remote access is needed. |
Three independent ceilings apply, and hitting one says nothing about the others:
+2 -2
View File
@@ -233,7 +233,7 @@ When running Docker Agent as an API server or MCP server, ensure the port is not
$ lsof -i :8080
# Use a different port
$ docker agent serve api config.yaml --listen :9090
$ docker agent serve api config.yaml --listen :9090 --auth-token "$AGENT_TOKEN"
```
### MCP endpoint accessibility
@@ -272,7 +272,7 @@ A few things that catch people out:
- Piping stdin into a **local** run (`docker agent run agent.yaml -`) never crosses Docker Agent's own inbound HTTP boundary — Docker Agent may still send that content onward to a model/provider over HTTP, but no request reaches Docker Agent's own server to be measured against a `--max-request-size` cap. Piping stdin into `docker agent run --remote ... -`, however, does cross that boundary: the CLI serializes that stdin text into a native API run request and sends it to whichever Docker Agent server the `--remote` address points at — a `serve api` process or another run's `--listen` control plane, never `serve chat`, which speaks a different protocol — so it's measured against that server's own limit like any other request (a configurable `--max-request-size` for `serve api`, or the fixed 1 MiB cap for a `--listen` control plane). That initial request carries only message text — conversion currently drops any attachment resolved locally (`@path`, `/attach`, `--attach`) for the first message, so it alone can't be the cause of a 413. But a `--remote` run doesn't stay text-only for its whole lifetime: a locally resolved attachment added to a *later* message while the agent is still busy — via the default steer behavior or an explicit follow-up (Alt+Enter) — is forwarded as part of that native API request, counts toward the same limit, and can trigger 413 just like any other oversized request.
> [!WARNING]
> Raising `--max-request-size` increases how much memory an unauthenticated or malicious client can force the server to buffer per request. Pick a value with your deployment's exposure in mind, and pair any non-loopback listener with `--auth-token` (API server) or `--api-key`/`--api-key-env` (chat server). A `--listen` control plane has neither flag — keep it on loopback, a unix socket, or behind an authenticating reverse proxy if it must be reachable from elsewhere.
> Raising `--max-request-size` increases how much memory an unauthenticated or malicious client can force the server to buffer per request. Pick a value with your deployment's exposure in mind, and pair any non-loopback listener with `--auth-token` (API server) or `--api-key`/`--api-key-env` (chat server). A `--listen` control plane has neither flag — keep its listener on loopback or a local socket (non-loopback TCP is rejected); an authenticating reverse proxy can connect to that local listener.
### Delegated task appears stalled before its first response
+1 -1
View File
@@ -329,7 +329,7 @@ $ docker agent run --exec agent.yaml "Create a Dockerfile for a Node.js app"
$ cat error.log | docker agent run --exec agent.yaml "What's wrong in this log?"
# Serve as an API
$ docker agent serve api agent.yaml --listen :8080
$ docker agent serve api agent.yaml --listen 127.0.0.1:8080
```
<div class="callout callout-tip">
+36
View File
@@ -0,0 +1,36 @@
package httpsec
import (
"errors"
"net"
"net/http"
"net/url"
"strings"
)
// LoopbackHost rejects DNS-rebinding hosts on unauthenticated TCP listeners.
// Local sockets should not use it: their clients may use synthetic hostnames.
func LoopbackHost(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, err := url.Parse("//" + r.Host)
if err != nil || u.Host != r.Host || u.User != nil || !isLoopbackHost(u.Hostname()) {
http.Error(w, "invalid Host header", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func isLoopbackHost(host string) bool {
ip := net.ParseIP(host)
return strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback())
}
// ValidateListenerAuth requires authentication when a TCP listener is not local.
// Checking the bound address also covers inherited file-descriptor listeners.
func ValidateListenerAuth(addr net.Addr, token string) error {
if tcpAddr, ok := addr.(*net.TCPAddr); ok && !tcpAddr.IP.IsLoopback() && token == "" {
return errors.New("non-loopback TCP listeners require --auth-token; use a loopback address or local socket for unauthenticated access")
}
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package httpsec
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoopbackHost(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
host string
want int
}{
{"localhost", http.StatusNoContent},
{"LOCALHOST:8080", http.StatusNoContent},
{"127.0.0.1:8080", http.StatusNoContent},
{"127.0.0.2", http.StatusNoContent},
{"[::1]:8080", http.StatusNoContent},
{"[::1]", http.StatusNoContent},
{"[::ffff:127.0.0.1]:8080", http.StatusNoContent},
{"attacker.example:8080", http.StatusForbidden},
{"localhost.attacker.example", http.StatusForbidden},
{"192.168.1.1:8080", http.StatusForbidden},
{"0.0.0.0:8080", http.StatusForbidden},
{"[::]:8080", http.StatusForbidden},
{"_", http.StatusForbidden},
{"agent", http.StatusForbidden},
{"", http.StatusForbidden},
{"localhost@attacker.example", http.StatusForbidden},
{"attacker.example@localhost", http.StatusForbidden},
{"localhost/path", http.StatusForbidden},
{"localhost?query", http.StatusForbidden},
{"localhost#fragment", http.StatusForbidden},
{"localhost:bad-port", http.StatusForbidden},
} {
t.Run(tc.host, func(t *testing.T) {
t.Parallel()
called := false
handler := LoopbackHost(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.Host = tc.host
req.Header.Set("Origin", "http://"+tc.host)
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("X-Forwarded-Host", "localhost")
req.Header.Set("Forwarded", "host=localhost")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, tc.want, rec.Code)
assert.Equal(t, tc.want == http.StatusNoContent, called)
})
}
}
func TestValidateListenerAuth(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
addr net.Addr
}{
{"IPv4 loopback", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")}},
{"IPv6 loopback", &net.TCPAddr{IP: net.IPv6loopback}},
{"IPv4 wildcard", &net.TCPAddr{IP: net.IPv4zero}},
{"IPv6 wildcard", &net.TCPAddr{IP: net.IPv6zero}},
{"unspecified", &net.TCPAddr{}},
{"private", &net.TCPAddr{IP: net.ParseIP("192.168.1.1")}},
{"public", &net.TCPAddr{IP: net.ParseIP("203.0.113.1")}},
{"unix", &net.UnixAddr{Name: "/tmp/agent.sock", Net: "unix"}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := ValidateListenerAuth(tc.addr, "")
if addr, tcp := tc.addr.(*net.TCPAddr); tcp && !addr.IP.IsLoopback() {
require.ErrorContains(t, err, "require --auth-token")
} else {
require.NoError(t, err)
}
require.NoError(t, ValidateListenerAuth(tc.addr, "secret"))
})
}
}
+1
View File
@@ -69,6 +69,7 @@ func TestAgentSourceHTTPStatus(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), tc.method, tc.path, bytes.NewBufferString(`{"messages":[]}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.e.ServeHTTP(rec, req)
assert.Equal(t, tc.want, rec.Code)
+35
View File
@@ -0,0 +1,35 @@
package server
import (
"mime"
"net/http"
"github.com/labstack/echo/v4"
)
func sameOrigin() echo.MiddlewareFunc {
var protection http.CrossOriginProtection
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// GET can initialize tool processes, so even safe methods need checking.
req := *c.Request()
req.Method = http.MethodPost
if err := protection.Check(&req); err != nil {
return echo.NewHTTPError(http.StatusForbidden, err.Error())
}
return next(c)
}
}
}
func requireJSON(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
mediaType, params, err := mime.ParseMediaType(c.Request().Header.Get(echo.HeaderContentType))
if err != nil || mediaType != echo.MIMEApplicationJSON {
return echo.NewHTTPError(http.StatusUnsupportedMediaType, "Content-Type must be application/json")
}
// Echo's binder matches media types case-sensitively.
c.Request().Header.Set(echo.HeaderContentType, mime.FormatMediaType(mediaType, params))
return next(c)
}
}
+316
View File
@@ -0,0 +1,316 @@
package server
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/session"
)
func TestServerBrowserMutations(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, origin, fetchSite string
want int
}{
{name: "native client", want: http.StatusOK},
{name: "same origin", origin: "http://localhost:8080", fetchSite: "same-origin", want: http.StatusOK},
{name: "origin fallback", origin: "http://localhost:8080", want: http.StatusOK},
{name: "cross site form", origin: "http://attacker.example", fetchSite: "cross-site", want: http.StatusForbidden},
{name: "same site is not same origin", fetchSite: "same-site", want: http.StatusForbidden},
{name: "fetch metadata only", fetchSite: "cross-site", want: http.StatusForbidden},
{name: "legacy browser", origin: "http://attacker.example", want: http.StatusForbidden},
{name: "different port", origin: "http://localhost:3000", want: http.StatusForbidden},
{name: "opaque origin", origin: "null", want: http.StatusForbidden},
{name: "malformed origin", origin: "://invalid", want: http.StatusForbidden},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sess := session.New(session.WithID("known-session"), session.WithSafetyPolicy(session.SafetyPolicyStrict))
sm := newTestSessionManager(t, sess, &resumeRecordingRuntime{})
srv := NewWithManager(sm, "")
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost:8080/api/sessions/known-session/tools/toggle", http.NoBody)
req.Header.Set("Origin", tc.origin)
req.Header.Set("Sec-Fetch-Site", tc.fetchSite)
req.Header.Set("Sec-Fetch-Mode", "navigate")
rec := httptest.NewRecorder()
srv.e.ServeHTTP(rec, req)
assert.Equal(t, tc.want, rec.Code)
if tc.want == http.StatusForbidden {
assert.Equal(t, session.SafetyPolicyStrict, sess.GetSafetyPolicy())
assert.False(t, sess.IsToolsApproved())
} else {
assert.Equal(t, session.SafetyPolicyAutonomous, sess.GetSafetyPolicy())
}
})
}
}
func TestServerCrossOriginRoutes(t *testing.T) {
t.Parallel()
srv := NewWithManager(nil, "secret")
for _, tc := range []struct{ method, path string }{
{http.MethodPost, "/api/sessions/known/tools/toggle"},
{http.MethodPost, "/api/sessions/known/agent/known-agent"},
{http.MethodPost, "/api/sessions/known/agent/known-agent/root"},
{http.MethodPatch, "/api/sessions/known/safety-policy"},
{http.MethodDelete, "/api/sessions/known"},
{http.MethodPost, "/api/mcp-oauth/callback?state=known&code=attacker"},
{http.MethodPost, "/unknown-route"},
{http.MethodGet, "/api/agents/known-agent/root/tools/count"},
{http.MethodHead, "/api/agents/known-agent/root/tools/count"},
{http.MethodOptions, "/api/agents/known-agent/root/tools/count"},
{http.MethodGet, "/api/sessions"},
} {
t.Run(tc.method+tc.path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(t.Context(), tc.method, "http://localhost:8080"+tc.path, strings.NewReader(`{"messages":[]}`))
req.Header.Set("Origin", "http://attacker.example")
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
}
}
func TestServerJSONContentType(t *testing.T) {
t.Parallel()
srv := NewWithManager(nil, "")
for _, route := range srv.e.Routes() {
if route.Method != http.MethodPost && route.Method != http.MethodPatch {
continue
}
if strings.HasSuffix(route.Path, "/tools/toggle") || route.Path == "/api/mcp-oauth/callback" {
continue
}
for _, contentType := range []string{"", "text/plain", "text/plain;charset=UTF-8", "application/x-www-form-urlencoded", "multipart/form-data; boundary=abc", "application/jsonp", "application/json; invalid"} {
t.Run(route.Method+route.Path+"/"+contentType, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(t.Context(), route.Method, route.Path, strings.NewReader(`{"messages":[]}`))
req.Header.Set("Content-Type", contentType)
// Unknown lengths must not bypass the media-type check.
req.ContentLength = -1
rec := httptest.NewRecorder()
srv.e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code)
})
}
}
for _, contentType := range []string{"application/json", "application/json; charset=utf-8", "Application/JSON"} {
t.Run(contentType, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/sessions/known/messages", strings.NewReader(`{}`))
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
srv.e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "message is required")
})
}
}
func TestServerBearerAuthentication(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
authorization string
want int
}{
{"", http.StatusUnauthorized},
{"Bearer wrong", http.StatusUnauthorized},
{"Basic secret", http.StatusUnauthorized},
{"Bearer secret", http.StatusOK},
} {
t.Run(tc.authorization, func(t *testing.T) {
t.Parallel()
sess := session.New(session.WithID("known"), session.WithSafetyPolicy(session.SafetyPolicyStrict))
srv := NewWithManager(newTestSessionManager(t, sess, &resumeRecordingRuntime{}), "secret")
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/sessions/known/tools/toggle", http.NoBody)
req.Header.Set("Authorization", tc.authorization)
rec := httptest.NewRecorder()
srv.e.ServeHTTP(rec, req)
assert.Equal(t, tc.want, rec.Code)
assert.Equal(t, tc.want == http.StatusOK, sess.IsToolsApproved())
})
}
}
func TestServerTCPHost(t *testing.T) {
t.Parallel()
for _, token := range []string{"", "secret"} {
t.Run("token="+token, func(t *testing.T) {
t.Parallel()
ln, err := Listen(t.Context(), "127.0.0.1:0")
require.NoError(t, err)
srv := NewWithManager(nil, token)
done := make(chan error, 1)
go func() { done <- srv.Serve(t.Context(), ln) }()
t.Cleanup(func() {
_ = ln.Close()
<-done
})
client := newTestHTTPClient(t)
for _, host := range []string{"127.0.0.1:8080", "localhost:8080", "[::1]:8080", "rebind.attacker.example:8080", "_", "agent"} {
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+ln.Addr().String()+"/api/ping", http.NoBody)
require.NoError(t, err)
req.Host = host
req.Header.Set("Origin", "http://"+host)
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
_, err = io.Copy(io.Discard, resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
want := http.StatusOK
if token == "" && (host == "rebind.attacker.example:8080" || host == "_" || host == "agent") {
want = http.StatusForbidden
}
assert.Equal(t, want, resp.StatusCode, host)
}
})
}
}
func TestServerRefusesUnauthenticatedNonLocalListener(t *testing.T) {
t.Parallel()
ln, err := Listen(t.Context(), "0.0.0.0:0")
require.NoError(t, err)
defer ln.Close()
require.ErrorContains(t, NewWithManager(nil, "").Serve(t.Context(), ln), "require --auth-token")
}
func TestServerKnownSessionFormSequence(t *testing.T) {
t.Parallel()
sess := session.New(session.WithID("known"), session.WithSafetyPolicy(session.SafetyPolicyStrict))
srv := NewWithManager(newTestSessionManager(t, sess, &resumeRecordingRuntime{}), "")
for _, path := range []string{"/api/sessions/known/tools/toggle", "/api/sessions/known/agent/known-agent"} {
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost:8080"+path, bytes.NewBufferString(`{"messages":[{"role":"user","content":"untrusted prompt"}]}`))
req.Header.Set("Origin", "http://attacker.example")
req.Header.Set("Sec-Fetch-Site", "cross-site")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Content-Type", "text/plain")
rec := httptest.NewRecorder()
srv.e.ServeHTTP(rec, req)
require.Equal(t, http.StatusForbidden, rec.Code)
}
assert.Equal(t, session.SafetyPolicyStrict, sess.GetSafetyPolicy())
assert.Empty(t, sess.GetAllMessages())
}
// Preserve the synthetic Host used by native local-socket clients.
func TestServerLocalSocketHost(t *testing.T) {
t.Parallel()
ln, err := Listen(t.Context(), "unix://"+shortTempDir(t)+"/api.sock")
require.NoError(t, err)
done := make(chan error, 1)
go func() { done <- NewWithManager(nil, "").Serve(t.Context(), ln) }()
t.Cleanup(func() {
_ = ln.Close()
<-done
})
transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", ln.Addr().String())
}}
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{Transport: transport}
for _, host := range []string{"_", "agent"} {
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+host+"/api/ping", http.NoBody)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
}
func TestServerInheritedListenerAuth(t *testing.T) {
t.Parallel()
for _, addr := range []string{"127.0.0.1:0", "0.0.0.0:0"} {
t.Run(addr, func(t *testing.T) {
t.Parallel()
original, err := Listen(t.Context(), addr)
require.NoError(t, err)
defer original.Close()
file, err := original.(*net.TCPListener).File()
require.NoError(t, err)
pinFDOwnership(file)
ln, err := Listen(t.Context(), fmt.Sprintf("fd://%d", file.Fd()))
require.NoError(t, err)
defer ln.Close()
srv := NewWithManager(nil, "")
if addr == "0.0.0.0:0" {
require.ErrorContains(t, srv.Serve(t.Context(), ln), "require --auth-token")
return
}
done := make(chan error, 1)
go func() { done <- srv.Serve(t.Context(), ln) }()
t.Cleanup(func() {
_ = ln.Close()
<-done
})
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+ln.Addr().String()+"/api/ping", http.NoBody)
require.NoError(t, err)
req.Host = "rebind.attacker.example"
resp, err := newTestHTTPClient(t).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
}
}
func TestServerSameOriginToolCount(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, origin, fetchSite string
want int
}{
{name: "native client", want: http.StatusNotFound},
{name: "same origin", origin: "http://localhost:8080", fetchSite: "same-origin", want: http.StatusNotFound},
{name: "cross site navigation", fetchSite: "cross-site", want: http.StatusForbidden},
{name: "same site", fetchSite: "same-site", want: http.StatusForbidden},
{name: "legacy browser", origin: "http://attacker.example", want: http.StatusForbidden},
{name: "opaque origin", origin: "null", want: http.StatusForbidden},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// A nil manager makes any attempt to load the team fail the test.
var sm *SessionManager
if tc.want == http.StatusNotFound {
sm = NewSessionManager(t.Context(), nil, session.NewInMemorySessionStore(), 0, nil)
}
srv := NewWithManager(sm, "")
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:8080/api/agents/known-agent/root/tools/count", http.NoBody)
req.Header.Set("Origin", tc.origin)
req.Header.Set("Sec-Fetch-Site", tc.fetchSite)
req.Header.Set("Sec-Fetch-Mode", "navigate")
rec := httptest.NewRecorder()
srv.e.ServeHTTP(rec, req)
assert.Equal(t, tc.want, rec.Code)
})
}
}
+31 -20
View File
@@ -25,6 +25,7 @@ import (
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/echolog"
"github.com/docker/docker-agent/pkg/httpsec"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/tools/mcp"
@@ -76,6 +77,7 @@ func NewWithManager(sm *SessionManager, authToken string, opts ...Option) *Serve
}
e := echo.New()
e.Pre(sameOrigin())
e.Use(echolog.RedactedRequestLogger())
e.Use(middleware.BodyLimit(strconv.FormatInt(maxBytes, 10)))
e.Use(echo.WrapMiddleware(upstream.Handler))
@@ -104,30 +106,30 @@ func (s *Server) registerRoutes() {
group.GET("/sessions/:id", s.getSession)
group.GET("/sessions/:id/status", s.getSessionStatus)
group.GET("/sessions/:id/snapshot", s.getSessionSnapshot)
group.POST("/sessions/:id/resume", s.resumeSession)
group.POST("/sessions/:id/resume", s.resumeSession, requireJSON)
group.POST("/sessions/:id/tools/toggle", s.toggleSessionYolo)
group.PATCH("/sessions/:id/safety-policy", s.updateSessionSafetyPolicy)
group.PATCH("/sessions/:id/permissions", s.updateSessionPermissions)
group.PATCH("/sessions/:id/title", s.updateSessionTitle)
group.PATCH("/sessions/:id/tokens", s.updateSessionTokens)
group.PATCH("/sessions/:id/starred", s.setSessionStarred)
group.PATCH("/sessions/:id/safety-policy", s.updateSessionSafetyPolicy, requireJSON)
group.PATCH("/sessions/:id/permissions", s.updateSessionPermissions, requireJSON)
group.PATCH("/sessions/:id/title", s.updateSessionTitle, requireJSON)
group.PATCH("/sessions/:id/tokens", s.updateSessionTokens, requireJSON)
group.PATCH("/sessions/:id/starred", s.setSessionStarred, requireJSON)
group.GET("/sessions/:id/models", s.getSessionModels)
group.POST("/sessions", s.createSession)
group.POST("/sessions/:id/fork", s.forkSession)
group.POST("/sessions", s.createSession, requireJSON)
group.POST("/sessions/:id/fork", s.forkSession, requireJSON)
group.DELETE("/sessions/:id", s.deleteSession)
group.POST("/sessions/:id/agent/:agent", s.runAgent)
group.POST("/sessions/:id/agent/:agent/:agent_name", s.runAgent)
group.POST("/sessions/:id/elicitation", s.elicitation)
group.POST("/sessions/:id/steer", s.steerSession)
group.POST("/sessions/:id/followup", s.followUpSession)
group.POST("/sessions/:id/agent/:agent", s.runAgent, requireJSON)
group.POST("/sessions/:id/agent/:agent/:agent_name", s.runAgent, requireJSON)
group.POST("/sessions/:id/elicitation", s.elicitation, requireJSON)
group.POST("/sessions/:id/steer", s.steerSession, requireJSON)
group.POST("/sessions/:id/followup", s.followUpSession, requireJSON)
group.GET("/sessions/:id/events", s.sessionEvents)
group.POST("/sessions/:id/messages", s.addMessage)
group.PATCH("/sessions/:id/messages/:msg_id", s.updateMessage)
group.POST("/sessions/:id/summaries", s.addSummary)
group.POST("/sessions/:id/messages", s.addMessage, requireJSON)
group.PATCH("/sessions/:id/messages/:msg_id", s.updateMessage, requireJSON)
group.POST("/sessions/:id/summaries", s.addSummary, requireJSON)
group.GET("/sessions/:id/queue", s.getSessionQueueStatus)
group.GET("/sessions/:id/recovery", s.getSessionRecoveryData)
group.POST("/sessions/batch/delete", s.batchDeleteSessions)
group.POST("/sessions/batch/export", s.batchExportSessions)
group.POST("/sessions/batch/delete", s.batchDeleteSessions, requireJSON)
group.POST("/sessions/batch/export", s.batchExportSessions, requireJSON)
group.GET("/agents/:id/:agent_name/tools/count", s.getAgentToolCount)
@@ -140,6 +142,15 @@ func (s *Server) registerRoutes() {
}
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
if err := httpsec.ValidateListenerAuth(ln.Addr(), s.authToken); err != nil {
return err
}
var handler http.Handler = s.e
if _, tcp := ln.Addr().(*net.TCPAddr); tcp && s.authToken == "" {
handler = httpsec.LoopbackHost(handler)
}
// Wrap the Echo handler with otelhttp so the configured W3C
// propagator extracts `traceparent` / `tracestate` / `baggage`
// from incoming API requests. Without this the API server's
@@ -148,7 +159,7 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
// chaining onto the calling client's trace.
srv := http.Server{
BaseContext: func(net.Listener) context.Context { return ctx },
Handler: otelhttp.NewHandler(s.e, "agent-api"),
Handler: otelhttp.NewHandler(handler, "agent-api"),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -593,7 +604,7 @@ func (s *Server) elicitation(c echo.Context) error {
// blocks this regardless of state leakage, because the route then
// also requires bearer auth.
// - Operators running docker-agent on a network-reachable interface
// SHOULD configure --auth-token. Defaults to localhost-only via the
// must configure --auth-token. Defaults to localhost-only via the
// existing socket binding when not overridden.
//
// The handler never blocks: it hands the callback to the buffered