Skip to content

fix: respect response_status/response_body/response_headers from pipeline context when no HTTP response written#254

Merged
intel352 merged 4 commits intomainfrom
copilot/fix-openapi-response-handling
Mar 3, 2026
Merged

fix: respect response_status/response_body/response_headers from pipeline context when no HTTP response written#254
intel352 merged 4 commits intomainfrom
copilot/fix-openapi-response-handling

Conversation

Copy link
Contributor

Copilot AI commented Mar 3, 2026

Pipeline steps that set response_status/response_body/response_headers in result.Current and return Stop: 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: Introduce PipelineResultHolder / PipelineResultContextKey. Inject a holder into the context before calling TriggerWorkflow; after execution, check it for response_status before the generic 202 fallback. Adds shared helpers coercePipelineStatus, applyPipelineHeaders, and writePipelineContextResponse that handle all common type shapes emitted by pipeline steps.

  • module/openapi.go: Before the 200 + json.Encode(result.Current) fallback, call writePipelineContextResponse to apply headers, status, and body from the pipeline output if response_status is present.

  • engine.go: After handler.ExecuteWorkflow, populate the PipelineResultHolder from context if present — bridges the result back without changing the WorkflowEngine interface.

Type coercion

response_status is coerced from all common numeric and string shapes pipeline steps may emit:

  • int, int64, float64 (whole numbers only), json.Number, numeric string

response_headers is accepted in all common map/header shapes:

  • map[string]any (including []string/[]any multi-value entries via Header().Add)
  • map[string]string
  • http.Header

Example

// A pipeline step that sets response_status without writing to the HTTP writer:
return &StepResult{
    Output: map[string]any{
        "response_status": 403,           // also works as float64(403), "403", etc.
        "response_body":   `{"error":"forbidden"}`,
        "response_headers": map[string]any{
            "Content-Type": "application/json",
        },
    },
    Stop: true,
}, nil
// Previously: 200 OK with all pipeline state leaked as JSON
// After this fix: 403 Forbidden with {"error":"forbidden"}
Original prompt

This section details on the original issue you should resolve

<issue_title>openapi: respect response_status/response_body from pipeline context when no HTTP response written</issue_title>
<issue_description>When a pipeline step sets response_status, response_body, and response_headers in the pipeline context and returns StopPipeline: true (without writing to the _http_response_writer), the openapi module's x-pipeline handler falls through to writing 200 OK with json.Encode(result.Current).

This dumps all pipeline internal state (tokens, claims, config values) to the HTTP response, which is a security concern.

The same issue exists in the HTTP trigger handler (http_trigger.go), which falls through to 202 {"status": "workflow triggered"}.

Proposed fix: After pipeline execution, if rw.written is false, check result.Current for response_status, response_body, and response_headers keys. If present, use them to write the HTTP response instead of the generic fallback.

Example (in openapi.go, after the if rw.written { return } check):

if !rw.written {
    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))
        }
        return
    }
    // existing fallback...
}

Reproduce:

  1. Create a pipeline where a step returns StopPipeline: true with response_status: 403 in output
  2. Invoke via x-pipeline in the openapi module
  3. Observe: 200 OK with all pipeline context leaked as JSON body
  4. Expected: 403 Forbidden with only the intended error body

This should also be applied to the HTTP trigger handler for consistency.</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

…line context in openapi and http_trigger handlers

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix openapi to respect response_status and response_body fix: respect response_status/response_body/response_headers from pipeline context when no HTTP response written Mar 3, 2026
@intel352 intel352 marked this pull request as ready for review March 3, 2026 22:41
Copilot AI review requested due to automatic review settings March 3, 2026 22:41
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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-pipeline handler now honors response_status/response_body/response_headers from result.Current when no response was written.
  • HTTP trigger now injects a context-carried PipelineResultHolder, and uses it to honor response_status before the generic 202 fallback.
  • Engine bridges workflow execution results into PipelineResultHolder (when present in context) without changing the WorkflowEngine interface.

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.

Comment on lines +388 to +400
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
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

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

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment on lines +351 to +369
// 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
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
Comment on lines +586 to +591
// 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)
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

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 uses AI. Check for mistakes.
@intel352
Copy link
Contributor

intel352 commented Mar 3, 2026

@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>
Copy link
Contributor Author

Copilot AI commented Mar 3, 2026

@copilot apply changes based on the comments in this thread

Applied in db6365a:

  • Extracted shared helpers coercePipelineStatus, applyPipelineHeaders, and writePipelineContextResponse in http_trigger.go
  • coercePipelineStatus handles int, int64, float64 (whole only), json.Number, and numeric strings
  • applyPipelineHeaders accepts map[string]any (with []string/[]any slice values via Header().Add), map[string]string, and http.Header
  • Both openapi.go and http_trigger.go now use writePipelineContextResponse
  • Added TestCoercePipelineStatus covering all coercion cases, TestHTTPTrigger_PipelineContextResponse_Float64Status, TestHTTPTrigger_PipelineContextResponse_MapStringStringHeaders, TestOpenAPIModule_XPipeline_ResponseStatus_Float64, plus engine tests TestEngine_TriggerWorkflow_PopulatesPipelineResultHolder and TestEngine_TriggerWorkflow_HolderAbsent

@intel352 intel352 merged commit 50d6c63 into main Mar 3, 2026
14 checks passed
@intel352 intel352 deleted the copilot/fix-openapi-response-handling branch March 3, 2026 23:33
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.

openapi: respect response_status/response_body from pipeline context when no HTTP response written

3 participants