diff --git a/AGENTS.md b/AGENTS.md index c6e0fad..4653cb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ This document is a comprehensive guide for an AI agent tasked with developing an | Topic | Details | | --- | --- | | **Purpose** | Expose vetted Sysdig Monitor workflows to LLMs through MCP tools. | -| **Tech Stack** | Go 1.26+, `mcp-go`, Cobra CLI, Ginkgo/Gomega, `golangci-lint`, Nix. | +| **Tech Stack** | Go 1.27+, `mcp-go`, `go-oidc`, Cobra CLI, Ginkgo/Gomega, `golangci-lint`, Nix. | | **Entry Point** | `cmd/server/main.go` (Cobra CLI that wires config, Sysdig client, etc.). | | **Dev Shell** | `nix develop` provides a consistent development environment. | | **Key Commands** | `just fmt`, `just lint`, `just test`, `just check`, `just update`. | @@ -50,6 +50,7 @@ internal/ config/ - Environment variable loading and validation infra/ clock/ - System clock abstraction (for testing) + auth/ - Remote JWT access-token verification mcp/ - MCP server handler, transport setup, middleware tools/ - Individual MCP tool implementations sysdig/ - Sysdig API client (generated + extensions) @@ -68,19 +69,18 @@ package.nix - Defines how the package is going to be built with Nix 2. **Configuration (`internal/config/config.go`):** - Loads environment variables with `SYSDIG_MCP_*` prefix - - Validates required fields for stdio transport (API host and token mandatory) - - Supports remote transports where auth can come via HTTP headers + - Requires fixed Sysdig API credentials for every transport + - Validates the OAuth issuer, JWT/JWKS policy, resource audience, and exact browser origins for remote transports 3. **MCP Handler (`internal/infra/mcp/mcp_handler.go`):** - Wraps mcp-go server with permission filtering (`toolPermissionFiltering`, line 26-64) - Dynamically filters tools based on Sysdig API token permissions - - HTTP middleware extracts `Authorization` and `X-Sysdig-Host` headers for remote transports (line 108-138) + - Remote security validates OAuth access tokens and browser origins, then publishes RFC 9728 protected-resource metadata 4. **Sysdig Client (`internal/infra/sysdig/`):** - `client.gen.go`: Generated OpenAPI client (**DO NOT EDIT**, manually regenerated via oapi-codegen, not with `go generate`) - - `client.go`: Authentication strategies with fallback support - - Context-based auth: `WrapContextWithToken()` and `WrapContextWithHost()` for remote transports - - Fixed auth: `WithFixedHostAndToken()` for stdio mode and remote transports + - `client.go`: Fixed server-side authentication and version request editors + - `WithFixedHostAndToken()` is the only Sysdig authentication path; inbound MCP credentials must never be forwarded upstream - Custom extensions in `client_extension.go` and `client_*.go` files 5. **Tools (`internal/infra/mcp/tools/`):** diff --git a/README.md b/README.md index c090ca7..f7b93e4 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Get up and running with the Sysdig MCP Server quickly using our pre-built Docker Substitute the following placeholders with your actual values: - ``: The hostname of your Sysdig instance (e.g., `https://us2.app.sysdig.com` or `https://eu1.app.sysdig.com`) - - ``: Your Sysdig API token (Secure or Monitor) + - ``: Your least-privilege Sysdig Monitor API token ## Available Tools @@ -141,14 +141,20 @@ The server dynamically filters the available tools based on the permissions asso > **Note:** When a time window is provided, the underlying PromQL is wrapped in the aggregation appropriate for each tool (`avg_over_time`, `max_over_time`, `min_over_time`, `increase`, etc.) and evaluated at `end`. See [`internal/infra/mcp/tools/README.md`](./internal/infra/mcp/tools/README.md) for the per-tool aggregation table. ## Requirements -- [Go](https://go.dev/doc/install) 1.26 or higher (if running without Docker). +- [Go](https://go.dev/doc/install) 1.27 or higher (if running without Docker). ## Configuration -The following environment variables are **required** for configuring the Sysdig SDK: +The following environment variables are **required for every transport**. They are deployment secrets and are never accepted from an MCP request: -- `SYSDIG_MCP_API_HOST`: The URL of your Sysdig instance (e.g., `https://us2.app.sysdig.com`). **Required when using `stdio` transport.** -- `SYSDIG_MCP_API_TOKEN`: Your Sysdig API token (Secure or Monitor). **Required only when using `stdio` transport.** +- `SYSDIG_MCP_API_HOST`: The absolute URL of your Sysdig instance (e.g., `https://us2.app.sysdig.com`). +- `SYSDIG_MCP_API_TOKEN`: A dedicated, least-privilege Sysdig Monitor API token used only for server-to-Sysdig requests. + +Remote transports (`streamable-http` and `sse`) are OAuth 2.1 protected resources and also require: + +- `SYSDIG_MCP_RESOURCE_URL`: The public MCP endpoint and expected JWT audience (for example, `https://mcp.example.com/sysdig-mcp-server`). +- `SYSDIG_MCP_AUTH_ISSUER`: The exact access-token issuer and authorization server URL. +- `SYSDIG_MCP_AUTH_JWKS_URL`: The issuer's JWKS endpoint used to verify JWT signatures. You can also set the following variables to override the default configuration: @@ -159,6 +165,11 @@ You can also set the following variables to override the default configuration: - `SYSDIG_MCP_LISTENING_PORT`: The port for the server when it is deployed using remote protocols (`streamable-http`, `sse`). Defaults to: `8080` - `SYSDIG_MCP_LISTENING_HOST`: The host for the server when it is deployed using remote protocols (`streamable-http`, `sse`). Defaults to all interfaces (`:port`). Set to `127.0.0.1` for local-only access. - `SYSDIG_MCP_STATELESS`: Enable stateless mode for `streamable-http` transport, where each request is self-contained with no session tracking (useful for AWS Bedrock AgentCore). Defaults to: `false`. +- `SYSDIG_MCP_AUTH_SCOPES`: Comma- or space-separated scopes required on remote MCP access tokens. Defaults to no required scopes. +- `SYSDIG_MCP_AUTH_SIGNING_ALGS`: Comma- or space-separated asymmetric JWT algorithms accepted from the issuer. Defaults to: `RS256`. Symmetric algorithms and `none` are rejected. +- `SYSDIG_MCP_ALLOWED_ORIGINS`: Comma- or space-separated browser origins allowed to call the remote transport, using exact `scheme://authority` values. Wildcards are rejected. If omitted, requests carrying an `Origin` header are denied; non-browser clients remain supported. + +All configured URLs must use HTTPS. Plain HTTP is accepted only for loopback development (`localhost` or a loopback IP). You can find your API token in the Sysdig UI under **Settings > Sysdig Secure API** (or **Sysdig Monitor API**). Make sure to copy the token as it will not be shown again. @@ -183,15 +194,24 @@ SYSDIG_MCP_LOGLEVEL=INFO ```bash # Required SYSDIG_MCP_TRANSPORT=streamable-http - -# Optional (Host and Token can be provided via HTTP headers) -# SYSDIG_MCP_API_HOST= -# SYSDIG_MCP_API_TOKEN=your-api-token-here +SYSDIG_MCP_API_HOST=https://us2.app.sysdig.com +SYSDIG_MCP_API_TOKEN=your-server-side-sysdig-token +SYSDIG_MCP_RESOURCE_URL=https://mcp.example.com/sysdig-mcp-server +SYSDIG_MCP_AUTH_ISSUER=https://identity.example.com +SYSDIG_MCP_AUTH_JWKS_URL=https://identity.example.com/.well-known/jwks.json + +# Optional remote policy +SYSDIG_MCP_AUTH_SCOPES=mcp:tools +SYSDIG_MCP_AUTH_SIGNING_ALGS=RS256 +SYSDIG_MCP_ALLOWED_ORIGINS=https://approved-client.example.com SYSDIG_MCP_LISTENING_PORT=8080 SYSDIG_MCP_LISTENING_HOST= SYSDIG_MCP_MOUNT_PATH=/sysdig-mcp-server ``` +> [!IMPORTANT] +> This is a breaking security boundary for remote deployments. MCP clients present an issuer-signed access token intended for `SYSDIG_MCP_RESOURCE_URL`; they never present a Sysdig API token. The server does not support `X-Sysdig-Host`, `X-Sysdig-Token`, or forwarding the request's `Authorization` value to Sysdig. + ### API Permissions To use the MCP server tools, your API token needs specific permissions on the Sysdig platform. We recommend creating a dedicated Service Account (SA) with a custom role containing only the required permissions. @@ -376,7 +396,7 @@ codex mcp add \ ### Kubernetes -Deploy the MCP server to a Kubernetes cluster as a remote service. MCP clients like Claude Desktop will connect to it via URL. +Deploy the MCP server to a Kubernetes cluster as an HTTPS remote service. MCP clients like Claude Desktop connect with an access token issued specifically for this MCP resource. **1. Create a Secret with your Sysdig credentials:** @@ -418,6 +438,16 @@ spec: env: - name: SYSDIG_MCP_TRANSPORT value: "streamable-http" + - name: SYSDIG_MCP_RESOURCE_URL + value: "https://mcp.example.com/sysdig-mcp-server" + - name: SYSDIG_MCP_AUTH_ISSUER + value: "https://identity.example.com" + - name: SYSDIG_MCP_AUTH_JWKS_URL + value: "https://identity.example.com/.well-known/jwks.json" + - name: SYSDIG_MCP_AUTH_SCOPES + value: "mcp:tools" + - name: SYSDIG_MCP_ALLOWED_ORIGINS + value: "https://approved-client.example.com" envFrom: - secretRef: name: mcp-server-secrets @@ -436,7 +466,7 @@ spec: targetPort: 8080 ``` -> **Note:** Expose the Service externally using a `NodePort`, `LoadBalancer`, or `Ingress` depending on your cluster setup. The examples in the [Client Configuration](#client-configuration) section assume the server is reachable at `http://:/sysdig-mcp-server`. +> **Note:** Terminate TLS at an Ingress or load balancer and set `SYSDIG_MCP_RESOURCE_URL` to the exact externally reachable endpoint. Keep the Kubernetes Service private; only the ingress should expose it. ## Local Development @@ -456,28 +486,34 @@ direnv allow ## Client Configuration -To use the MCP server with a client like Claude or Cursor, you need to provide the server's URL and authentication details. +To use the MCP server with a client like Claude or Cursor, provide the server URL and authentication details appropriate to the transport. ### Authentication -When using the `sse` or `streamable-http` transport, the server requires a Bearer token for authentication. The token is passed in the `X-Sysdig-Token` or default to `Authorization` header of the HTTP request (i.e `Bearer SYSDIG_MCP_API_TOKEN`). +With `stdio`, the locally launched process uses `SYSDIG_MCP_API_HOST` and `SYSDIG_MCP_API_TOKEN` directly. -Additionally, you can specify the Sysdig host by providing the `X-Sysdig-Host` header. +With `sse` or `streamable-http`, the server requires an issuer-signed JWT access token in the standard `Authorization` header. The token must: -> **Note:** When provided, the authentication headers (`Authorization`, `X-Sysdig-Token`) and host header (`X-Sysdig-Host`) take precedence over the configured environment variables. +- have an `iss` claim equal to `SYSDIG_MCP_AUTH_ISSUER`; +- include `SYSDIG_MCP_RESOURCE_URL` in `aud`; +- be unexpired and use an allowed asymmetric signing algorithm; +- contain every configured scope in `scope` or `scp`. + +The MCP access token and the server-side Sysdig token are different credentials for different audiences. The server validates the first and never forwards it; outbound Sysdig calls always use the second. Example headers: ``` -Authorization: Bearer -X-Sysdig-Host: +Authorization: Bearer ``` +The server publishes OAuth protected-resource metadata at `/.well-known/oauth-protected-resource[/]`. Missing or invalid credentials receive `401 Unauthorized` with a `WWW-Authenticate` challenge that points clients to this metadata. + ### URL -If you are running the server with the `sse` or `streamable-http` transport, the URL will be `http://:`, where `` is the value of `SYSDIG_MCP_MOUNT_PATH` (defaults to `/sysdig-mcp-server`). Do not include a trailing `/`. +If you are running the server with the `sse` or `streamable-http` transport, the production URL is `https://`, where `` is the value of `SYSDIG_MCP_MOUNT_PATH` (defaults to `/sysdig-mcp-server`). Do not include a trailing `/`. -For example, if you are running the server locally on port 8080 with the default mount path, the URL will be `http://localhost:8080/sysdig-mcp-server`. +For loopback development only, the default URL can be `http://localhost:8080/sysdig-mcp-server`. ### Claude Desktop App @@ -486,7 +522,7 @@ For the Claude Desktop app, configure the MCP server by editing the `claude_desk 1. Go to **Settings > Developer** in the Claude Desktop app. 2. Click on **Edit Config** to open the `claude_desktop_config.json` file. 3. Add the JSON configuration from the [Server Setup](#server-setup) section that matches your installation method (Go, Docker, or Binary). -4. Replace `` with your Sysdig host URL and `` with your Sysdig Secure or Monitor API token. +4. Replace `` with your Sysdig host URL and `` with your Sysdig Monitor API token. 5. Save the file and restart the Claude Desktop app. **Connecting to a Remote Server:** @@ -501,21 +537,20 @@ If the MCP server is deployed remotely (e.g., in a [Kubernetes cluster](#kuberne "args": [ "-y", "mcp-remote", - "http://:/sysdig-mcp-server", - "--allow-http" + "https://mcp.example.com/sysdig-mcp-server" ] } } } ``` -> **Note:** The `--allow-http` flag is required when connecting over plain HTTP. If your server is behind HTTPS (e.g., via an Ingress with TLS), you can omit it. No authentication headers or tokens are needed in the client configuration when the server has `SYSDIG_MCP_API_HOST` and `SYSDIG_MCP_API_TOKEN` set as environment variables. +> **Note:** Use `--allow-http` only for loopback development. In production, use HTTPS and authenticate through the authorization server advertised by the protected-resource metadata. Configuring the server-side Sysdig token does not authenticate MCP clients. ### MCP Inspector 1. Run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) locally. 2. Select the transport type and configure the connection to the Sysdig MCP server. -3. Pass the Authorization header if using `streamable-http` or the `SYSDIG_MCP_API_TOKEN` env var if using `stdio`. +3. For `streamable-http`, complete OAuth with the configured issuer or pass `Authorization: Bearer `. For `stdio`, configure `SYSDIG_MCP_API_TOKEN` in the launched process. ![mcp-inspector](./docs/assets/mcp-inspector.png) diff --git a/cmd/server/main.go b/cmd/server/main.go index b20f236..a6379ce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,9 +9,11 @@ import ( "os" "runtime/debug" "strings" + "time" "github.com/spf13/cobra" "github.com/sysdiglabs/sysdig-mcp-server/internal/config" + infraauth "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/auth" "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/clock" "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp" "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp/tools" @@ -85,21 +87,11 @@ func setupLogger(logLevel string) { func setupSysdigClient(cfg *config.Config) (sysdig.ExtendedClientWithResponsesInterface, error) { sysdigClientOptions := []sysdig.IntoClientOption{ sysdig.WithVersion(Version), - sysdig.WithFallbackAuthentication( - sysdig.WithHostAndTokenFromContext(), - sysdig.WithFixedHostAndToken(cfg.APIHost, cfg.APIToken), - ), + sysdig.WithFixedHostAndToken(cfg.APIHost, cfg.APIToken), } if cfg.SkipTLSVerification { - transport := http.DefaultTransport.(*http.Transport).Clone() - if transport.TLSClientConfig == nil { - transport.TLSClientConfig = &tls.Config{} - } - transport.TLSClientConfig.InsecureSkipVerify = true - httpClient := &http.Client{Transport: transport} - - sysdigClientOptions = append(sysdigClientOptions, sysdig.WithHTTPClient(httpClient)) + sysdigClientOptions = append(sysdigClientOptions, sysdig.WithHTTPClient(insecureHTTPClient())) } sysdigClient, err := sysdig.NewSysdigClient(sysdigClientOptions...) @@ -109,6 +101,40 @@ func setupSysdigClient(cfg *config.Config) (sysdig.ExtendedClientWithResponsesIn return sysdigClient, nil } +func setupRemoteSecurity(cfg *config.Config) mcp.RemoteSecurity { + var jwksHTTPClient *http.Client + if cfg.SkipJWKSTLSVerification { + jwksHTTPClient = insecureHTTPClient() + } + + verifier := infraauth.NewJWTVerifierWithHTTPClient( + context.Background(), + cfg.AuthIssuer, + cfg.ResourceURL, + cfg.AuthJWKSURL, + cfg.AuthSigningAlgs, + cfg.AuthScopes, + jwksHTTPClient, + ) + + return mcp.NewRemoteSecurity( + verifier, + cfg.ResourceURL, + cfg.AuthIssuer, + cfg.AuthScopes, + cfg.AllowedOrigins, + ) +} + +func insecureHTTPClient() *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } + transport.TLSClientConfig.InsecureSkipVerify = true + return &http.Client{Transport: transport} +} + func setupHandler(sysdigClient sysdig.ExtendedClientWithResponsesInterface) *mcp.Handler { systemClock := clock.NewSystemClock() handler := mcp.NewHandler(Version, sysdigClient) @@ -142,13 +168,27 @@ func startServer(cfg *config.Config, handler *mcp.Handler) error { case "streamable-http": addr := fmt.Sprintf("%s:%s", cfg.ListeningHost, cfg.ListeningPort) slog.Info("MCP Server listening", "addr", addr, "mountPath", cfg.MountPath, "stateless", cfg.Stateless) - if err := http.ListenAndServe(addr, handler.AsStreamableHTTP(cfg.MountPath, cfg.Stateless)); err != nil { + server := &http.Server{ + Addr: addr, + Handler: handler.AsStreamableHTTP(cfg.MountPath, cfg.Stateless, setupRemoteSecurity(cfg)), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, + } + if err := server.ListenAndServe(); err != nil { return fmt.Errorf("error serving streamable http: %w", err) } case "sse": addr := fmt.Sprintf("%s:%s", cfg.ListeningHost, cfg.ListeningPort) slog.Info("MCP Server listening", "addr", addr, "mountPath", cfg.MountPath) - if err := http.ListenAndServe(addr, handler.AsSSE(cfg.MountPath)); err != nil { + server := &http.Server{ + Addr: addr, + Handler: handler.AsSSE(cfg.MountPath, setupRemoteSecurity(cfg)), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, + } + if err := server.ListenAndServe(); err != nil { return fmt.Errorf("error serving sse: %w", err) } default: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 482b8fa..0af5cb1 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -3,11 +3,20 @@ **Problem**: Tool not appearing in MCP client - **Solution**: Check API token permissions match tool's `WithRequiredPermissions()`. The token must have **all** permissions listed. -**Problem**: "unable to authenticate with any method" -- **Solution**: For `stdio`, verify `SYSDIG_MCP_API_HOST` and `SYSDIG_MCP_API_TOKEN` env vars are set correctly. For remote transports, check `Authorization: Bearer ` header format. +**Problem**: Server exits with a missing configuration error +- **Solution**: All transports require absolute `SYSDIG_MCP_API_HOST` and `SYSDIG_MCP_API_TOKEN` values. Remote transports also require `SYSDIG_MCP_RESOURCE_URL`, `SYSDIG_MCP_AUTH_ISSUER`, and `SYSDIG_MCP_AUTH_JWKS_URL`. The resource URL path must exactly match `SYSDIG_MCP_MOUNT_PATH`. -**Problem**: Connection failing with "certificate signed by unknown authority" -- **Solution**: If using a self-signed certificate (e.g. on-prem), set `SYSDIG_MCP_API_SKIP_TLS_VERIFICATION=true`. +**Problem**: Remote request returns `401 Unauthorized` +- **Solution**: Use an issuer-signed MCP access token, not the Sysdig API token. Verify its `iss`, `aud`, expiry, asymmetric signing algorithm, and required scopes. Follow the `resource_metadata` URL in the `WWW-Authenticate` response header to inspect the server's OAuth metadata. + +**Problem**: Browser request returns `403 Forbidden` +- **Solution**: Add the browser's exact origin to `SYSDIG_MCP_ALLOWED_ORIGINS`. Include only `scheme://authority`; wildcard origins and origins with paths are rejected. Hostname matching is case-insensitive. + +**Problem**: Sysdig API connection fails with "certificate signed by unknown authority" +- **Solution**: If the Sysdig API uses a self-signed certificate (e.g. on-prem), set `SYSDIG_MCP_API_SKIP_TLS_VERIFICATION=true`. + +**Problem**: OAuth JWKS retrieval fails with "certificate signed by unknown authority" +- **Solution**: If the authorization server's JWKS endpoint uses a self-signed certificate, set `SYSDIG_MCP_AUTH_JWKS_SKIP_TLS_VERIFICATION=true`. This is intentionally separate from the Sysdig API TLS setting because the two endpoints are different trust domains. **Problem**: Tests failing with "command not found" - **Solution**: Enter Nix shell with `nix develop` or `direnv allow`. All dev tools are provided by the flake. diff --git a/go.mod b/go.mod index c9317d3..aa30881 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/sysdiglabs/sysdig-mcp-server go 1.27 require ( + github.com/coreos/go-oidc/v3 v3.21.0 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/mark3labs/mcp-go v0.58.0 github.com/oapi-codegen/runtime v1.7.0 github.com/onsi/ginkgo/v2 v2.32.1 @@ -28,6 +30,7 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/mod v0.40.0 // indirect golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/go.sum b/go.sum index 8c46051..19eb83f 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMz github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -18,6 +20,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -93,6 +97,8 @@ golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= diff --git a/internal/config/config.go b/internal/config/config.go index 9faed91..b05c6fe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,44 +2,152 @@ package config import ( "fmt" + "net" + "net/url" "os" + "path" + "slices" "strconv" "strings" + "unicode" + + "github.com/coreos/go-oidc/v3/oidc" ) +var supportedAuthSigningAlgorithms = []string{ + oidc.RS256, + oidc.RS384, + oidc.RS512, + oidc.PS256, + oidc.PS384, + oidc.PS512, + oidc.ES256, + oidc.ES384, + oidc.ES512, + oidc.EdDSA, +} + type Config struct { - APIHost string - APIToken string - SkipTLSVerification bool - Transport string - ListeningHost string - ListeningPort string - MountPath string - LogLevel string - Stateless bool + APIHost string + APIToken string + SkipTLSVerification bool + SkipJWKSTLSVerification bool + Transport string + ListeningHost string + ListeningPort string + MountPath string + LogLevel string + Stateless bool + ResourceURL string + AuthIssuer string + AuthJWKSURL string + AuthScopes []string + AuthSigningAlgs []string + AllowedOrigins []string } func (c *Config) Validate() error { - if c.Transport == "stdio" && c.APIHost == "" { + if !slices.Contains([]string{"stdio", "streamable-http", "sse"}, c.Transport) { + return fmt.Errorf("unsupported SYSDIG_MCP_TRANSPORT %q", c.Transport) + } + if c.APIHost == "" { return fmt.Errorf("required configuration missing: SYSDIG_MCP_API_HOST") } - if c.Transport == "stdio" && c.APIToken == "" { + if c.APIToken == "" { return fmt.Errorf("required configuration missing: SYSDIG_MCP_API_TOKEN") } + apiHost, err := parseAbsoluteURL("SYSDIG_MCP_API_HOST", c.APIHost) + if err != nil { + return err + } + if apiHost.RawQuery != "" { + return fmt.Errorf("SYSDIG_MCP_API_HOST must not contain a query string") + } + if c.Transport == "stdio" { + return nil + } + + if err := validateMountPath(c.MountPath); err != nil { + return err + } + if c.ResourceURL == "" { + return fmt.Errorf("required configuration missing: SYSDIG_MCP_RESOURCE_URL") + } + if c.AuthIssuer == "" { + return fmt.Errorf("required configuration missing: SYSDIG_MCP_AUTH_ISSUER") + } + if c.AuthJWKSURL == "" { + return fmt.Errorf("required configuration missing: SYSDIG_MCP_AUTH_JWKS_URL") + } + + resourceURL, err := parseAbsoluteURL("SYSDIG_MCP_RESOURCE_URL", c.ResourceURL) + if err != nil { + return err + } + if resourceURL.RawQuery != "" { + return fmt.Errorf("SYSDIG_MCP_RESOURCE_URL must not contain a query string") + } + resourcePath := resourceURL.Path + if resourcePath == "" { + resourcePath = "/" + } + if resourcePath != c.MountPath { + return fmt.Errorf( + "SYSDIG_MCP_RESOURCE_URL path %q must match SYSDIG_MCP_MOUNT_PATH %q", + resourcePath, + c.MountPath, + ) + } + + authIssuer, err := parseAbsoluteURL("SYSDIG_MCP_AUTH_ISSUER", c.AuthIssuer) + if err != nil { + return err + } + if authIssuer.RawQuery != "" { + return fmt.Errorf("SYSDIG_MCP_AUTH_ISSUER must not contain a query string") + } + if _, err := parseAbsoluteURL("SYSDIG_MCP_AUTH_JWKS_URL", c.AuthJWKSURL); err != nil { + return err + } + for _, origin := range c.AllowedOrigins { + if err := validateOrigin(origin); err != nil { + return fmt.Errorf("invalid SYSDIG_MCP_ALLOWED_ORIGINS entry %q: %w", origin, err) + } + } + for _, scope := range c.AuthScopes { + if !validScopeToken(scope) { + return fmt.Errorf("invalid scope %q in SYSDIG_MCP_AUTH_SCOPES", scope) + } + } + if len(c.AuthSigningAlgs) == 0 { + return fmt.Errorf("SYSDIG_MCP_AUTH_SIGNING_ALGS must contain at least one asymmetric signing algorithm") + } + for _, algorithm := range c.AuthSigningAlgs { + if !slices.Contains(supportedAuthSigningAlgorithms, algorithm) { + return fmt.Errorf("unsupported asymmetric signing algorithm %q in SYSDIG_MCP_AUTH_SIGNING_ALGS", algorithm) + } + } return nil } func Load() (*Config, error) { cfg := &Config{ - APIHost: getEnv("SYSDIG_MCP_API_HOST", ""), - APIToken: getEnv("SYSDIG_MCP_API_TOKEN", ""), - SkipTLSVerification: getEnv("SYSDIG_MCP_API_SKIP_TLS_VERIFICATION", false), - Transport: getEnv("SYSDIG_MCP_TRANSPORT", "stdio"), - ListeningHost: getEnv("SYSDIG_MCP_LISTENING_HOST", ""), - ListeningPort: getEnv("SYSDIG_MCP_LISTENING_PORT", "8080"), - MountPath: getEnv("SYSDIG_MCP_MOUNT_PATH", "/sysdig-mcp-server"), - LogLevel: getEnv("SYSDIG_MCP_LOGLEVEL", "INFO"), - Stateless: getEnv("SYSDIG_MCP_STATELESS", false), + APIHost: getEnv("SYSDIG_MCP_API_HOST", ""), + APIToken: getEnv("SYSDIG_MCP_API_TOKEN", ""), + SkipTLSVerification: getEnv("SYSDIG_MCP_API_SKIP_TLS_VERIFICATION", false), + SkipJWKSTLSVerification: getEnv("SYSDIG_MCP_AUTH_JWKS_SKIP_TLS_VERIFICATION", false), + Transport: getEnv("SYSDIG_MCP_TRANSPORT", "stdio"), + ListeningHost: getEnv("SYSDIG_MCP_LISTENING_HOST", ""), + ListeningPort: getEnv("SYSDIG_MCP_LISTENING_PORT", "8080"), + MountPath: getEnv("SYSDIG_MCP_MOUNT_PATH", "/sysdig-mcp-server"), + LogLevel: getEnv("SYSDIG_MCP_LOGLEVEL", "INFO"), + Stateless: getEnv("SYSDIG_MCP_STATELESS", false), + ResourceURL: getEnv("SYSDIG_MCP_RESOURCE_URL", ""), + AuthIssuer: getEnv("SYSDIG_MCP_AUTH_ISSUER", ""), + AuthJWKSURL: getEnv("SYSDIG_MCP_AUTH_JWKS_URL", ""), + AuthScopes: getEnvList("SYSDIG_MCP_AUTH_SCOPES", nil), + AuthSigningAlgs: getEnvList("SYSDIG_MCP_AUTH_SIGNING_ALGS", []string{oidc.RS256}), + AllowedOrigins: getEnvList("SYSDIG_MCP_ALLOWED_ORIGINS", nil), } if err := cfg.Validate(); err != nil { @@ -49,6 +157,73 @@ func Load() (*Config, error) { return cfg, nil } +func getEnvList(key string, fallback []string) []string { + value, ok := os.LookupEnv(key) + if !ok { + return slices.Clone(fallback) + } + + return strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || unicode.IsSpace(r) + }) +} + +func parseAbsoluteURL(name, rawURL string) (*url.URL, error) { + u, err := url.Parse(rawURL) + if err != nil || !u.IsAbs() || u.Host == "" { + return nil, fmt.Errorf("%s must be an absolute URL", name) + } + if u.User != nil || u.Fragment != "" { + return nil, fmt.Errorf("%s must not contain user information or a fragment", name) + } + if u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHostname(u.Hostname())) { + return nil, fmt.Errorf("%s must use https (http is allowed only for loopback development)", name) + } + return u, nil +} + +func validateMountPath(mountPath string) error { + if mountPath == "" || !strings.HasPrefix(mountPath, "/") || path.Clean(mountPath) != mountPath { + return fmt.Errorf("SYSDIG_MCP_MOUNT_PATH must be an absolute, canonical URL path") + } + return nil +} + +func validateOrigin(origin string) error { + if origin == "*" { + return fmt.Errorf("wildcard origins are not allowed") + } + u, err := parseAbsoluteURL("origin", origin) + if err != nil { + return err + } + if u.Path != "" || u.RawQuery != "" { + return fmt.Errorf("origin must contain only scheme and authority") + } + return nil +} + +func validScopeToken(scope string) bool { + if scope == "" { + return false + } + for _, r := range scope { + // RFC 6749 scope-token = 1*( %x21 / %x23-5B / %x5D-7E ). + if r < 0x21 || r > 0x7e || r == '"' || r == '\\' { + return false + } + } + return true +} + +func isLoopbackHostname(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + type envType interface { ~string | ~bool } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3800bbb..e8ab887 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,148 +9,179 @@ import ( "github.com/sysdiglabs/sysdig-mcp-server/internal/config" ) +func validConfig(transport string) *config.Config { + cfg := &config.Config{ + APIHost: "https://app.us4.sysdig.com", + APIToken: "sysdig-token", + Transport: transport, + MountPath: "/sysdig-mcp-server", + } + if transport != "stdio" { + cfg.ResourceURL = "https://mcp.example.com/sysdig-mcp-server" + cfg.AuthIssuer = "https://identity.example.com" + cfg.AuthJWKSURL = "https://identity.example.com/.well-known/jwks.json" + cfg.AuthSigningAlgs = []string{"RS256"} + cfg.AllowedOrigins = []string{"https://client.example.com"} + } + return cfg +} + var _ = Describe("Config", func() { Describe("Validate", func() { - Context("with a valid config", func() { - It("should not return an error", func() { - cfg := &config.Config{ - APIHost: "host", - APIToken: "token", - } - Expect(cfg.Validate()).To(Succeed()) - }) + It("accepts stdio and secured remote configurations", func() { + Expect(validConfig("stdio").Validate()).To(Succeed()) + Expect(validConfig("streamable-http").Validate()).To(Succeed()) + Expect(validConfig("sse").Validate()).To(Succeed()) + }) + + DescribeTable("rejects missing credentials for every transport", + func(transport string) { + cfg := validConfig(transport) + cfg.APIHost = "" + Expect(cfg.Validate()).To(MatchError(ContainSubstring("SYSDIG_MCP_API_HOST"))) + + cfg = validConfig(transport) + cfg.APIToken = "" + Expect(cfg.Validate()).To(MatchError(ContainSubstring("SYSDIG_MCP_API_TOKEN"))) + }, + Entry("stdio", "stdio"), + Entry("streamable HTTP", "streamable-http"), + Entry("SSE", "sse"), + ) + + It("rejects unsupported transports", func() { + cfg := validConfig("websocket") + Expect(cfg.Validate()).To(MatchError(ContainSubstring("unsupported SYSDIG_MCP_TRANSPORT"))) }) - Context("with a missing api host", func() { - It("should return an error if transport is stdio", func() { - cfg := &config.Config{ - Transport: "stdio", - APIToken: "token", - } - err := cfg.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("SYSDIG_MCP_API_HOST")) - }) - - It("should not return an error if transport is not stdio", func() { - cfg := &config.Config{ - Transport: "sse", - APIToken: "token", - } - err := cfg.Validate() - Expect(err).ToNot(HaveOccurred()) - }) + DescribeTable("requires the remote OAuth configuration", + func(clear func(*config.Config), expected string) { + cfg := validConfig("streamable-http") + clear(cfg) + Expect(cfg.Validate()).To(MatchError(ContainSubstring(expected))) + }, + Entry("resource URL", func(cfg *config.Config) { cfg.ResourceURL = "" }, "SYSDIG_MCP_RESOURCE_URL"), + Entry("issuer", func(cfg *config.Config) { cfg.AuthIssuer = "" }, "SYSDIG_MCP_AUTH_ISSUER"), + Entry("JWKS URL", func(cfg *config.Config) { cfg.AuthJWKSURL = "" }, "SYSDIG_MCP_AUTH_JWKS_URL"), + ) + + DescribeTable("rejects unsafe URLs and paths", + func(mutate func(*config.Config), expected string) { + cfg := validConfig("streamable-http") + mutate(cfg) + Expect(cfg.Validate()).To(MatchError(ContainSubstring(expected))) + }, + Entry("relative API host", func(cfg *config.Config) { cfg.APIHost = "app.example.com" }, "absolute URL"), + Entry("API host query", func(cfg *config.Config) { cfg.APIHost += "?tenant=one" }, "query string"), + Entry("plaintext resource", func(cfg *config.Config) { cfg.ResourceURL = "http://mcp.example.com/sysdig-mcp-server" }, "must use https"), + Entry("resource query", func(cfg *config.Config) { cfg.ResourceURL += "?tenant=one" }, "query string"), + Entry("resource path mismatch", func(cfg *config.Config) { cfg.ResourceURL = "https://mcp.example.com/other" }, "must match SYSDIG_MCP_MOUNT_PATH"), + Entry("non-canonical mount path", func(cfg *config.Config) { cfg.MountPath = "/sysdig-mcp-server/" }, "canonical URL path"), + Entry("issuer with user info", func(cfg *config.Config) { cfg.AuthIssuer = "https://user@identity.example.com" }, "user information"), + Entry("issuer query", func(cfg *config.Config) { cfg.AuthIssuer += "?tenant=one" }, "query string"), + Entry("fragmented JWKS URL", func(cfg *config.Config) { cfg.AuthJWKSURL += "#keys" }, "fragment"), + Entry("wildcard origin", func(cfg *config.Config) { cfg.AllowedOrigins = []string{"*"} }, "wildcard"), + Entry("origin path", func(cfg *config.Config) { cfg.AllowedOrigins = []string{"https://client.example.com/path"} }, "scheme and authority"), + Entry("invalid scope", func(cfg *config.Config) { cfg.AuthScopes = []string{"mcp:\"tools"} }, "invalid scope"), + Entry("empty signing algorithms", func(cfg *config.Config) { cfg.AuthSigningAlgs = nil }, "at least one"), + Entry("symmetric signing", func(cfg *config.Config) { cfg.AuthSigningAlgs = []string{"HS256"} }, "asymmetric signing algorithm"), + ) + + It("allows an API path prefix when it is otherwise safe", func() { + cfg := validConfig("streamable-http") + cfg.APIHost = "https://gateway.example.com/sysdig-proxy" + Expect(cfg.Validate()).To(Succeed()) }) - Context("with a missing api token", func() { - It("should not return an error if transport is not stdio", func() { - cfg := &config.Config{ - APIHost: "host", - } - err := cfg.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - - It("should return an error if transport is stdio", func() { - cfg := &config.Config{ - Transport: "stdio", - APIHost: "host", - } - err := cfg.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("SYSDIG_MCP_API_TOKEN")) - }) + It("allows a JWKS URL with a query component", func() { + cfg := validConfig("streamable-http") + cfg.AuthJWKSURL = "https://identity.example.com/jwks?tenant=one" + Expect(cfg.Validate()).To(Succeed()) + }) + + It("allows HTTP only for loopback development", func() { + cfg := validConfig("streamable-http") + cfg.APIHost = "http://127.0.0.1:9000" + cfg.ResourceURL = "http://localhost:8080/sysdig-mcp-server" + cfg.AuthIssuer = "http://[::1]:9001" + cfg.AuthJWKSURL = "http://localhost:9001/jwks" + cfg.AllowedOrigins = []string{"http://localhost:5173"} + Expect(cfg.Validate()).To(Succeed()) }) }) Describe("Load", func() { BeforeEach(func() { os.Clearenv() + _ = os.Setenv("SYSDIG_MCP_API_HOST", "https://app.us4.sysdig.com") + _ = os.Setenv("SYSDIG_MCP_API_TOKEN", "sysdig-token") }) - Context("with required env vars set for stdio", func() { - BeforeEach(func() { - _ = os.Setenv("SYSDIG_MCP_API_HOST", "host") - _ = os.Setenv("SYSDIG_MCP_API_TOKEN", "token") - }) - - It("should load default values", func() { - cfg, err := config.Load() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Transport).To(Equal("stdio")) - Expect(cfg.ListeningHost).To(BeEmpty()) - Expect(cfg.ListeningPort).To(Equal("8080")) - Expect(cfg.MountPath).To(Equal("/sysdig-mcp-server")) - Expect(cfg.LogLevel).To(Equal("INFO")) - Expect(cfg.SkipTLSVerification).To(BeFalse()) - Expect(cfg.Stateless).To(BeFalse()) - }) + It("loads stdio defaults", func() { + cfg, err := config.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Transport).To(Equal("stdio")) + Expect(cfg.ListeningHost).To(BeEmpty()) + Expect(cfg.ListeningPort).To(Equal("8080")) + Expect(cfg.MountPath).To(Equal("/sysdig-mcp-server")) + Expect(cfg.LogLevel).To(Equal("INFO")) + Expect(cfg.SkipTLSVerification).To(BeFalse()) + Expect(cfg.SkipJWKSTLSVerification).To(BeFalse()) + Expect(cfg.Stateless).To(BeFalse()) + Expect(cfg.AuthSigningAlgs).To(Equal([]string{"RS256"})) }) - Context("with required env vars set for http", func() { - BeforeEach(func() { - _ = os.Setenv("SYSDIG_MCP_API_HOST", "host") - _ = os.Setenv("SYSDIG_MCP_TRANSPORT", "streamable-http") - }) - - It("should load default values", func() { - cfg, err := config.Load() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Transport).To(Equal("streamable-http")) - Expect(cfg.ListeningHost).To(BeEmpty()) - Expect(cfg.ListeningPort).To(Equal("8080")) - Expect(cfg.MountPath).To(Equal("/sysdig-mcp-server")) - Expect(cfg.LogLevel).To(Equal("INFO")) - }) + It("loads all remote security values", func() { + _ = os.Setenv("SYSDIG_MCP_API_SKIP_TLS_VERIFICATION", "true") + _ = os.Setenv("SYSDIG_MCP_AUTH_JWKS_SKIP_TLS_VERIFICATION", "true") + _ = os.Setenv("SYSDIG_MCP_TRANSPORT", "streamable-http") + _ = os.Setenv("SYSDIG_MCP_LISTENING_HOST", "0.0.0.0") + _ = os.Setenv("SYSDIG_MCP_LISTENING_PORT", "9090") + _ = os.Setenv("SYSDIG_MCP_MOUNT_PATH", "/custom") + _ = os.Setenv("SYSDIG_MCP_LOGLEVEL", "DEBUG") + _ = os.Setenv("SYSDIG_MCP_STATELESS", "true") + _ = os.Setenv("SYSDIG_MCP_RESOURCE_URL", "https://mcp.example.com/custom") + _ = os.Setenv("SYSDIG_MCP_AUTH_ISSUER", "https://identity.example.com") + _ = os.Setenv("SYSDIG_MCP_AUTH_JWKS_URL", "https://identity.example.com/jwks") + _ = os.Setenv("SYSDIG_MCP_AUTH_SCOPES", "mcp:tools,\r\n profile") + _ = os.Setenv("SYSDIG_MCP_AUTH_SIGNING_ALGS", "RS256 ES256") + _ = os.Setenv("SYSDIG_MCP_ALLOWED_ORIGINS", "https://one.example.com, https://two.example.com") + + cfg, err := config.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.SkipTLSVerification).To(BeTrue()) + Expect(cfg.SkipJWKSTLSVerification).To(BeTrue()) + Expect(cfg.Transport).To(Equal("streamable-http")) + Expect(cfg.ListeningHost).To(Equal("0.0.0.0")) + Expect(cfg.ListeningPort).To(Equal("9090")) + Expect(cfg.MountPath).To(Equal("/custom")) + Expect(cfg.LogLevel).To(Equal("DEBUG")) + Expect(cfg.Stateless).To(BeTrue()) + Expect(cfg.ResourceURL).To(Equal("https://mcp.example.com/custom")) + Expect(cfg.AuthIssuer).To(Equal("https://identity.example.com")) + Expect(cfg.AuthJWKSURL).To(Equal("https://identity.example.com/jwks")) + Expect(cfg.AuthScopes).To(Equal([]string{"mcp:tools", "profile"})) + Expect(cfg.AuthSigningAlgs).To(Equal([]string{"RS256", "ES256"})) + Expect(cfg.AllowedOrigins).To(Equal([]string{"https://one.example.com", "https://two.example.com"})) }) - Context("with all env vars set", func() { - BeforeEach(func() { - _ = os.Setenv("SYSDIG_MCP_API_HOST", "env-host") - _ = os.Setenv("SYSDIG_MCP_API_TOKEN", "env-token") - _ = os.Setenv("SYSDIG_MCP_API_SKIP_TLS_VERIFICATION", "true") - _ = os.Setenv("SYSDIG_MCP_TRANSPORT", "http") - _ = os.Setenv("SYSDIG_MCP_LISTENING_HOST", "0.0.0.0") - _ = os.Setenv("SYSDIG_MCP_LISTENING_PORT", "9090") - _ = os.Setenv("SYSDIG_MCP_MOUNT_PATH", "/custom") - _ = os.Setenv("SYSDIG_MCP_LOGLEVEL", "DEBUG") - _ = os.Setenv("SYSDIG_MCP_STATELESS", "true") - }) - - It("should load all values from the environment", func() { - cfg, err := config.Load() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.APIHost).To(Equal("env-host")) - Expect(cfg.APIToken).To(Equal("env-token")) - Expect(cfg.SkipTLSVerification).To(BeTrue()) - Expect(cfg.Transport).To(Equal("http")) - Expect(cfg.ListeningHost).To(Equal("0.0.0.0")) - Expect(cfg.ListeningPort).To(Equal("9090")) - Expect(cfg.MountPath).To(Equal("/custom")) - Expect(cfg.LogLevel).To(Equal("DEBUG")) - Expect(cfg.Stateless).To(BeTrue()) - }) + It("requires all remote settings", func() { + _ = os.Setenv("SYSDIG_MCP_TRANSPORT", "sse") + _, err := config.Load() + Expect(err).To(MatchError(ContainSubstring("SYSDIG_MCP_RESOURCE_URL"))) }) - Context("with invalid boolean env var", func() { - BeforeEach(func() { - _ = os.Setenv("SYSDIG_MCP_API_HOST", "host") - _ = os.Setenv("SYSDIG_MCP_API_TOKEN", "token") - _ = os.Setenv("SYSDIG_MCP_API_SKIP_TLS_VERIFICATION", "invalid-bool") - }) - - It("should fall back to default value", func() { - cfg, err := config.Load() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.SkipTLSVerification).To(BeFalse()) - }) + It("falls back for invalid booleans", func() { + _ = os.Setenv("SYSDIG_MCP_API_SKIP_TLS_VERIFICATION", "invalid-bool") + cfg, err := config.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.SkipTLSVerification).To(BeFalse()) }) - Context("without required env vars", func() { - It("should return an error", func() { - _, err := config.Load() - Expect(err).To(HaveOccurred()) - }) + It("fails without required values", func() { + os.Clearenv() + _, err := config.Load() + Expect(err).To(HaveOccurred()) }) }) }) diff --git a/internal/infra/auth/token_verifier.go b/internal/infra/auth/token_verifier.go new file mode 100644 index 0000000..c5a1280 --- /dev/null +++ b/internal/infra/auth/token_verifier.go @@ -0,0 +1,125 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" +) + +const jwksRequestTimeout = 10 * time.Second + +// ErrInsufficientScope distinguishes an authenticated token from one that +// lacks the authorization required by this resource. +var ErrInsufficientScope = errors.New("access token has insufficient scope") + +// TokenVerifier validates an access token presented to the MCP server. +// Implementations must never forward the token to an upstream service. +type TokenVerifier interface { + Verify(context.Context, string) error +} + +// JWTVerifier validates signed JWT access tokens against a remote JWKS. +// Issuer, audience, expiry, and signing algorithm checks are delegated to the +// OIDC verifier. Optional scopes are checked after cryptographic validation. +type JWTVerifier struct { + verifier *oidc.IDTokenVerifier + requiredScopes []string +} + +func NewJWTVerifier( + ctx context.Context, + issuer string, + audience string, + jwksURL string, + signingAlgorithms []string, + requiredScopes []string, +) *JWTVerifier { + return NewJWTVerifierWithHTTPClient( + ctx, + issuer, + audience, + jwksURL, + signingAlgorithms, + requiredScopes, + nil, + ) +} + +func NewJWTVerifierWithHTTPClient( + ctx context.Context, + issuer string, + audience string, + jwksURL string, + signingAlgorithms []string, + requiredScopes []string, + httpClient *http.Client, +) *JWTVerifier { + if httpClient == nil { + httpClient = &http.Client{} + } else { + clone := *httpClient + httpClient = &clone + } + if httpClient.Timeout == 0 { + httpClient.Timeout = jwksRequestTimeout + } + + ctx = oidc.ClientContext(ctx, httpClient) + keySet := oidc.NewRemoteKeySet(ctx, jwksURL) + verifier := oidc.NewVerifier(issuer, keySet, &oidc.Config{ + ClientID: audience, + SupportedSigningAlgs: signingAlgorithms, + }) + + return &JWTVerifier{ + verifier: verifier, + requiredScopes: slices.Clone(requiredScopes), + } +} + +func (v *JWTVerifier) Verify(ctx context.Context, rawToken string) error { + token, err := v.verifier.Verify(ctx, rawToken) + if err != nil { + return fmt.Errorf("validating access token: %w", err) + } + + if len(v.requiredScopes) == 0 { + return nil + } + + var claims struct { + Scope string `json:"scope"` + SCP json.RawMessage `json:"scp"` + } + if err := token.Claims(&claims); err != nil { + return fmt.Errorf("decoding access token claims: %w", err) + } + + grantedScopes := strings.Fields(claims.Scope) + if len(claims.SCP) > 0 { + var scopeString string + if err := json.Unmarshal(claims.SCP, &scopeString); err == nil { + grantedScopes = append(grantedScopes, strings.Fields(scopeString)...) + } else { + var scopeList []string + if err := json.Unmarshal(claims.SCP, &scopeList); err != nil { + return fmt.Errorf("decoding scp claim: %w", err) + } + grantedScopes = append(grantedScopes, scopeList...) + } + } + for _, requiredScope := range v.requiredScopes { + if !slices.Contains(grantedScopes, requiredScope) { + return fmt.Errorf("%w: missing %q", ErrInsufficientScope, requiredScope) + } + } + + return nil +} diff --git a/internal/infra/auth/token_verifier_test.go b/internal/infra/auth/token_verifier_test.go new file mode 100644 index 0000000..342eb72 --- /dev/null +++ b/internal/infra/auth/token_verifier_test.go @@ -0,0 +1,226 @@ +package auth_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + infraauth "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/auth" +) + +const ( + testIssuer = "https://identity.example.com" + testAudience = "https://mcp.example.com/sysdig-mcp-server" +) + +type accessTokenClaims struct { + Scope string `json:"scope,omitempty"` + SCP any `json:"scp,omitempty"` +} + +func TestJWTVerifier(t *testing.T) { + t.Parallel() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generating RSA key: %v", err) + } + + keyID := "test-key" + jwks := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{ + Key: &privateKey.PublicKey, + KeyID: keyID, + Algorithm: string(jose.RS256), + Use: "sig", + }}} + jwksHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/jwks" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(jwks); err != nil { + t.Errorf("encoding JWKS: %v", err) + } + }) + jwksServer := httptest.NewServer(jwksHandler) + defer jwksServer.Close() + + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: privateKey}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", keyID), + ) + if err != nil { + t.Fatalf("creating signer: %v", err) + } + + newToken := func(issuer string, audience jwt.Audience, expiry time.Time, claims accessTokenClaims) string { + t.Helper() + rawToken, err := jwt.Signed(signer). + Claims(jwt.Claims{ + Issuer: issuer, + Audience: audience, + Expiry: jwt.NewNumericDate(expiry), + }). + Claims(claims). + Serialize() + if err != nil { + t.Fatalf("signing token: %v", err) + } + return rawToken + } + + tests := []struct { + name string + issuer string + audience jwt.Audience + expiry time.Time + claims accessTokenClaims + signingAlgorithms []string + requiredScopes []string + wantError bool + wantScopeError bool + }{ + { + name: "valid scope claim", + issuer: testIssuer, + audience: jwt.Audience{testAudience}, + expiry: time.Now().Add(time.Hour), + claims: accessTokenClaims{Scope: "openid mcp:tools"}, + signingAlgorithms: []string{"RS256"}, + requiredScopes: []string{"mcp:tools"}, + }, + { + name: "valid scp claim", + issuer: testIssuer, + audience: jwt.Audience{"another-audience", testAudience}, + expiry: time.Now().Add(time.Hour), + claims: accessTokenClaims{SCP: []string{"mcp:tools", "profile"}}, + signingAlgorithms: []string{"RS256"}, + requiredScopes: []string{"mcp:tools", "profile"}, + }, + { + name: "valid space-delimited scp claim", + issuer: testIssuer, + audience: jwt.Audience{testAudience}, + expiry: time.Now().Add(time.Hour), + claims: accessTokenClaims{SCP: "mcp:tools profile"}, + signingAlgorithms: []string{"RS256"}, + requiredScopes: []string{"mcp:tools", "profile"}, + }, + { + name: "wrong issuer", + issuer: "https://attacker.example.com", + audience: jwt.Audience{testAudience}, + expiry: time.Now().Add(time.Hour), + signingAlgorithms: []string{"RS256"}, + wantError: true, + }, + { + name: "wrong audience", + issuer: testIssuer, + audience: jwt.Audience{"another-audience"}, + expiry: time.Now().Add(time.Hour), + signingAlgorithms: []string{"RS256"}, + wantError: true, + }, + { + name: "expired", + issuer: testIssuer, + audience: jwt.Audience{testAudience}, + expiry: time.Now().Add(-time.Hour), + signingAlgorithms: []string{"RS256"}, + wantError: true, + }, + { + name: "missing required scope", + issuer: testIssuer, + audience: jwt.Audience{testAudience}, + expiry: time.Now().Add(time.Hour), + claims: accessTokenClaims{Scope: "openid"}, + signingAlgorithms: []string{"RS256"}, + requiredScopes: []string{"mcp:tools"}, + wantError: true, + wantScopeError: true, + }, + { + name: "disallowed signing algorithm", + issuer: testIssuer, + audience: jwt.Audience{testAudience}, + expiry: time.Now().Add(time.Hour), + signingAlgorithms: []string{"ES256"}, + wantError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rawToken := newToken(test.issuer, test.audience, test.expiry, test.claims) + verifier := infraauth.NewJWTVerifier( + context.Background(), + testIssuer, + testAudience, + jwksServer.URL+"/jwks", + test.signingAlgorithms, + test.requiredScopes, + ) + + err := verifier.Verify(context.Background(), rawToken) + if test.wantError && err == nil { + t.Fatal("expected token verification to fail") + } + if test.wantScopeError && !errors.Is(err, infraauth.ErrInsufficientScope) { + t.Fatalf("expected insufficient scope error, got: %v", err) + } + if !test.wantError && err != nil { + t.Fatalf("expected token verification to succeed: %v", err) + } + }) + } + + t.Run("uses a supplied HTTP client for JWKS TLS policy", func(t *testing.T) { + tlsServer := httptest.NewTLSServer(jwksHandler) + defer tlsServer.Close() + + rawToken := newToken( + testIssuer, + jwt.Audience{testAudience}, + time.Now().Add(time.Hour), + accessTokenClaims{}, + ) + + defaultVerifier := infraauth.NewJWTVerifier( + context.Background(), + testIssuer, + testAudience, + tlsServer.URL+"/jwks", + []string{"RS256"}, + nil, + ) + if err := defaultVerifier.Verify(context.Background(), rawToken); err == nil { + t.Fatal("expected the default JWKS client to reject the self-signed certificate") + } + + customVerifier := infraauth.NewJWTVerifierWithHTTPClient( + context.Background(), + testIssuer, + testAudience, + tlsServer.URL+"/jwks", + []string{"RS256"}, + nil, + tlsServer.Client(), + ) + if err := customVerifier.Verify(context.Background(), rawToken); err != nil { + t.Fatalf("expected custom JWKS HTTP client to be used: %v", err) + } + }) +} diff --git a/internal/infra/mcp/mcp_handler.go b/internal/infra/mcp/mcp_handler.go index f9eea98..e1d1ec5 100644 --- a/internal/infra/mcp/mcp_handler.go +++ b/internal/infra/mcp/mcp_handler.go @@ -15,6 +15,8 @@ import ( "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig" ) +const corsMaxAgeSeconds = 600 + type Handler struct { server *server.MCPServer } @@ -67,7 +69,7 @@ func NewHandler(version string, sysdigClient sysdig.ExtendedClientWithResponsesI s := server.NewMCPServer( "Sysdig MCP Server", version, - server.WithInstructions("Provides Sysdig Secure tools and resources."), + server.WithInstructions("Provides read-only Sysdig Monitor tools for infrastructure analysis."), server.WithToolCapabilities(true), server.WithToolFilter(toolPermissionFiltering(sysdigClient)), ) @@ -87,58 +89,51 @@ func (h *Handler) ServeStdio(ctx context.Context, stdin io.Reader, stdout io.Wri return server.NewStdioServer(h.server).Listen(ctx, stdin, stdout) } -func (h *Handler) AsStreamableHTTP(mountPath string, stateless bool) http.Handler { +func (h *Handler) AsStreamableHTTP(mountPath string, stateless bool, security RemoteSecurity) http.Handler { mux := http.NewServeMux() - var opts []server.StreamableHTTPOption + opts := []server.StreamableHTTPOption{ + server.WithStreamableHTTPCORS(remoteCORSOptions(security)...), + } if stateless { opts = append(opts, server.WithStateLess(true)) } httpServer := server.NewStreamableHTTPServer(h.server, opts...) - mux.Handle(mountPath, authMiddleware(httpServer)) + security.mountMetadata(mux) + mux.Handle(mountPath, security.protect(httpServer)) return mux } -func (h *Handler) AsSSE(mountPath string) http.Handler { +func (h *Handler) AsSSE(mountPath string, security RemoteSecurity) http.Handler { mux := http.NewServeMux() - sseServer := server.NewSSEServer(h.server, server.WithStaticBasePath(mountPath)) - mux.Handle(mountPath, authMiddleware(sseServer)) + sseServer := server.NewSSEServer( + h.server, + server.WithStaticBasePath(mountPath), + server.WithSSECORS(remoteCORSOptions(security)...), + ) + security.mountMetadata(mux) + mux.Handle(sseServer.CompleteSsePath(), security.protect(sseServer.SSEHandler())) + mux.Handle(sseServer.CompleteMessagePath(), security.protect(sseServer.MessageHandler())) return mux } -func (h *Handler) ServeInProcessClient() (*client.Client, error) { - return client.NewInProcessClient(h.server) +func remoteCORSOptions(security RemoteSecurity) []server.CORSOption { + return []server.CORSOption{ + server.WithCORSAllowedOrigins(security.corsOrigins()...), + server.WithCORSAllowedMethods(http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodOptions), + server.WithCORSAllowedHeaders( + "Authorization", + "Content-Type", + "Last-Event-ID", + server.HeaderKeyProtocolVersion, + server.HeaderKeySessionID, + ), + server.WithCORSExposedHeaders(server.HeaderKeySessionID, "WWW-Authenticate"), + server.WithCORSMaxAge(corsMaxAgeSeconds), + } } -func authMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - slog.Debug("starting middleware", "headers", r.Header.Clone()) - ctx := r.Context() - - if host := r.Header.Get("X-Sysdig-Host"); host != "" { - slog.Debug("setting up host", "host", host) - ctx = sysdig.WrapContextWithHost(ctx, host) - } - - var token string - authHeader := r.Header.Get("Authorization") - if authHeader != "" { - parts := strings.Split(authHeader, " ") - if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { - token = parts[1] - } - } - - if token == "" { - token = r.Header.Get("X-Sysdig-Token") - } - - if token != "" { - slog.Debug("setting up token", "token", token) - ctx = sysdig.WrapContextWithToken(ctx, token) - } - - next.ServeHTTP(w, r.WithContext(ctx)) - }) +func (h *Handler) ServeInProcessClient() (*client.Client, error) { + return client.NewInProcessClient(h.server) } diff --git a/internal/infra/mcp/mcp_handler_test.go b/internal/infra/mcp/mcp_handler_test.go index c134924..cdb356b 100644 --- a/internal/infra/mcp/mcp_handler_test.go +++ b/internal/infra/mcp/mcp_handler_test.go @@ -4,11 +4,13 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" "net/http" "net/http/httptest" + "strings" "time" "github.com/mark3labs/mcp-go/client" @@ -18,13 +20,19 @@ import ( . "github.com/onsi/gomega" "go.uber.org/mock/gomock" + infraauth "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/auth" localmcp "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp" "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp/tools" "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig" "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig/mocks" ) -// dummyTool implements the interface required by Handler.RegisterTools +const ( + validMCPToken = "mcp-access-token" + allowedOrigin = "https://client.example.com" + resourceURL = "https://mcp.example.com/sysdig-mcp-server" +) + type dummyTool struct { name string requiredPermissions []string @@ -32,20 +40,47 @@ type dummyTool struct { func (d *dummyTool) RegisterInServer(s *server.MCPServer) { tool := mcp.NewTool(d.name, mcp.WithDescription("dummy tool")) - // Initialize Meta to avoid nil pointer issues in strict checks if tool.Meta == nil { - tool.Meta = &mcp.Meta{ - AdditionalFields: make(map[string]any), - } + tool.Meta = &mcp.Meta{AdditionalFields: make(map[string]any)} } if len(d.requiredPermissions) > 0 { tools.WithRequiredPermissions(d.requiredPermissions...)(&tool) } - s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + s.AddTool(tool, func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return mcp.NewToolResultText("success"), nil }) } +type fakeTokenVerifier struct { + validToken string + calls int +} + +func (v *fakeTokenVerifier) Verify(_ context.Context, rawToken string) error { + v.calls++ + if rawToken == "insufficient-scope" { + return infraauth.ErrInsufficientScope + } + if rawToken != v.validToken { + return errors.New("invalid access token") + } + return nil +} + +func remoteSecurity(verifier *fakeTokenVerifier) localmcp.RemoteSecurity { + return localmcp.NewRemoteSecurity( + verifier, + resourceURL, + "https://identity.example.com", + []string{"mcp:tools"}, + []string{allowedOrigin}, + ) +} + +func authorizationHeaders() http.Header { + return http.Header{"Authorization": []string{"Bearer " + validMCPToken}} +} + var _ = Describe("McpHandler", func() { var ( ctrl *gomock.Controller @@ -65,164 +100,263 @@ var _ = Describe("McpHandler", func() { }) Context("Permissions", func() { - It("should filter tools based on permissions", func() { - t1 := &dummyTool{name: "tool1", requiredPermissions: []string{"perm1"}} - t2 := &dummyTool{name: "tool2", requiredPermissions: []string{"perm2"}} - t3 := &dummyTool{name: "tool3" /* no permissions required */} - - handler.RegisterTools(t1, t2, t3) + It("filters tools based on permissions", func() { + handler.RegisterTools( + &dummyTool{name: "tool1", requiredPermissions: []string{"perm1"}}, + &dummyTool{name: "tool2", requiredPermissions: []string{"perm2"}}, + &dummyTool{name: "tool3"}, + ) mockClient.EXPECT().GetMyPermissionsWithResponse(gomock.Any()).Return(&sysdig.GetMyPermissionsResponse{ - HTTPResponse: &http.Response{StatusCode: 200}, - JSON200: &sysdig.UserPermissions{ - Permissions: []string{"perm1"}, - }, + HTTPResponse: &http.Response{StatusCode: http.StatusOK}, + JSON200: &sysdig.UserPermissions{Permissions: []string{"perm1"}}, }, nil) c := initializeInProcessClient(handler) - resp, err := c.ListTools(context.Background(), mcp.ListToolsRequest{}) Expect(err).NotTo(HaveOccurred()) var names []string - for _, t := range resp.Tools { - names = append(names, t.Name) + for _, tool := range resp.Tools { + names = append(names, tool.Name) } Expect(names).To(ConsistOf("tool1", "tool3")) }) - It("should handle permission errors gracefully", func() { - t1 := &dummyTool{name: "tool1", requiredPermissions: []string{"perm1"}} - handler.RegisterTools(t1) - - mockClient.EXPECT().GetMyPermissionsWithResponse(gomock.Any()).Return(nil, fmt.Errorf("error")) + It("handles permission errors without exposing tools", func() { + handler.RegisterTools(&dummyTool{name: "tool1", requiredPermissions: []string{"perm1"}}) + mockClient.EXPECT().GetMyPermissionsWithResponse(gomock.Any()).Return(nil, fmt.Errorf("permission lookup failed")) c := initializeInProcessClient(handler) - resp, err := c.ListTools(context.Background(), mcp.ListToolsRequest{}) Expect(err).NotTo(HaveOccurred()) Expect(resp.Tools).To(BeEmpty()) }) }) - Context("HTTP Handlers and Middleware", func() { - var testClient *HTTPTestClient + Context("Remote trust boundary", func() { + var ( + verifier *fakeTokenVerifier + testClient *HTTPTestClient + ) BeforeEach(func() { - // Default middleware setup for HTTP tests - h := handler.AsStreamableHTTP("/", false) - testClient = NewHTTPTestClient(h) + verifier = &fakeTokenVerifier{validToken: validMCPToken} + testClient = NewHTTPTestClient(handler.AsStreamableHTTP("/", false, remoteSecurity(verifier))) + }) + + It("serves an authenticated Streamable HTTP session", func(ctx SpecContext) { + handler.RegisterTools(&dummyTool{name: "tool1", requiredPermissions: []string{"perm1"}}) + mockClient.EXPECT().GetMyPermissionsWithResponse(gomock.Any()).Return(&sysdig.GetMyPermissionsResponse{ + HTTPResponse: &http.Response{StatusCode: http.StatusOK}, + JSON200: &sysdig.UserPermissions{Permissions: []string{"perm1"}}, + }, nil) + + testClient.Initialize(ctx, authorizationHeaders()) + resp := testClient.ListTools(ctx, authorizationHeaders()) + defer func() { _ = resp.Body.Close() }() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(verifier.calls).To(Equal(2)) + }, NodeTimeout(5*time.Second)) + + DescribeTable("rejects invalid authorization headers", + func(headers http.Header) { + resp := testClient.RPC(context.Background(), "tools/list", nil, headers) + defer func() { _ = resp.Body.Close() }() + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + Expect(resp.Header.Get("WWW-Authenticate")).To(ContainSubstring("resource_metadata=")) + }, + Entry("missing", http.Header{}), + Entry("wrong scheme", http.Header{"Authorization": []string{"Basic abc"}}), + Entry("empty bearer", http.Header{"Authorization": []string{"Bearer"}}), + Entry("duplicate", http.Header{"Authorization": []string{"Bearer one", "Bearer two"}}), + ) + + It("returns invalid_token when JWT verification fails", func() { + headers := http.Header{"Authorization": []string{"Bearer wrong-token"}} + resp := testClient.RPC(context.Background(), "tools/list", nil, headers) + defer func() { _ = resp.Body.Close() }() + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + Expect(resp.Header.Get("WWW-Authenticate")).To(ContainSubstring(`error="invalid_token"`)) }) - It("AsStreamableHTTP should serve correctly and middleware should extract headers", func(ctx SpecContext) { - expectedHost := "https://test.sysdig.com" - expectedToken := "my-token" + It("returns insufficient_scope with 403 for a valid but under-scoped token", func() { + headers := http.Header{"Authorization": []string{"Bearer insufficient-scope"}} + resp := testClient.RPC(context.Background(), "tools/list", nil, headers) + defer func() { _ = resp.Body.Close() }() + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + Expect(resp.Header.Get("WWW-Authenticate")).To(ContainSubstring(`error="insufficient_scope"`)) + Expect(resp.Header.Get("WWW-Authenticate")).To(ContainSubstring(`scope="mcp:tools"`)) + }) - t1 := &dummyTool{name: "tool1", requiredPermissions: []string{"perm1"}} - handler.RegisterTools(t1) + It("rejects non-allowlisted browser origins before token verification", func() { + headers := authorizationHeaders() + headers.Set("Origin", "https://evil.example.com") + resp := testClient.RPC(context.Background(), "tools/list", nil, headers) + defer func() { _ = resp.Body.Close() }() + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + Expect(verifier.calls).To(BeZero()) + }) - mockClient.EXPECT(). - GetMyPermissionsWithResponse(gomock.Any(), gomock.Any()). - DoAndReturn(func(c context.Context, reqEditors ...sysdig.RequestEditorFn) (*sysdig.GetMyPermissionsResponse, error) { - Expect(sysdig.GetHostFromContext(c)).To(Equal(expectedHost)) - Expect(sysdig.GetTokenFromContext(c)).To(Equal(expectedToken)) + It("rejects duplicate Origin headers", func() { + headers := authorizationHeaders() + headers.Add("Origin", allowedOrigin) + headers.Add("Origin", allowedOrigin) + resp := testClient.RPC(context.Background(), "tools/list", nil, headers) + defer func() { _ = resp.Body.Close() }() + Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) + Expect(verifier.calls).To(BeZero()) + }) - return &sysdig.GetMyPermissionsResponse{ - HTTPResponse: &http.Response{StatusCode: 200}, - JSON200: &sysdig.UserPermissions{ - Permissions: []string{"perm1"}, - }, - }, nil - }) + It("returns CORS headers only for an exact allowlisted origin", func(ctx SpecContext) { + headers := authorizationHeaders() + headers.Set("Origin", allowedOrigin) + testClient.Initialize(ctx, headers) + resp := testClient.RPC(ctx, "ping", nil, headers) + defer func() { _ = resp.Body.Close() }() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(resp.Header.Get("Access-Control-Allow-Origin")).To(Equal(allowedOrigin)) + Expect(resp.Header.Get("Vary")).To(ContainSubstring("Origin")) + }, NodeTimeout(5*time.Second)) + + It("normalizes origin host case before exact comparison", func() { + mixedCaseSecurity := localmcp.NewRemoteSecurity( + verifier, + resourceURL, + "https://identity.example.com", + []string{"mcp:tools"}, + []string{"https://Client.Example.com"}, + ) + client := NewHTTPTestClient(handler.AsStreamableHTTP("/", false, mixedCaseSecurity)) + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "https://client.example.com") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + recorder := httptest.NewRecorder() + client.handler.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusNoContent)) + Expect(recorder.Header().Get("Access-Control-Allow-Origin")).To(Equal("https://client.example.com")) + }) - testClient.Initialize(ctx) + It("answers an allowlisted CORS preflight without a token", func() { + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", allowedOrigin) + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + recorder := httptest.NewRecorder() + testClient.handler.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusNoContent)) + Expect(recorder.Header().Get("Access-Control-Allow-Origin")).To(Equal(allowedOrigin)) + Expect(recorder.Header().Get("Access-Control-Allow-Headers")).To(ContainSubstring("Authorization")) + Expect(recorder.Header().Get("Access-Control-Max-Age")).To(Equal("600")) + Expect(verifier.calls).To(BeZero()) + }) - headers := map[string]string{ - "X-Sysdig-Host": expectedHost, - "Authorization": "Bearer " + expectedToken, - } - respList := testClient.ListTools(ctx, headers) - Expect(respList.StatusCode).To(Equal(http.StatusOK)) - }, NodeTimeout(time.Second*5)) + It("does not treat a plain OPTIONS request as a CORS preflight", func() { + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", allowedOrigin) + recorder := httptest.NewRecorder() + testClient.handler.ServeHTTP(recorder, req) - It("should handle X-Sysdig-Token header in middleware", func(ctx SpecContext) { - expectedToken := "token-header" + Expect(recorder.Code).To(Equal(http.StatusUnauthorized)) + Expect(verifier.calls).To(BeZero()) + }) - handler.RegisterTools(&dummyTool{name: "tool1", requiredPermissions: []string{"perm1"}}) + It("publishes OAuth protected-resource metadata without authentication", func() { + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource/sysdig-mcp-server", nil) + recorder := httptest.NewRecorder() + testClient.handler.ServeHTTP(recorder, req) - mockClient.EXPECT(). - GetMyPermissionsWithResponse(gomock.Any(), gomock.Any()). - DoAndReturn(func(c context.Context, reqEditors ...sysdig.RequestEditorFn) (*sysdig.GetMyPermissionsResponse, error) { - Expect(sysdig.GetTokenFromContext(c)).To(Equal(expectedToken)) - - return &sysdig.GetMyPermissionsResponse{ - HTTPResponse: &http.Response{StatusCode: 200}, - JSON200: &sysdig.UserPermissions{Permissions: []string{"perm1"}}, - }, nil - }) - - testClient.Initialize(ctx) - - headers := map[string]string{"X-Sysdig-Token": expectedToken} - respList := testClient.ListTools(ctx, headers) - Expect(respList.StatusCode).To(Equal(http.StatusOK)) - }, NodeTimeout(time.Second*5)) - - It("should handle request with no auth headers", func(ctx SpecContext) { - mockClient.EXPECT(). - GetMyPermissionsWithResponse(gomock.Any(), gomock.Any()). - Return(nil, fmt.Errorf("no auth")) - - testClient.Initialize(ctx) - respList := testClient.ListTools(ctx, nil) - Expect(respList.StatusCode).To(Equal(http.StatusOK)) - }, NodeTimeout(time.Second*5)) - - It("AsSSE should return a handler", func() { - h := handler.AsSSE("/sse") - Expect(h).NotTo(BeNil()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + Expect(recorder.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + Expect(recorder.Body.String()).To(ContainSubstring(resourceURL)) + Expect(recorder.Body.String()).To(ContainSubstring("https://identity.example.com")) + Expect(verifier.calls).To(BeZero()) }) - It("AsStreamableHTTP with stateless should serve tools/list without initialize", func(ctx SpecContext) { - h := handler.AsStreamableHTTP("/", true) - statelessClient := NewHTTPTestClient(h) + It("never forwards the MCP access token to Sysdig", func(ctx SpecContext) { + var upstreamAuthorization string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamAuthorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"permissions":[]}`)) + })) + defer upstream.Close() - handler.RegisterTools(&dummyTool{name: "tool1"}) + sysdigClient, err := sysdig.NewSysdigClient(sysdig.WithFixedHostAndToken(upstream.URL, "server-side-sysdig-token")) + Expect(err).NotTo(HaveOccurred()) + isolatedHandler := localmcp.NewHandler("dev", sysdigClient) + isolatedHandler.RegisterTools(&dummyTool{name: "tool1"}) + client := NewHTTPTestClient(isolatedHandler.AsStreamableHTTP("/", false, remoteSecurity(verifier))) + + client.Initialize(ctx, authorizationHeaders()) + resp := client.ListTools(ctx, authorizationHeaders()) + defer func() { _ = resp.Body.Close() }() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(upstreamAuthorization).To(Equal("Bearer server-side-sysdig-token")) + Expect(upstreamAuthorization).NotTo(ContainSubstring(validMCPToken)) + }, NodeTimeout(5*time.Second)) + + It("routes the protected SSE message endpoint and preserves exact-origin CORS", func() { + sseHandler := handler.AsSSE("/sysdig-mcp-server", remoteSecurity(verifier)) + req := httptest.NewRequest( + http.MethodPost, + "/sysdig-mcp-server/message?sessionId=missing", + strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`), + ) + req.Header = authorizationHeaders() + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", allowedOrigin) + recorder := httptest.NewRecorder() + sseHandler.ServeHTTP(recorder, req) + + Expect(recorder.Code).NotTo(Equal(http.StatusNotFound)) + Expect(recorder.Header().Get("Access-Control-Allow-Origin")).To(Equal(allowedOrigin)) + Expect(recorder.Header().Get("Access-Control-Allow-Origin")).NotTo(Equal("*")) + Expect(verifier.calls).To(Equal(1)) + }) - mockClient.EXPECT(). - GetMyPermissionsWithResponse(gomock.Any(), gomock.Any()). - Return(&sysdig.GetMyPermissionsResponse{ - HTTPResponse: &http.Response{StatusCode: 200}, - JSON200: &sysdig.UserPermissions{Permissions: []string{}}, - }, nil) + It("serves SSE preflight on the actual SSE endpoint", func() { + sseHandler := handler.AsSSE("/sysdig-mcp-server", remoteSecurity(verifier)) + req := httptest.NewRequest(http.MethodOptions, "/sysdig-mcp-server/sse", nil) + req.Header.Set("Origin", allowedOrigin) + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + recorder := httptest.NewRecorder() + sseHandler.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusNoContent)) + Expect(recorder.Header().Get("Access-Control-Allow-Origin")).To(Equal(allowedOrigin)) + Expect(recorder.Header().Get("Access-Control-Max-Age")).To(Equal("600")) + Expect(verifier.calls).To(BeZero()) + }) - // Call tools/list directly without initialize — should work in stateless mode - resp := statelessClient.ListTools(ctx, nil) + It("serves stateless calls without initialization", func(ctx SpecContext) { + statelessClient := NewHTTPTestClient(handler.AsStreamableHTTP("/", true, remoteSecurity(verifier))) + handler.RegisterTools(&dummyTool{name: "tool1"}) + mockClient.EXPECT().GetMyPermissionsWithResponse(gomock.Any()).Return(&sysdig.GetMyPermissionsResponse{ + HTTPResponse: &http.Response{StatusCode: http.StatusOK}, + JSON200: &sysdig.UserPermissions{Permissions: []string{}}, + }, nil) + + resp := statelessClient.ListTools(ctx, authorizationHeaders()) + defer func() { _ = resp.Body.Close() }() Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(resp.Header.Get("Mcp-Session-Id")).To(BeEmpty()) - }, NodeTimeout(time.Second*5)) + }, NodeTimeout(5*time.Second)) }) Context("Stdio", func() { - It("ServeStdio should return when context is cancelled", func(ctx SpecContext) { - c, cancel := context.WithCancel(ctx) + It("returns when the context is cancelled", func(ctx SpecContext) { + cancelled, cancel := context.WithCancel(ctx) cancel() + reader, writer := io.Pipe() + defer func() { _ = writer.Close() }() - r, w := io.Pipe() - defer func() { _ = w.Close() }() - - err := handler.ServeStdio(c, r, io.Discard) - // ServeStdio typically returns when the context is canceled or IO stream ends. - // We just want to ensure it doesn't block indefinitely and exits. - if err != nil { - Expect(err).To(HaveOccurred()) - } - }, NodeTimeout(time.Second*5)) + _ = handler.ServeStdio(cancelled, reader, io.Discard) + }, NodeTimeout(5*time.Second)) }) }) -// Helpers - func initializeInProcessClient(handler *localmcp.Handler) *client.Client { c, err := handler.ServeInProcessClient() Expect(err).NotTo(HaveOccurred()) @@ -237,13 +371,10 @@ type HTTPTestClient struct { } func NewHTTPTestClient(handler http.Handler) *HTTPTestClient { - return &HTTPTestClient{ - handler: handler, - } + return &HTTPTestClient{handler: handler} } -// RPC sends a JSON-RPC 2.0 request -func (c *HTTPTestClient) RPC(ctx context.Context, method string, params any, headers map[string]string) *http.Response { +func (c *HTTPTestClient) RPC(ctx context.Context, method string, params any, headers http.Header) *http.Response { payload := map[string]any{ "jsonrpc": "2.0", "id": 1, @@ -255,15 +386,16 @@ func (c *HTTPTestClient) RPC(ctx context.Context, method string, params any, hea body, err := json.Marshal(payload) Expect(err).NotTo(HaveOccurred()) - - req, err := http.NewRequestWithContext(ctx, "POST", "/", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/", bytes.NewReader(body)) Expect(err).NotTo(HaveOccurred()) req.Header.Set("Content-Type", "application/json") if c.sessionID != "" { req.Header.Set("Mcp-Session-Id", c.sessionID) } - for k, v := range headers { - req.Header.Set(k, v) + for key, values := range headers { + for _, value := range values { + req.Header.Add(key, value) + } } recorder := httptest.NewRecorder() @@ -271,7 +403,7 @@ func (c *HTTPTestClient) RPC(ctx context.Context, method string, params any, hea return recorder.Result() } -func (c *HTTPTestClient) Initialize(ctx context.Context) { +func (c *HTTPTestClient) Initialize(ctx context.Context, headers http.Header) { params := mcp.InitializeParams{ ProtocolVersion: "2024-11-05", ClientInfo: mcp.Implementation{ @@ -281,13 +413,12 @@ func (c *HTTPTestClient) Initialize(ctx context.Context) { Capabilities: mcp.ClientCapabilities{}, } - resp := c.RPC(ctx, "initialize", params, nil) + resp := c.RPC(ctx, "initialize", params, headers) defer func() { _ = resp.Body.Close() }() - Expect(resp.StatusCode).To(Equal(http.StatusOK)) c.sessionID = resp.Header.Get("Mcp-Session-Id") } -func (c *HTTPTestClient) ListTools(ctx context.Context, headers map[string]string) *http.Response { +func (c *HTTPTestClient) ListTools(ctx context.Context, headers http.Header) *http.Response { return c.RPC(ctx, "tools/list", nil, headers) } diff --git a/internal/infra/mcp/remote_security.go b/internal/infra/mcp/remote_security.go new file mode 100644 index 0000000..7e9b1f7 --- /dev/null +++ b/internal/infra/mcp/remote_security.go @@ -0,0 +1,168 @@ +package mcp + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/mark3labs/mcp-go/server" + infraauth "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/auth" +) + +type RemoteSecurity struct { + verifier infraauth.TokenVerifier + allowedOrigins map[string]struct{} + corsAllowedOrigins []string + metadata server.ProtectedResourceMetadataConfig + metadataURL string +} + +func NewRemoteSecurity( + verifier infraauth.TokenVerifier, + resourceURL string, + authorizationServer string, + requiredScopes []string, + allowedOrigins []string, +) RemoteSecurity { + origins := make(map[string]struct{}, len(allowedOrigins)) + corsOrigins := make([]string, 0, len(allowedOrigins)) + for _, origin := range allowedOrigins { + normalized := normalizeOrigin(origin) + if _, exists := origins[normalized]; exists { + continue + } + origins[normalized] = struct{}{} + corsOrigins = append(corsOrigins, normalized) + } + + metadata := server.ProtectedResourceMetadataConfig{ + Resource: resourceURL, + AuthorizationServers: []string{authorizationServer}, + ScopesSupported: requiredScopes, + BearerMethodsSupported: []string{"header"}, + ResourceName: "Sysdig MCP Server", + } + + return RemoteSecurity{ + verifier: verifier, + allowedOrigins: origins, + corsAllowedOrigins: corsOrigins, + metadata: metadata, + metadataURL: protectedResourceMetadataURL(resourceURL), + } +} + +func (s RemoteSecurity) protect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin, hasOrigin, ok := requestOrigin(r.Header.Values("Origin")) + if !ok { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + if hasOrigin { + if _, allowed := s.allowedOrigins[origin]; !allowed { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + if isCORSPreflight(r) { + next.ServeHTTP(w, r) + return + } + } + + rawToken, ok := bearerToken(r.Header.Values("Authorization")) + if !ok { + s.writeUnauthorized(w, "") + return + } + + if err := s.verifier.Verify(r.Context(), rawToken); err != nil { + if errors.Is(err, infraauth.ErrInsufficientScope) { + s.writeInsufficientScope(w) + return + } + s.writeUnauthorized(w, "invalid_token") + return + } + + next.ServeHTTP(w, r) + }) +} + +func (s RemoteSecurity) corsOrigins() []string { + return append([]string(nil), s.corsAllowedOrigins...) +} + +func (s RemoteSecurity) writeInsufficientScope(w http.ResponseWriter) { + challenge := fmt.Sprintf( + `Bearer resource_metadata=%q, error="insufficient_scope", scope=%q`, + s.metadataURL, + strings.Join(s.metadata.ScopesSupported, " "), + ) + w.Header().Set("WWW-Authenticate", challenge) + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) +} + +func (s RemoteSecurity) mountMetadata(mux *http.ServeMux) { + mux.Handle(server.ProtectedResourceMetadataPath(s.metadata.Resource), server.NewProtectedResourceMetadataHandler(s.metadata)) +} + +func (s RemoteSecurity) writeUnauthorized(w http.ResponseWriter, authError string) { + challenge := fmt.Sprintf(`Bearer resource_metadata=%q`, s.metadataURL) + if authError != "" { + challenge += fmt.Sprintf(`, error=%q`, authError) + } + w.Header().Set("WWW-Authenticate", challenge) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) +} + +func requestOrigin(values []string) (origin string, present bool, ok bool) { + if len(values) == 0 { + return "", false, true + } + if len(values) != 1 || strings.TrimSpace(values[0]) == "" { + return "", true, false + } + return normalizeOrigin(values[0]), true, true +} + +func normalizeOrigin(origin string) string { + u, err := url.Parse(origin) + if err != nil || !u.IsAbs() || u.Host == "" { + return origin + } + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + return u.String() +} + +func isCORSPreflight(r *http.Request) bool { + return r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" +} + +func bearerToken(values []string) (string, bool) { + if len(values) != 1 { + return "", false + } + + parts := strings.Fields(values[0]) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" { + return "", false + } + + return parts[1], true +} + +func protectedResourceMetadataURL(resource string) string { + u, err := url.Parse(resource) + if err != nil { + return "" + } + u.Path = server.ProtectedResourceMetadataPath(resource) + u.RawPath = "" + u.RawQuery = "" + u.Fragment = "" + return u.String() +} diff --git a/internal/infra/sysdig/client.go b/internal/infra/sysdig/client.go index c7d802a..602392f 100644 --- a/internal/infra/sysdig/client.go +++ b/internal/infra/sysdig/client.go @@ -2,46 +2,32 @@ package sysdig import ( "context" - "errors" "fmt" "net/http" "net/url" + "strings" ) -type contextKey string - -const ( - contextKeyToken contextKey = "sysdigApiToken" - contextKeyHost contextKey = "sysdigApiHost" -) - -func WrapContextWithToken(ctx context.Context, token string) context.Context { - return context.WithValue(ctx, contextKeyToken, token) -} - -func GetTokenFromContext(ctx context.Context) string { - return ctx.Value(contextKeyToken).(string) -} - -func WrapContextWithHost(ctx context.Context, host string) context.Context { - return context.WithValue(ctx, contextKeyHost, host) -} - -func GetHostFromContext(ctx context.Context) string { - return ctx.Value(contextKeyHost).(string) -} - func updateReqWithHostURL(req *http.Request, host string) error { u, err := url.Parse(host) if err != nil { - // If it's just a hostname without scheme, try prepending https:// - u, err = url.Parse("https://" + host) - if err != nil { - return err - } + return err + } + if !u.IsAbs() || u.Host == "" { + return fmt.Errorf("Sysdig API host must be an absolute URL") + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("Sysdig API host must not contain user information, a query string, or a fragment") } + req.URL.Scheme = u.Scheme req.URL.Host = u.Host + + basePath := strings.TrimSuffix(u.Path, "/") + if basePath != "" { + req.URL.Path = basePath + "/" + strings.TrimPrefix(req.URL.Path, "/") + req.URL.RawPath = "" + } return nil } @@ -55,32 +41,6 @@ func WithFixedHostAndToken(host, apiToken string) RequestEditorFn { } } -func WithHostAndTokenFromContext() RequestEditorFn { - return func(ctx context.Context, req *http.Request) error { - if host, ok := ctx.Value(contextKeyHost).(string); ok && host != "" { - if err := updateReqWithHostURL(req, host); err != nil { - return err - } - } - if token, ok := ctx.Value(contextKeyToken).(string); ok && token != "" { - req.Header.Set("Authorization", "Bearer "+token) - return nil - } - return errors.New("authorization token not present in context") - } -} - -func WithFallbackAuthentication(auths ...RequestEditorFn) RequestEditorFn { - return func(ctx context.Context, req *http.Request) error { - for _, auth := range auths { - if err := auth(ctx, req); err == nil { - return nil - } - } - return errors.New("unable to authenticate with any method") - } -} - func WithVersion(version string) RequestEditorFn { return func(ctx context.Context, req *http.Request) error { req.Header.Set("User-Agent", fmt.Sprintf("sysdig-mcp-server/%s", version)) diff --git a/internal/infra/sysdig/client_permissions_integration_test.go b/internal/infra/sysdig/client_permissions_integration_test.go index 704cd84..8837c2c 100644 --- a/internal/infra/sysdig/client_permissions_integration_test.go +++ b/internal/infra/sysdig/client_permissions_integration_test.go @@ -18,6 +18,9 @@ var _ = Describe("Sysdig Permissions Client", func() { BeforeEach(func() { sysdigURL = os.Getenv("SYSDIG_MCP_API_HOST") sysdigToken = os.Getenv("SYSDIG_MCP_API_TOKEN") + if sysdigURL == "" || sysdigToken == "" { + Skip("requires SYSDIG_MCP_API_HOST and SYSDIG_MCP_API_TOKEN") + } }) Context("when fetching user permissions", func() { @@ -33,33 +36,4 @@ var _ = Describe("Sysdig Permissions Client", func() { Expect(resp.JSON200.Permissions).ToNot(BeEmpty()) }) }) - - When("a token from context is used", func() { - It("loads the token from the context", func(ctx context.Context) { - var err error - client, err = sysdig.NewSysdigClient(sysdig.WithHostAndTokenFromContext()) - Expect(err).ToNot(HaveOccurred()) - - ctx = sysdig.WrapContextWithHost(ctx, sysdigURL) - ctx = sysdig.WrapContextWithToken(ctx, sysdigToken) - - resp, err := client.GetMyPermissionsWithResponse(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.HTTPResponse.StatusCode).To(Equal(http.StatusOK)) - Expect(resp.JSON200).ToNot(BeNil()) - Expect(resp.JSON200.Permissions).ToNot(BeEmpty()) - }) - - When("the token is not in the context", func() { - It("fails to retrieve the permissions", func(ctx context.Context) { - var err error - client, err = sysdig.NewSysdigClient(sysdig.WithHostAndTokenFromContext()) - Expect(err).ToNot(HaveOccurred()) - - resp, err := client.GetMyPermissionsWithResponse(ctx) - Expect(err).To(MatchError("authorization token not present in context")) - Expect(resp).To(BeNil()) - }) - }) - }) }) diff --git a/internal/infra/sysdig/client_test.go b/internal/infra/sysdig/client_test.go index f40124e..7e0e7d3 100644 --- a/internal/infra/sysdig/client_test.go +++ b/internal/infra/sysdig/client_test.go @@ -67,30 +67,6 @@ var _ = Describe("Client TLS", func() { }) }) -var _ = Describe("Context helpers", func() { - It("roundtrips token through context", func() { - ctx := sysdig.WrapContextWithToken(context.Background(), "my-token") - Expect(sysdig.GetTokenFromContext(ctx)).To(Equal("my-token")) - }) - - It("roundtrips host through context", func() { - ctx := sysdig.WrapContextWithHost(context.Background(), "https://example.com") - Expect(sysdig.GetHostFromContext(ctx)).To(Equal("https://example.com")) - }) - - It("panics when token is missing from context", func() { - Expect(func() { - sysdig.GetTokenFromContext(context.Background()) - }).To(Panic()) - }) - - It("panics when host is missing from context", func() { - Expect(func() { - sysdig.GetHostFromContext(context.Background()) - }).To(Panic()) - }) -}) - var _ = Describe("Client authentication", func() { var ts *httptest.Server var lastHeaders http.Header @@ -105,74 +81,60 @@ var _ = Describe("Client authentication", func() { ts.Close() }) - Describe("WithHostAndTokenFromContext", func() { - It("authenticates using context values", func() { - client, err := sysdig.NewSysdigClient(sysdig.WithHostAndTokenFromContext()) - Expect(err).NotTo(HaveOccurred()) - - ctx := sysdig.WrapContextWithHost(context.Background(), ts.URL) - ctx = sysdig.WrapContextWithToken(ctx, "ctx-token") + It("always sends the configured server-side token", func() { + client, err := sysdig.NewSysdigClient( + sysdig.WithFixedHostAndToken(ts.URL, "server-token"), + ) + Expect(err).NotTo(HaveOccurred()) - resp, err := client.GetMyPermissionsWithResponse(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.HTTPResponse.StatusCode).To(Equal(http.StatusOK)) - Expect(lastHeaders.Get("Authorization")).To(Equal("Bearer ctx-token")) - }) + resp, err := client.GetMyPermissionsWithResponse(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.HTTPResponse.StatusCode).To(Equal(http.StatusOK)) + Expect(lastHeaders.Get("Authorization")).To(Equal("Bearer server-token")) + }) - It("fails when token is missing from context", func() { - client, err := sysdig.NewSysdigClient(sysdig.WithHostAndTokenFromContext()) - Expect(err).NotTo(HaveOccurred()) + It("preserves a configured reverse-proxy path prefix", func() { + var requestedPath string + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath = r.URL.Path + if requestedPath != "/sysdig-proxy/api/users/me/permissions" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"permissions":[]}`)) + })) + defer proxy.Close() - ctx := sysdig.WrapContextWithHost(context.Background(), ts.URL) + client, err := sysdig.NewSysdigClient( + sysdig.WithFixedHostAndToken(proxy.URL+"/sysdig-proxy", "server-token"), + ) + Expect(err).NotTo(HaveOccurred()) - _, err = client.GetMyPermissionsWithResponse(ctx) - Expect(err).To(MatchError(ContainSubstring("authorization token not present"))) - }) + resp, err := client.GetMyPermissionsWithResponse(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.HTTPResponse.StatusCode).To(Equal(http.StatusOK)) + Expect(requestedPath).To(Equal("/sysdig-proxy/api/users/me/permissions")) }) - Describe("WithFallbackAuthentication", func() { - It("uses first auth when it succeeds", func() { - client, err := sysdig.NewSysdigClient( - sysdig.WithFallbackAuthentication( - sysdig.WithFixedHostAndToken(ts.URL, "primary-token"), - sysdig.WithFixedHostAndToken(ts.URL, "fallback-token"), - ), - ) - Expect(err).NotTo(HaveOccurred()) - - resp, err := client.GetMyPermissionsWithResponse(context.Background()) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.HTTPResponse.StatusCode).To(Equal(http.StatusOK)) - Expect(lastHeaders.Get("Authorization")).To(Equal("Bearer primary-token")) - }) - - It("falls back to second auth when first fails", func() { - client, err := sysdig.NewSysdigClient( - sysdig.WithFallbackAuthentication( - sysdig.WithHostAndTokenFromContext(), - sysdig.WithFixedHostAndToken(ts.URL, "fallback-token"), - ), - ) - Expect(err).NotTo(HaveOccurred()) + It("rejects a non-absolute configured host", func() { + client, err := sysdig.NewSysdigClient( + sysdig.WithFixedHostAndToken("app.example.com", "server-token"), + ) + Expect(err).NotTo(HaveOccurred()) - resp, err := client.GetMyPermissionsWithResponse(context.Background()) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.HTTPResponse.StatusCode).To(Equal(http.StatusOK)) - Expect(lastHeaders.Get("Authorization")).To(Equal("Bearer fallback-token")) - }) + _, err = client.GetMyPermissionsWithResponse(context.Background()) + Expect(err).To(MatchError(ContainSubstring("absolute URL"))) + }) - It("fails when all auth methods fail", func() { - client, err := sysdig.NewSysdigClient( - sysdig.WithFallbackAuthentication( - sysdig.WithHostAndTokenFromContext(), - sysdig.WithHostAndTokenFromContext(), - ), - ) - Expect(err).NotTo(HaveOccurred()) + It("rejects configured host query strings", func() { + client, err := sysdig.NewSysdigClient( + sysdig.WithFixedHostAndToken(ts.URL+"?tenant=one", "server-token"), + ) + Expect(err).NotTo(HaveOccurred()) - _, err = client.GetMyPermissionsWithResponse(context.Background()) - Expect(err).To(MatchError(ContainSubstring("unable to authenticate"))) - }) + _, err = client.GetMyPermissionsWithResponse(context.Background()) + Expect(err).To(MatchError(ContainSubstring("query string"))) }) Describe("WithVersion", func() { diff --git a/package.nix b/package.nix index f15a495..254d017 100644 --- a/package.nix +++ b/package.nix @@ -1,10 +1,10 @@ { buildGoLatestModule, versionCheckHook }: buildGoLatestModule (finalAttrs: { pname = "sysdig-mcp-server"; - version = "3.0.2"; + version = "4.0.0"; src = ./.; # This hash is automatically re-calculated with `just rehash-package-nix`. This is automatically called as well by `just update`. - vendorHash = "sha256-byCVbElMdYTQIbuKVFLTcl98rOGSjvxSbrv1m5CyN5k="; + vendorHash = "sha256-3CqE9xLjGc6fsQ2Cjd5SaCZjyO2LmdvEOg8NTAnMJqo="; subPackages = [ "cmd/server"