Conversation
…line context in openapi and http_trigger handlers Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Addresses a security issue where OpenAPI x-pipeline routes and the HTTP trigger handler could fall back to generic responses when no HTTP response was explicitly written—potentially leaking full pipeline internal state in the response body.
Changes:
- OpenAPI
x-pipelinehandler now honorsresponse_status/response_body/response_headersfromresult.Currentwhen no response was written. - HTTP trigger now injects a context-carried
PipelineResultHolder, and uses it to honorresponse_statusbefore the generic202fallback. - Engine bridges workflow execution results into
PipelineResultHolder(when present in context) without changing theWorkflowEngineinterface.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| module/openapi.go | Uses response_status/response_body/response_headers from pipeline output to build the HTTP response when no writer output occurred. |
| module/openapi_test.go | Adds coverage for honoring pipeline-provided response values and preserving the existing fallback when absent. |
| module/http_trigger.go | Adds PipelineResultHolder + context key and applies pipeline-provided response values before the 202 fallback. |
| module/http_trigger_test.go | Adds tests validating the new trigger behavior for both presence/absence of response_status. |
| engine.go | Populates PipelineResultHolder from context after ExecuteWorkflow to bridge results back to HTTP trigger. |
module/openapi.go
Outdated
| if status, ok := result.Current["response_status"].(int); ok { | ||
| if headers, ok := result.Current["response_headers"].(map[string]any); ok { | ||
| for k, v := range headers { | ||
| if s, ok := v.(string); ok { | ||
| w.Header().Set(k, s) | ||
| } | ||
| } | ||
| } | ||
| w.WriteHeader(status) | ||
| if body, ok := result.Current["response_body"].(string); ok { | ||
| _, _ = w.Write([]byte(body)) //nolint:gosec // G705: body is pipeline step output explicitly set as response body | ||
| } | ||
| return |
There was a problem hiding this comment.
response_status is type-asserted to int (and response_headers to map[string]any). In this codebase config-derived numerics are frequently float64, and steps may also emit headers as map[string]string/http.Header; in those cases this branch won’t run and the handler will fall back to 200 + json.Encode(result.Current), reintroducing the state-leak the PR is fixing. Consider coercing common numeric types (int/int64/float64/json.Number/string) and accepting common header map shapes (map[string]string, map[string]any, http.Header, and slice values via Header().Add).
| if status, ok := result.Current["response_status"].(int); ok { | |
| if headers, ok := result.Current["response_headers"].(map[string]any); ok { | |
| for k, v := range headers { | |
| if s, ok := v.(string); ok { | |
| w.Header().Set(k, s) | |
| } | |
| } | |
| } | |
| w.WriteHeader(status) | |
| if body, ok := result.Current["response_body"].(string); ok { | |
| _, _ = w.Write([]byte(body)) //nolint:gosec // G705: body is pipeline step output explicitly set as response body | |
| } | |
| return | |
| if rawStatus, ok := result.Current["response_status"]; ok { | |
| // Coerce common numeric/string shapes into an HTTP status code. | |
| coerceStatus := func(v any) (int, bool) { | |
| switch s := v.(type) { | |
| case int: | |
| return s, true | |
| case int64: | |
| status := int(s) | |
| if int64(status) != s { | |
| return 0, false | |
| } | |
| return status, true | |
| case float64: | |
| if math.IsNaN(s) || math.IsInf(s, 0) { | |
| return 0, false | |
| } | |
| status := int(s) | |
| if float64(status) != s { | |
| return 0, false | |
| } | |
| return status, true | |
| case json.Number: | |
| i64, err := s.Int64() | |
| if err != nil { | |
| return 0, false | |
| } | |
| status := int(i64) | |
| if int64(status) != i64 { | |
| return 0, false | |
| } | |
| return status, true | |
| case string: | |
| s = strings.TrimSpace(s) | |
| if s == "" { | |
| return 0, false | |
| } | |
| n, err := strconv.Atoi(s) | |
| if err != nil { | |
| return 0, false | |
| } | |
| return n, true | |
| default: | |
| return 0, false | |
| } | |
| } | |
| if status, ok := coerceStatus(rawStatus); ok { | |
| // Apply headers from common map/header shapes, if provided. | |
| if rawHeaders, ok := result.Current["response_headers"]; ok { | |
| switch headers := rawHeaders.(type) { | |
| case map[string]any: | |
| for k, v := range headers { | |
| switch hv := v.(type) { | |
| case string: | |
| w.Header().Set(k, hv) | |
| case []string: | |
| for _, sv := range hv { | |
| w.Header().Add(k, sv) | |
| } | |
| case []any: | |
| for _, sv := range hv { | |
| w.Header().Add(k, fmt.Sprint(sv)) | |
| } | |
| default: | |
| // Fallback: best-effort stringification for single values. | |
| w.Header().Set(k, fmt.Sprint(hv)) | |
| } | |
| } | |
| case map[string]string: | |
| for k, v := range headers { | |
| w.Header().Set(k, v) | |
| } | |
| case http.Header: | |
| for k, vals := range headers { | |
| for _, v := range vals { | |
| w.Header().Add(k, v) | |
| } | |
| } | |
| } | |
| } | |
| w.WriteHeader(status) | |
| if body, ok := result.Current["response_body"].(string); ok { | |
| _, _ = w.Write([]byte(body)) //nolint:gosec // G705: body is pipeline step output explicitly set as response body | |
| } | |
| return | |
| } |
| // If the pipeline set response_status in its output (without writing | ||
| // directly to the response writer), use those values to build the response. | ||
| if result := resultHolder.Get(); result != nil { | ||
| if status, ok := result["response_status"].(int); ok { | ||
| if headers, ok := result["response_headers"].(map[string]any); ok { | ||
| for k, v := range headers { | ||
| if s, ok := v.(string); ok { | ||
| w.Header().Set(k, s) | ||
| } | ||
| } | ||
| } | ||
| w.WriteHeader(status) | ||
| if body, ok := result["response_body"].(string); ok { | ||
| if _, err := w.Write([]byte(body)); err != nil { | ||
| log.Printf("http trigger: failed to write response: %v", err) | ||
| } | ||
| } | ||
| return | ||
| } |
There was a problem hiding this comment.
The response override only triggers when result["response_status"] is an int and headers are map[string]any. If the pipeline emits response_status as float64/int64 (common with generic decoding) or response_headers as map[string]string/http.Header, this will silently fall through to the generic 202 response and ignore the intended status/body/headers. Consider normalizing these types (similar to how step factories accept both int and float64) and supporting multi-value headers (e.g., []string/[]any via Header().Add).
| // If the caller stored a PipelineResultHolder in the context, populate it | ||
| // so HTTP trigger handlers can read response_status/body/headers without | ||
| // requiring the WorkflowEngine interface to return a result map. | ||
| if holder, ok := ctx.Value(module.PipelineResultContextKey).(*module.PipelineResultHolder); ok && holder != nil { | ||
| holder.Set(results) | ||
| } |
There was a problem hiding this comment.
This adds a new behavior path where TriggerWorkflow populates a PipelineResultHolder from the context. There are existing unit tests for StdEngine.TriggerWorkflow in engine_test.go, but none exercising this holder population; adding a test that asserts the holder is set (and remains nil when absent) would help prevent regressions.
|
@copilot apply changes based on the comments in this thread |
…http.Header headers; add engine holder tests Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Applied in db6365a:
|
Pipeline steps that set
response_status/response_body/response_headersinresult.Currentand returnStop: true(without writing to_http_response_writer) caused both the OpenAPI x-pipeline handler and HTTP trigger handler to fall through to generic fallbacks — leaking all pipeline internal state (tokens, claims, config values) as JSON in the response body.Changes
module/http_trigger.go: IntroducePipelineResultHolder/PipelineResultContextKey. Inject a holder into the context before callingTriggerWorkflow; after execution, check it forresponse_statusbefore the generic202fallback. Adds shared helperscoercePipelineStatus,applyPipelineHeaders, andwritePipelineContextResponsethat handle all common type shapes emitted by pipeline steps.module/openapi.go: Before the200 + json.Encode(result.Current)fallback, callwritePipelineContextResponseto apply headers, status, and body from the pipeline output ifresponse_statusis present.engine.go: Afterhandler.ExecuteWorkflow, populate thePipelineResultHolderfrom context if present — bridges the result back without changing theWorkflowEngineinterface.Type coercion
response_statusis coerced from all common numeric and string shapes pipeline steps may emit:int,int64,float64(whole numbers only),json.Number, numericstringresponse_headersis accepted in all common map/header shapes:map[string]any(including[]string/[]anymulti-value entries viaHeader().Add)map[string]stringhttp.HeaderExample
Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.