Skip to content

fix: RemoteStep writes HTTP response to _http_response_writer on StopPipeline#253

Closed
Copilot wants to merge 4 commits intomainfrom
copilot/fix-authz-check-http-response
Closed

fix: RemoteStep writes HTTP response to _http_response_writer on StopPipeline#253
Copilot wants to merge 4 commits intomainfrom
copilot/fix-authz-check-http-response

Conversation

Copy link
Contributor

Copilot AI commented Mar 3, 2026

External plugin steps (e.g. step.authz_check) signal denial via StopPipeline: true with response_status, response_body, and response_headers in their output — but RemoteStep.Execute was ignoring these fields, leaving the HTTP trigger and openapi handler to send their own default response (202 or 200 with full pipeline context), leaking internal state like tokens and claims.

Changes

  • plugin/external/remote_step.go: After a StopPipeline=true response, checks for response_status in the output; if _http_response_writer is present in pipeline metadata, writes the encoded response (headers, status code, body) and sets _response_handled=true. Mirrors the pattern in step.auth_validate for 401 responses.

  • plugin/external/remote_step_test.go: Three new test cases covering:

    • 403 status + body + headers are written and _response_handled is set
    • No _http_response_writer in metadata — no panic, _response_handled unset
    • StopPipeline=true without response_status — response writer is left untouched

Before / After

# Before
POST /api/protected  →  200 OK  {"response_status":403, "token":"...", ...}

# After  
POST /api/protected  →  403 Forbidden  {"error":"forbidden: insufficient permissions"}
Original prompt

This section details on the original issue you should resolve

<issue_title>step.authz_check: write HTTP 403 response on authorization denial</issue_title>
<issue_description>When step.authz_check denies a request, it returns StopPipeline: true with response_status: 403, response_body, and response_headers in the step output. However, it does NOT write the 403 response to the _http_response_writer.

Both the HTTP trigger handler and the openapi module handler check rw.written after pipeline execution:

  • If no step wrote to the ResponseWriter, the HTTP trigger returns 202 {"status": "workflow triggered"}
  • If no step wrote to the ResponseWriter, the openapi handler writes 200 OK with result.Currentleaking all pipeline context data (tokens, claims, internal state)

This differs from step.auth_validate, which correctly writes a 401 response to _http_response_writer before stopping the pipeline (see pipeline_step_auth_validate.go:110).

Expected behavior: step.authz_check should write the 403 JSON response to _http_response_writer on denial, matching the pattern used by step.auth_validate.

Reproduce:

  1. Configure a pipeline with step.authz_check where the subject is NOT authorized
  2. Invoke the pipeline via the openapi module's x-pipeline handler
  3. Observe: HTTP response is 200 OK with full pipeline context (including response_status: 403 in the JSON body)
  4. Expected: HTTP response is 403 Forbidden with {"error":"forbidden: ..."} body

Workaround: Ensure Casbin policies cover all routes so authz never fails for legitimate requests. There is no way to add a response-writing step after step.authz_check because StopPipeline: true prevents subsequent steps from executing.

Note: step.authz_check is provided by the workflow-plugin-authz external plugin. The fix likely needs to be in that plugin's forbiddenResult() function.</issue_description>

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


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

…step stops pipeline with response_status

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix HTTP 403 response writing on authorization denial fix: RemoteStep writes HTTP response to _http_response_writer on StopPipeline 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

This PR ensures that when an external (gRPC) plugin step stops a pipeline and returns an HTTP response payload (status/body/headers) in its output, the engine writes that response to the in-flight http.ResponseWriter so HTTP handlers don’t fall back to default 200/202 responses that may leak pipeline context.

Changes:

  • Update RemoteStep.Execute to detect StopPipeline=true + response_status and write headers/status/body to _http_response_writer, setting _response_handled=true.
  • Add unit tests covering response-writing behavior, absence of _http_response_writer, and stop-without-response_status.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
plugin/external/remote_step.go Writes HTTP response from plugin output when StopPipeline is set and a response writer is available.
plugin/external/remote_step_test.go Adds tests validating the response-writing behavior and guard rails.

Comment on lines +75 to +86
output := structToMap(resp.Output)

// When the plugin signals a pipeline stop with an HTTP response encoded in
// the output (response_status, response_body, response_headers), write that
// response to _http_response_writer so the caller sees the correct status
// code rather than a default 200/202. This mirrors the pattern used by
// step.auth_validate for 401 responses.
if resp.StopPipeline {
if w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter); ok {
if statusRaw, hasStatus := output["response_status"]; hasStatus {
var statusCode int
switch v := statusRaw.(type) {
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.

output := structToMap(resp.Output) can be nil when the plugin returns a response with output unset. In the StopPipeline branch the code indexes output["response_status"], which will panic on a nil map. Initialize output to an empty map (or guard output != nil) before reading response_status/response_headers/response_body.

Copilot uses AI. Check for mistakes.
Comment on lines +164 to +209
// TestRemoteStep_Execute_WritesHTTPResponseOnStopPipeline verifies that when a
// remote plugin returns StopPipeline=true with response_status, response_body,
// and response_headers in the output, the RemoteStep writes those to the
// _http_response_writer and sets _response_handled=true in pipeline metadata.
func TestRemoteStep_Execute_WritesHTTPResponseOnStopPipeline(t *testing.T) {
output, _ := structpb.NewStruct(map[string]any{
"response_status": float64(http.StatusForbidden),
"response_body": `{"error":"forbidden: insufficient permissions"}`,
"response_headers": map[string]any{
"Content-Type": "application/json",
},
})
stub := &stubPluginServiceClient{
response: &pb.ExecuteStepResponse{
Output: output,
StopPipeline: true,
},
}
step := NewRemoteStep("authz-step", "handle-authz", stub, nil)

recorder := httptest.NewRecorder()
pc := module.NewPipelineContext(nil, nil)
pc.Metadata["_http_response_writer"] = http.ResponseWriter(recorder)

result, err := step.Execute(context.Background(), pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !result.Stop {
t.Error("expected Stop=true")
}

if recorder.Code != http.StatusForbidden {
t.Errorf("expected HTTP 403, got %d", recorder.Code)
}

body := recorder.Body.String()
if body != `{"error":"forbidden: insufficient permissions"}` {
t.Errorf("unexpected body: %q", body)
}

if pc.Metadata["_response_handled"] != true {
t.Error("expected _response_handled=true in metadata")
}
}
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.

Add a regression test for the nil-output case: when the plugin returns StopPipeline=true but ExecuteStepResponse.Output is nil, RemoteStep.Execute should not panic and should not attempt to index into the output map. This test will protect the response-writing path once the nil-map panic is fixed.

Copilot generated this review using guidance from organization custom instructions.
@intel352
Copy link
Contributor

intel352 commented Mar 3, 2026

@copilot apply changes based on the comments in this thread

1 similar comment
@intel352
Copy link
Contributor

intel352 commented Mar 3, 2026

@copilot apply changes based on the comments in this thread

@intel352 intel352 closed this Mar 4, 2026
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.

step.authz_check: write HTTP 403 response on authorization denial

3 participants