Root cause
SSRF & network boundaries
implementing auth, dynamic tool registry, updating mcp-go library (#121)
How AI contributed: Flawed AI-written codePublished Jul 28, 2026 in
hashicorp/terraform-mcp-server
SSRF / credential exfiltration (CWE-918): TerraformContextMiddleware (pkg/client/middleware.go) accepted a client-supplied TFE_ADDRESS (TerraformAddress) via HTTP header or query parameter (priority: header > query > env) in streamable-HTTP mode; the server then directed its Terraform API requests, carrying the server-side Authorization token, to whatever endpoint the remote client chose, letting an unauthenticated remote client steal the Terraform token and proxy/redirect API requests (fixed in 1.1.0).
Root cause
implementing auth, dynamic tool registry, updating mcp-go library (#121)
Fix
Disable client-supplied TFE_ADDRESS in streamable-http mode (#389)
Fix by Jaylon McShan · no AI marker found
Case facts
@@ -4,7 +4,10 @@ package client import (+ "context"+ "fmt" "net/http"+ "net/textproto" "os" "strings" @@ -120,3 +123,47 @@ func NewSecurityHandler(handler http.Handler, allowedOrigins []string, corsMode logger: logger, } }++// TerraformContextMiddleware adds Terraform-related header values to the request context+// This middleware extracts Terraform configuration from HTTP headers, query parameters,+// or environment variables and adds them to the request context for use by MCP tools+func TerraformContextMiddleware(logger *log.Logger) func(http.Handler) http.Handler {+ return func(next http.Handler) http.Handler {+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+ requiredHeaders := []string{TerraformAddress, TerraformToken, TerraformSkipTLSVerify}+ ctx := r.Context()+ for _, header := range requiredHeaders {+ // Priority order: HTTP header -> Query parameter -> Environment variable+ headerValue := r.Header.Get(textproto.CanonicalMIMEHeaderKey(header))++ if headerValue == "" {+ headerValue = r.URL.Query().Get(header)++ // Explicitly disallow TerraformToken in query parameters for security reasons+ if header == TerraformToken && headerValue != "" {+ logger.Info(fmt.Sprintf("Terraform token was provided in query parameters by client %v, terminating request", r.RemoteAddr))+ http.Error(w, "Terraform token should not be provided in query parameters for security reasons, use the terraform_token header", http.StatusBadRequest)+ return+ }+ }++ if headerValue == "" {+ headerValue = getEnv(header, "")+ }++ // Add to context using the header name as key+ ctx = context.WithValue(ctx, contextKey(header), headerValue)++ // Log the source of the configuration (without exposing sensitive values)+ if header == TerraformToken && headerValue != "" {+ logger.Debug("Terraform token provided via request context")+ } else if header == TerraformAddress && headerValue != "" {+ logger.Debug("Terraform address configured via request context")+ }+ }++ // Call the next handler with the enriched context+ next.ServeHTTP(w, r.WithContext(ctx))+ })+ }+}@@ -167,8 +167,10 @@ func OrganizationAllowlistMiddleware(allowlist []string, logger *log.Logger) fun } if r.Header.Get(TerraformAddress) != "" || r.URL.Query().Get(TerraformAddress) != "" {- logger.Warn("Rejecting request: Terraform address override is not allowed when organization allowlist is configured")- http.Error(w, "Cannot specify Terraform address when organization allowlist is configured", http.StatusForbidden)+ if logger != nil {+ logger.Warn("Rejecting request: Terraform address cannot be specified via header or query parameter")+ }+ http.Error(w, "Cannot specify Terraform address via header or query parameter", http.StatusForbidden) return } @@ -263,24 +265,29 @@ func getTokenFromAuthHeader(r *http.Request) string { // This middleware extracts Terraform configuration from HTTP headers, query parameters, // or environment variables and adds them to the request context for use by MCP tools func TerraformContextMiddleware(logger *log.Logger) func(http.Handler) http.Handler {- // Check at startup if token is configured via env var- tokenFromEnv := utils.GetEnv(TerraformToken, "") != ""- return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {- requiredHeaders := []string{TerraformAddress, TerraformToken, TerraformSkipTLSVerify} ctx := r.Context()- for _, header := range requiredHeaders {- var headerValue string - //If TFE_TOKEN is set via env var, reject TFE_ADDRESS from headers.- // This will help prevent potential attackers from redirecting requests to a bad server- // that could harvest the Authorization header containing the token.- if header == TerraformAddress && tokenFromEnv && r.Header.Get(header) != "" {- logger.Warn("Rejecting Terraform-Address header: cannot override address when token is set via environment variable")- http.Error(w, "Cannot specify Terraform address via header when token is configured server-side", http.StatusForbidden)- return- }+ // TFE_ADDRESS is never sourced from the client in streamable-http mode.+ // Allowing a client to set the address via header or query parameter would+ // let it redirect requests (and the Authorization token) to an arbitrary+ // server. We Reject those attempts and source the address via server-side only.+ if r.Header.Get(TerraformAddress) != "" || r.URL.Query().Get(TerraformAddress) != "" {+ logger.Warn("Rejecting request: Terraform address cannot be specified via header or query parameter")+ http.Error(w, "Cannot specify Terraform address via header or query parameter", http.StatusForbidden)+ return+ }+ terraformAddress := utils.GetEnv(TerraformAddress, DefaultTerraformAddress)+ ctx = context.WithValue(ctx, contextKey(TerraformAddress), terraformAddress)+ if logger != nil {+ logger.Debug("Terraform address configured server-side")+ }++ // The remaining vals may still be sourced from the client.+ clientHeaders := []string{TerraformToken, TerraformSkipTLSVerify}+ for _, header := range clientHeaders {+ var headerValue string // Check standard header first headerValue = r.Header.Get(header)@@ -310,8 +317,6 @@ func TerraformContextMiddleware(logger *log.Logger) func(http.Handler) http.Hand // Log the source of the configuration (without exposing sensitive values) if header == TerraformToken && headerValue != "" { logger.Debug("Terraform token provided via request context")- } else if header == TerraformAddress && headerValue != "" {- logger.Debug("Terraform address configured via request context") } } @@ -678,21 +678,17 @@ func TestTerraformContextMiddleware(t *testing.T) { { name: "headers take priority over query params and env vars", headers: map[string]string{- TerraformAddress: "https://header.terraform.io", TerraformToken: "header-token", TerraformSkipTLSVerify: "true", }, queryParams: map[string]string{- TerraformAddress: "https://query.terraform.io", TerraformSkipTLSVerify: "false", }, envVars: map[string]string{- TerraformAddress: "https://env.terraform.io", TerraformSkipTLSVerify: "false", }, expectedStatus: http.StatusOK, expectedContextVals: map[string]string{- TerraformAddress: "https://header.terraform.io", TerraformToken: "header-token", TerraformSkipTLSVerify: "true", },@@ -726,18 +722,15 @@ func TestTerraformContextMiddleware(t *testing.T) { name: "query params take priority over env vars (except token)", headers: map[string]string{}, queryParams: map[string]string{- TerraformAddress: "https://query.terraform.io", TerraformSkipTLSVerify: "true", }, envVars: map[string]string{- TerraformAddress: "https://env.terraform.io", TerraformToken: "env-token", TerraformSkipTLSVerify: "false", }, expectedStatus: http.StatusOK, expectedContextVals: map[string]string{- TerraformAddress: "https://query.terraform.io",- TerraformToken: "env-token", // From env since not in query+ TerraformToken: "env-token", TerraformSkipTLSVerify: "true", }, },@@ -761,12 +754,12 @@ func TestTerraformContextMiddleware(t *testing.T) { name: "empty values result in empty context values", headers: map[string]string{}, queryParams: map[string]string{- TerraformAddress: "", // Empty value+ TerraformAddress: "", // Empty value, not treated as an override attempt }, envVars: map[string]string{}, expectedStatus: http.StatusOK, expectedContextVals: map[string]string{- TerraformAddress: "",+ TerraformAddress: DefaultTerraformAddress, TerraformToken: "", TerraformSkipTLSVerify: "", },@@ -775,46 +768,53 @@ func TestTerraformContextMiddleware(t *testing.T) { name: "token in query params is rejected for security", headers: map[string]string{}, queryParams: map[string]string{- TerraformAddress: "https://query.terraform.io",- TerraformToken: "query-token", // This should cause an error+ TerraformToken: "query-token", // This should produce an err / cause an err }, envVars: map[string]string{}, expectedStatus: http.StatusBadRequest, expectError: true, errorMessage: "Terraform token should not be provided in query parameters", },+ {+ name: "address in query params is rejected",+ headers: map[string]string{},+ queryParams: map[string]string{+ TerraformAddress: "https://query.terraform.io",+ },+ envVars: map[string]string{},+ expectedStatus: http.StatusForbidden,+ expectError: true,+ errorMessage: "Cannot specify Terraform address via header or query parameter",+ }, { name: "canonical header names are handled correctly", headers: map[string]string{- "tfe_address": "https://canonical.terraform.io", // lowercase- "TFE_TOKEN": "canonical-token", // uppercase- "tfe_skip_tls_verify": "true", // mixed case+ "TFE_TOKEN": "canonical-token", // uppercase+ "tfe_skip_tls_verify": "true", // mixed case },@@ -1,3 +1,9 @@+# 1.0.1++SECURITY++* Disable client-supplied `TFE_ADDRESS` in streamable-http mode. Previously a client could override the Terraform address via HTTP header or query parameter, redirecting the server's requests and Authorization bearer token to an arbitrary endpoint. The address must now be configured server-side via the `TFE_ADDRESS` env var. This is a breaking change: clients supplying the address via header or query parameter now receive a 403. [389](https://github.com/hashicorp/terraform-mcp-server/pull/389)+ # 1.0.0 FEATURESAI-assisted change 91b55fef78babe29a5ad70f95a77417edafd113e86b24272c4f6af3a9d5a7e55 · Fix 8fb3621738385aa28560969f64b1a21b24ea176ff9f0f60285149423ade364e9