mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
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:
18 files changed
+656
-54
No files matched your search
+5
-22
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 & Collaboration
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user