feat(auth)!: establish secure remote transport boundaries - #94
riccardomenegazzo wants to merge 19 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
A couple of security/operational hardening gaps remain (URL validation and remote HTTP server timeout coverage) that should be addressed to fully meet the intended boundary protections.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR hardens the remote (streamable-http/sse) transports by separating MCP client authentication (OAuth JWT access tokens) from Sysdig upstream credentials, preventing request headers from selecting upstream host/token, and adding standards-based protected-resource metadata and CORS/origin enforcement.
Changes:
- Introduces remote OAuth JWT verification (issuer/audience/JWKS/signing alg/expiry + optional scopes) and publishes RFC 9728 protected-resource metadata with proper bearer challenges.
- Enforces exact Origin allowlisting for browser-based requests and removes request-derived Sysdig host/token overrides to preserve trust boundaries.
- Updates configuration validation and documentation for the breaking remote-security migration (Go 1.27+, new env vars, updated client guidance), plus adjusts tests accordingly.
File summaries
| File | Description |
|---|---|
| README.md | Documents the new remote auth boundary, required OAuth settings, origin allowlist behavior, and migration notes. |
| package.nix | Updates vendored dependency hash after Go module changes. |
| internal/infra/sysdig/client.go | Removes context-based Sysdig auth; enforces fixed server-side host/token usage and requires an absolute host URL. |
| internal/infra/sysdig/client_test.go | Updates unit tests to reflect fixed server-side Sysdig auth and absolute-host validation. |
| internal/infra/sysdig/client_permissions_integration_test.go | Removes context-token integration scenarios; adds env guard/skip when Sysdig creds aren’t provided. |
| internal/infra/mcp/remote_security.go | Adds remote transport security middleware: Origin allowlist + bearer token extraction + verifier integration + metadata/challenges. |
| internal/infra/mcp/mcp_handler.go | Wires RemoteSecurity into SSE/streamable-http handlers and mounts protected-resource metadata. |
| internal/infra/mcp/mcp_handler_test.go | Adds end-to-end tests for token verification, insufficient scope behavior, exact-origin CORS, and “never forward MCP token upstream”. |
| internal/infra/auth/token_verifier.go | Introduces TokenVerifier and JWTVerifier backed by remote JWKS (go-oidc) with optional scope enforcement. |
| internal/infra/auth/token_verifier_test.go | Adds JWT verifier tests (issuer/audience/expiry/scope/alg). |
| internal/config/config.go | Adds remote OAuth and origin configuration fields; validates transports, absolute URLs, allowed signing algs, and origin formatting. |
| internal/config/config_test.go | Updates tests to cover secured remote configs, URL/origin validation, signing alg rules, and env loading of new settings. |
| go.sum | Adds checksums for new OIDC/JWT-related dependencies. |
| go.mod | Adds OIDC/JWT dependencies and updates indirect oauth2 dependency. |
| docs/TROUBLESHOOTING.md | Updates troubleshooting guidance for new required config and remote 401/403 behavior. |
| cmd/server/main.go | Removes fallback/context Sysdig auth, wires remote security setup, and introduces http.Server timeouts for remote transports. |
| AGENTS.md | Updates handbook to reflect the new auth boundary, dependencies, and architecture notes for remote security. |
Review details
Suppressed comments (1)
cmd/server/main.go:179
- The SSE server sets
ReadHeaderTimeoutandIdleTimeout, but it still has no overallReadTimeout. Adding aReadTimeouthelps mitigate slow request bodies and keeps timeout behavior consistent across remote transports (SSE requests typically have no body, so this mainly adds defense-in-depth).
server := &http.Server{
Addr: addr,
Handler: handler.AsSSE(cfg.MountPath, setupRemoteSecurity(cfg)),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
- Files reviewed: 16/17 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| server := &http.Server{ | ||
| Addr: addr, | ||
| Handler: handler.AsStreamableHTTP(cfg.MountPath, cfg.Stateless, setupRemoteSecurity(cfg)), | ||
| ReadHeaderTimeout: 10 * time.Second, | ||
| IdleTimeout: 2 * time.Minute, | ||
| } |
| func validateAbsoluteURL(name, rawURL string) error { | ||
| u, err := url.Parse(rawURL) | ||
| if err != nil || !u.IsAbs() || u.Host == "" { | ||
| return fmt.Errorf("%s must be an absolute URL", name) | ||
| } | ||
| if u.User != nil || u.Fragment != "" { | ||
| return fmt.Errorf("%s must not contain user information or a fragment", name) | ||
| } | ||
| if u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHostname(u.Hostname())) { | ||
| return fmt.Errorf("%s must use https (http is allowed only for loopback development)", name) | ||
| } | ||
| return nil | ||
| } |
| sseServer := server.NewSSEServer(h.server, server.WithStaticBasePath(mountPath)) | ||
| mux.Handle(mountPath, authMiddleware(sseServer)) | ||
| security.mountMetadata(mux) | ||
| mux.Handle(mountPath, security.protect(sseServer)) |
There was a problem hiding this comment.
AsSSE mounts the SSE server at an exact-match ServeMux pattern that mcp-go's SSEServer never actually answers at. With the default mount path, GET /sysdig-mcp-server, GET .../sse, and POST .../message all return 404. The sse transport is currently non-functional end to end, which means the whole OAuth/CORS boundary this PR adds is dead code for it. Worth fixing the routing or dropping sse support until it is.
| http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) | ||
| return | ||
| } | ||
| w.Header().Set("Access-Control-Allow-Origin", origin) |
There was a problem hiding this comment.
Once the routing above is fixed and requests actually reach SSEServer.handleSSE, that handler unconditionally overwrites Access-Control-Allow-Origin with "*" unless WithSSECORS was passed to server.NewSSEServer. AsSSE never passes it, so the exact-origin allowlist set here gets undone for the SSE transport.
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| w.Header().Add("Vary", "Origin") | ||
| if origin := r.Header.Get("Origin"); origin != "" { | ||
| if _, allowed := s.allowedOrigins[origin]; !allowed { |
There was a problem hiding this comment.
This is an exact, case-sensitive string match, and validateOrigin in config.go never normalizes host case. An entry like SYSDIG_MCP_ALLOWED_ORIGINS=https://Client.Example.com passes validation but will never match the lowercased Origin header a real browser sends, causing a silent, permanent 403 for a correctly configured origin. Consider normalizing both sides (e.g. lowercase the host) before comparing.
| server := &http.Server{ | ||
| Addr: addr, | ||
| Handler: handler.AsStreamableHTTP(cfg.MountPath, cfg.Stateless, setupRemoteSecurity(cfg)), | ||
| ReadHeaderTimeout: 10 * time.Second, |
There was a problem hiding this comment.
Both http.Server instances (here and the sse case below at line 178) set ReadHeaderTimeout and IdleTimeout but never ReadTimeout, so a slow or stalled request body after valid headers can hold the connection open indefinitely. Suggest adding a ReadTimeout alongside the other two.
| return slices.Clone(fallback) | ||
| } | ||
|
|
||
| return strings.FieldsFunc(value, func(r rune) bool { |
There was a problem hiding this comment.
strings.FieldsFunc splits on space/tab/newline/comma but not \r. A SYSDIG_MCP_AUTH_SCOPES value with \r\n (e.g. pasted from a Windows-style env file) leaves a trailing \r on a scope, which will never match a token's clean claim, silently 403-ing every valid token. There is also no format validation on the scope strings themselves. Worth adding \r to the split set and/or trimming each field.
| signingAlgorithms []string, | ||
| requiredScopes []string, | ||
| ) *JWTVerifier { | ||
| ctx = oidc.ClientContext(ctx, &http.Client{Timeout: jwksRequestTimeout}) |
There was a problem hiding this comment.
SYSDIG_MCP_API_SKIP_TLS_VERIFICATION is only wired into the Sysdig API client in main.go, not into this JWKS http.Client. For an on-prem IdP with a self-signed cert, operators following docs/TROUBLESHOOTING.md's generic remedy will still get a permanent 401 (x509: certificate signed by unknown authority) here, making the documented fix a no-op for the JWKS path.
| }) | ||
| } | ||
|
|
||
| func validateAbsoluteURL(name, rawURL string) error { |
There was a problem hiding this comment.
validateAbsoluteURL checks scheme/host/user/fragment but never rejects a query string (unlike validateOrigin below, which does). A SYSDIG_MCP_RESOURCE_URL with a ?query passes validation and then leaks into both the JWT-audience comparison and the public RFC 9728 metadata document, where a canonicalization mismatch with the authorization server would break every token's audience check.
| @@ -4,7 +4,7 @@ buildGoLatestModule (finalAttrs: { | |||
| version = "3.0.2"; | |||
There was a problem hiding this comment.
version stays "3.0.2" even though this PR is a breaking change per its own commit convention (feat(auth)!:). publish.yaml only releases on a version diff against the latest tag, so without a bump this breaking change ships with no release, and a later unrelated patch bump would carry it in silently. The two previous breaking commits (#87, #91) both bumped MAJOR in-commit.
| return fmt.Errorf("Sysdig API host must be an absolute URL") | ||
| } | ||
| req.URL.Scheme = u.Scheme | ||
| req.URL.Host = u.Host |
There was a problem hiding this comment.
updateReqWithHostURL copies only Scheme and Host from the parsed SYSDIG_MCP_API_HOST, silently dropping any path component, and validateAbsoluteURL never warns about a path being present. A reverse-proxied host like https://gateway.example.com/sysdig-proxy passes config validation but every outbound request then drops the /sysdig-proxy prefix and silently hits the wrong path.
| w.Header().Set("Access-Control-Allow-Origin", origin) | ||
| w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate") | ||
|
|
||
| if r.Method == http.MethodOptions { |
There was a problem hiding this comment.
This treats any OPTIONS request carrying an Origin header as a CORS preflight, without checking Access-Control-Request-Method the way mcp-go's own CORSConfig.handlePreflight does. A non-preflight OPTIONS probe with an Origin header gets an unwarranted 204 and skips bearer-token verification entirely instead of the normal 401.
| if c.AuthJWKSURL == "" { | ||
| return fmt.Errorf("required configuration missing: SYSDIG_MCP_AUTH_JWKS_URL") | ||
| } | ||
| if err := validateAbsoluteURL("SYSDIG_MCP_RESOURCE_URL", c.ResourceURL); err != nil { |
There was a problem hiding this comment.
Nothing here cross-checks SYSDIG_MCP_RESOURCE_URL's path against SYSDIG_MCP_MOUNT_PATH, so the RFC 9728 / JWT-audience identity can silently diverge from the path actually served. E.g. MountPath stays the default /sysdig-mcp-server while ResourceURL=https://mcp.example.com (no path): the metadata then advertises the wrong resource, and a token scoped to a different root-level resource from the same authorization server would incorrectly pass the audience check.
| func (s RemoteSecurity) protect(next http.Handler) http.Handler { | ||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| w.Header().Add("Vary", "Origin") | ||
| if origin := r.Header.Get("Origin"); origin != "" { |
There was a problem hiding this comment.
Origin is read with Header.Get (first value only), while Authorization a few lines below is deliberately required to have exactly one value via Header.Values. Worth applying the same strict handling to Origin for consistency, even though browsers won't normally send it duplicated.
| return fmt.Errorf("SYSDIG_MCP_AUTH_SIGNING_ALGS must contain at least one asymmetric signing algorithm") | ||
| } | ||
| for _, algorithm := range c.AuthSigningAlgs { | ||
| if !slices.Contains([]string{"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"}, algorithm) { |
There was a problem hiding this comment.
This hardcodes its own copy of the 10 JOSE asymmetric algorithm names instead of referencing exported constants from go-oidc (the dependency this PR adds). Two independently maintained copies of a security-relevant allowlist can drift silently if go-oidc adds or deprecates an algorithm.
| return nil | ||
| } | ||
|
|
||
| func validateOrigin(origin string) error { |
There was a problem hiding this comment.
validateOrigin duplicates most of validateAbsoluteURL's parse/IsAbs/Host/scheme logic, and the two have already drifted (only this one checks Path/RawQuery). Worth factoring out the shared checks so a future policy change (e.g. tightening the scheme rule) only needs to happen once.
|
|
||
| if r.Method == http.MethodOptions { | ||
| w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") | ||
| w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Last-Event-ID, Mcp-Protocol-Version, Mcp-Session-Id") |
There was a problem hiding this comment.
The hand-rolled preflight response never sets Access-Control-Max-Age, unlike mcp-go's CORSConfig, so browsers can never cache the preflight result. Every browser-originated tools/call pays a fresh OPTIONS round trip first, roughly doubling latency compared to the library's cacheable default.
|
@tembleking Thanks a lot for the thorough review, I’ve addressed the findings in the latest commit. |
Remote MCP access tokens and Sysdig API credentials cross different trust boundaries. Treating them as interchangeable exposed the configured upstream and token to client control.
Fix SSE routing and CORS integration, tighten remote URL and scope validation, preserve Sysdig API path prefixes, add independent JWKS TLS policy, harden HTTP timeouts, and bump the breaking release to v4.0.0.
6643c87 to
f851af0
Compare
tembleking
left a comment
There was a problem hiding this comment.
Rebased onto main to save you the trouble. go.mod/go.sum now take main's mcp-go v1.1.1 plus go-oidc/go-jose, package.nix keeps your 4.0.0 with a fresh vendorHash. just check passes, including the integration suite against the real API. Please pull before pushing again.
| return | ||
| } | ||
|
|
||
| if err := s.verifier.Verify(r.Context(), rawToken); err != nil { |
There was a problem hiding this comment.
The token gets verified here, but its identity is dropped right after. Session IDs aren't bound to any principal, so another user with a valid token who learns a session ID (SSE puts it in the URL, which ends up in proxy logs) can cancel, inject into, read, or DELETE that session. Could Verify return sub/iss, get stored when the session is created, and be checked on every later request for that session? The MCP security guidance calls this out explicitly.
| } | ||
|
|
||
| ctx = oidc.ClientContext(ctx, httpClient) | ||
| keySet := oidc.NewRemoteKeySet(ctx, jwksURL) |
There was a problem hiding this comment.
oidc.NewRemoteKeySet has no refresh throttling or key expiry. Any unauthenticated request with a random kid triggers a JWKS fetch, so a simple loop can burn the IdP rate limit and break real logins. On top of that, a key the IdP removes stays trusted until the next miss. Wrapping the KeySet with a minimum refresh interval and a max key age would cover both cases.
|
|
||
| rawToken, ok := bearerToken(r.Header.Values("Authorization")) | ||
| if !ok { | ||
| s.writeUnauthorized(w, "") |
There was a problem hiding this comment.
These 401/403 responses are written before mcp-go's CORS layer runs, so they carry no Access-Control-Allow-Origin or Access-Control-Expose-Headers. Browsers block the response and the client never sees WWW-Authenticate, which means OAuth discovery can't start from a web client, and the WWW-Authenticate entry in WithCORSExposedHeaders has no effect. Suggestion: set the CORS headers for allowlisted origins inside protect() before writing the error, or wrap CORS as the outermost handler.
| server.WithCORSAllowedHeaders( | ||
| "Authorization", | ||
| "Content-Type", | ||
| "Last-Event-ID", | ||
| server.HeaderKeyProtocolVersion, | ||
| server.HeaderKeySessionID, | ||
| ), |
There was a problem hiding this comment.
Now that main is on mcp-go v1.1.x, protocol 2026-07-28 clients send Mcp-Method and Mcp-Name, and the preflight will reject them with this list.
| server.WithCORSAllowedHeaders( | |
| "Authorization", | |
| "Content-Type", | |
| "Last-Event-ID", | |
| server.HeaderKeyProtocolVersion, | |
| server.HeaderKeySessionID, | |
| ), | |
| server.WithCORSAllowedHeaders( | |
| "Authorization", | |
| "Content-Type", | |
| "Last-Event-ID", | |
| server.HeaderKeyProtocolVersion, | |
| server.HeaderKeySessionID, | |
| mcp.HeaderMethod, | |
| mcp.HeaderName, | |
| ), |
| } | ||
|
|
||
| var claims struct { | ||
| Scope string `json:"scope"` |
There was a problem hiding this comment.
Spring Authorization Server and Duende emit scope as a JSON array by default. Decoding it into a string fails, and the user gets 401 invalid_token even with the right scopes. Treating it like scp fixes that:
| Scope string `json:"scope"` | |
| Scope json.RawMessage `json:"scope"` |
and then parse both claims with the same string-or-array helper.
| if c.APIToken == "" { | ||
| return fmt.Errorf("required configuration missing: SYSDIG_MCP_API_TOKEN") | ||
| } | ||
| apiHost, err := parseAbsoluteURL("SYSDIG_MCP_API_HOST", c.APIHost) |
There was a problem hiding this comment.
parseAbsoluteURL enforces https-or-loopback on SYSDIG_MCP_API_HOST before the stdio early return, so stdio users pointing at an on-prem http://10.0.0.5 can no longer start the server. The PR says stdio is unchanged. I'd keep the https rule for remote transports only, or at least list this as breaking in the release notes.
| // ignore a stale Mcp-Session-Id rather than applying legacy ownership | ||
| // semantics to a modern request. | ||
| if r.Header.Get(mcpprotocol.HeaderProtocolVersion) != mcpprotocol.ProtocolVersion20260728 { | ||
| if sessionID := r.Header.Get(mcpprotocol.HeaderSessionID); sessionID != "" { |
There was a problem hiding this comment.
Semgrep identified an issue in your code:
validateRequestSessionOwner processes the session header without validating the HTTP Origin. A malicious website may use the victim’s browser credentials to send or trigger MCP requests when cross-origin access is allowed.
More details about this
validateRequestSessionOwner reads r.Header.Get(mcpprotocol.HeaderSessionID) before any check of r.Header.Get("Origin"). If an MCP endpoint accepts browser requests with ambient credentials or permissive cross-origin access, a malicious site could send a request such as POST https://mcp.example.com/... with the victim’s session credentials and a stolen or selected Mcp-Session-Id; the request reaches this code, which performs only session-owner checks through validatePrincipalSessionID and does not establish that the request came from an approved origin. The attacker could then trigger an MCP operation or access its response from https://evil.example using the victim’s authorization context. For example, JavaScript hosted by evil.example could issue fetch("https://mcp.example.com/tools/call", {method: "POST", credentials: "include", headers: {"Mcp-Session-Id": sessionId, "Content-Type": "application/json"}, body: toolRequest}); without origin validation, the MCP server has no origin-based barrier before processing the request.
Dataflow graph
flowchart LR
classDef invis fill:white, stroke: none
classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none
subgraph File0["<b>internal/infra/mcp/remote_security.go</b>"]
direction LR
%% Source
subgraph Source
direction LR
v0["<a href=https://github.com/sysdiglabs/sysdig-mcp-server/blob/66085f32516f0f506c5069259b08494a61900175/internal/infra/mcp/remote_security.go#L211 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 211] r</a>"]
end
%% Intermediate
subgraph Traces0[Traces]
direction TB
v2["<a href=https://github.com/sysdiglabs/sysdig-mcp-server/blob/66085f32516f0f506c5069259b08494a61900175/internal/infra/mcp/remote_security.go#L211 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 211] r</a>"]
end
%% Sink
subgraph Sink
direction LR
v1["<a href=https://github.com/sysdiglabs/sysdig-mcp-server/blob/66085f32516f0f506c5069259b08494a61900175/internal/infra/mcp/remote_security.go#L216 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 216] r.Header.Get(mcpprotocol.HeaderSessionID)</a>"]
end
end
%% Class Assignment
Source:::invis
Sink:::invis
Traces0:::invis
File0:::invis
%% Connections
Source --> Traces0
Traces0 --> Sink
To resolve this comment:
✨ Commit fix suggestion
| if sessionID := r.Header.Get(mcpprotocol.HeaderSessionID); sessionID != "" { | |
| origin := r.Header.Get("Origin") | |
| if origin == "" || origin == "null" { | |
| return fmt.Errorf("forbidden request origin") | |
| } | |
| parsedOrigin, err := url.Parse(origin) | |
| if err != nil || | |
| parsedOrigin.Scheme == "" || | |
| parsedOrigin.Host == "" || | |
| parsedOrigin.User != nil || | |
| parsedOrigin.Path != "" || | |
| parsedOrigin.RawQuery != "" || | |
| parsedOrigin.Fragment != "" || | |
| parsedOrigin.Opaque != "" { | |
| return fmt.Errorf("forbidden request origin") | |
| } | |
| if parsedOrigin.Scheme+"://"+parsedOrigin.Host != "https://app.example.com" { | |
| return fmt.Errorf("forbidden request origin") | |
| } | |
| if sessionID := r.Header.Get(mcpprotocol.HeaderSessionID); sessionID != "" { |
View step-by-step instructions
-
Define an explicit allowlist of trusted origins, such as
https://app.example.comand any development origin required by the application. Do not accept arbitrary origins, wildcards, substring matches, or the valuenull. -
Add an
http.Handlermiddleware around the mcp-go handler, such asserver.NewStreamableHTTPServer(...).ServeHTTP, before requests reach MCP authentication, session, or tool processing. -
Read and validate the request origin with
origin := r.Header.Get("Origin"). Parse it withurl.Parseand allow the request only when its scheme and host exactly match an entry in the allowlist; include the port in the comparison when applicable. -
Reject an invalid origin before calling the next handler, for example by returning HTTP
403 Forbiddenand stopping request processing. Treat malformed origins,null, and origins not in the allowlist as invalid. -
Decide explicitly how to handle a missing
Originheader. Allow it only for non-browser clients if the endpoint already requires authentication and has appropriate CSRF protections; otherwise reject it with HTTP403 Forbidden. -
Mount the protected MCP handler through the middleware, such as
http.Handle("/mcp", validateOrigin(mcpServer)), rather than exposing the mcp-go handler directly. -
Verify that requests with an allowed
Originreach the MCP handler, while requests with a malicious origin,Origin: null, a malformed origin, or a missing origin where it is required receive403 Forbiddenand do not execute MCP tools.
💬 Ignore this finding
Leave a nosemgrep comment directly above or at the end of line 216 like so // nosemgrep: go.mcp.no-origin-validation.no-origin-validation
Take care to validate that this is not a true positive finding before ignoring it.
Learn more about ignoring code, files and folders here.
If your PR is blocked and it's a false positive OR it's a true positive but requires major effort to fix (2+ more days, rearchitecturing, etc...)
- Reach out to #ask-security-compliance with your PR link (tagging @team-security-compliance)
Any other question or doubts? Please reach out to #ask-security-compliance
You can view more details about this finding in the Semgrep AppSec Platform.
Why
Remote MCP access tokens and Sysdig API credentials belong to different trust domains. The current remote path treats the caller bearer token as a Sysdig API token and lets request headers select the upstream host. This collapses the MCP and Sysdig trust boundaries.
What
Compatibility
Breaking for remote deployments: new OAuth and Origin settings are required. The stdio authentication flow is unchanged; all modes now require an absolute Sysdig API URL.
Validation
go generate, gofumpt, unit tests, race tests, golangci-lint, govulncheck, flake evaluation, and the full Nix static build all pass.
References