Skip to content

feat(auth)!: establish secure remote transport boundaries - #94

Open
riccardomenegazzo wants to merge 19 commits into
sysdiglabs:mainfrom
riccardomenegazzo:feat/secure-remote-trust-boundary
Open

riccardomenegazzo wants to merge 19 commits into
sysdiglabs:mainfrom
riccardomenegazzo:feat/secure-remote-trust-boundary

Conversation

@riccardomenegazzo

Copy link
Copy Markdown

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

  • validate issuer, JWKS, audience, signing algorithm, expiry, and scopes for remote OAuth tokens
  • publish RFC 9728 protected-resource metadata and standards-based bearer challenges
  • enforce an exact Origin allowlist and exact-origin CORS
  • keep Sysdig host/token server-side and remove request/context overrides
  • remove request-header/token logging and add HTTP timeouts
  • document the breaking migration
  • prove end-to-end that inbound MCP tokens are never forwarded upstream

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

Copilot AI lite review requested due to automatic review settings September 3, 2026 11:05
@riccardomenegazzo
riccardomenegazzo requested a review from a team as a code owner September 3, 2026 11:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 ReadHeaderTimeout and IdleTimeout, but it still has no overall ReadTimeout. Adding a ReadTimeout helps 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.

Comment thread cmd/server/main.go
Comment on lines +163 to +168
server := &http.Server{
Addr: addr,
Handler: handler.AsStreamableHTTP(cfg.MountPath, cfg.Stateless, setupRemoteSecurity(cfg)),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
}
Comment thread internal/config/config.go Outdated
Comment on lines +119 to +131
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
}
Comment thread internal/infra/mcp/mcp_handler.go Outdated
sseServer := server.NewSSEServer(h.server, server.WithStaticBasePath(mountPath))
mux.Handle(mountPath, authMiddleware(sseServer))
security.mountMetadata(mux)
mux.Handle(mountPath, security.protect(sseServer))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/infra/mcp/remote_security.go Outdated
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/server/main.go
server := &http.Server{
Addr: addr,
Handler: handler.AsStreamableHTTP(cfg.MountPath, cfg.Stateless, setupRemoteSecurity(cfg)),
ReadHeaderTimeout: 10 * time.Second,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/config/config.go
return slices.Clone(fallback)
}

return strings.FieldsFunc(value, func(r rune) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/infra/auth/token_verifier.go Outdated
signingAlgorithms []string,
requiredScopes []string,
) *JWTVerifier {
ctx = oidc.ClientContext(ctx, &http.Client{Timeout: jwksRequestTimeout})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/config/config.go Outdated
})
}

func validateAbsoluteURL(name, rawURL string) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread package.nix Outdated
@@ -4,7 +4,7 @@ buildGoLatestModule (finalAttrs: {
version = "3.0.2";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/infra/mcp/remote_security.go Outdated
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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/config/config.go Outdated
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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/infra/mcp/remote_security.go Outdated
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 != "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/config/config.go Outdated
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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/config/config.go
return nil
}

func validateOrigin(origin string) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/infra/mcp/remote_security.go Outdated

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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@riccardomenegazzo riccardomenegazzo changed the title feat(auth): establish secure remote transport boundaries feat(auth)!: establish secure remote transport boundaries Sep 4, 2026
@riccardomenegazzo

Copy link
Copy Markdown
Author

@tembleking Thanks a lot for the thorough review, I’ve addressed the findings in the latest commit.
In particular, I fixed the SSE routing end-to-end, moved CORS handling onto mcp-go’s native primitives, tightened resource/mount-path and URL validation, normalized Origin handling, preserved Sysdig API path prefixes, added ReadTimeout coverage, and separated JWKS TLS policy from the Sysdig API TLS setting.
I also added regression tests for the reported cases and bumped the version to 4.0.0 to reflect the breaking change.
The updated CI workflow is currently waiting for approval to run on the fork PR. Once it runs, I’ll address anything else that comes up.
Thanks again! The review was very helpful in tightening both the security boundary and the operational behavior.

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.
@tembleking
tembleking force-pushed the feat/secure-remote-trust-boundary branch from 6643c87 to f851af0 Compare September 24, 2026 14:56

@tembleking tembleking left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/infra/mcp/remote_security.go Outdated
return
}

if err := s.verifier.Verify(r.Context(), rawToken); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/infra/auth/token_verifier.go Outdated
}

ctx = oidc.ClientContext(ctx, httpClient)
keySet := oidc.NewRemoteKeySet(ctx, jwksURL)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, "")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +125 to +131
server.WithCORSAllowedHeaders(
"Authorization",
"Content-Type",
"Last-Event-ID",
server.HeaderKeyProtocolVersion,
server.HeaderKeySessionID,
),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
),

Comment thread internal/infra/auth/token_verifier.go Outdated
}

var claims struct {
Scope string `json:"scope"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
Scope string `json:"scope"`
Scope json.RawMessage `json:"scope"`

and then parse both claims with the same string-or-array helper.

Comment thread internal/config/config.go
if c.APIToken == "" {
return fmt.Errorf("required configuration missing: SYSDIG_MCP_API_TOKEN")
}
apiHost, err := parseAbsoluteURL("SYSDIG_MCP_API_HOST", c.APIHost)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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


Loading

To resolve this comment:

✨ Commit fix suggestion

Suggested change
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
  1. Define an explicit allowlist of trusted origins, such as https://app.example.com and any development origin required by the application. Do not accept arbitrary origins, wildcards, substring matches, or the value null.

  2. Add an http.Handler middleware around the mcp-go handler, such as server.NewStreamableHTTPServer(...).ServeHTTP, before requests reach MCP authentication, session, or tool processing.

  3. Read and validate the request origin with origin := r.Header.Get("Origin"). Parse it with url.Parse and allow the request only when its scheme and host exactly match an entry in the allowlist; include the port in the comparison when applicable.

  4. Reject an invalid origin before calling the next handler, for example by returning HTTP 403 Forbidden and stopping request processing. Treat malformed origins, null, and origins not in the allowlist as invalid.

  5. Decide explicitly how to handle a missing Origin header. Allow it only for non-browser clients if the endpoint already requires authentication and has appropriate CSRF protections; otherwise reject it with HTTP 403 Forbidden.

  6. Mount the protected MCP handler through the middleware, such as http.Handle("/mcp", validateOrigin(mcpServer)), rather than exposing the mcp-go handler directly.

  7. Verify that requests with an allowed Origin reach the MCP handler, while requests with a malicious origin, Origin: null, a malformed origin, or a missing origin where it is required receive 403 Forbidden and 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants