From 79e0ed8dd43997371c2c3f1cd33cb638fb93dddb Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:20:26 -0400 Subject: [PATCH 1/6] fix: close echoHandler request body on all exit paths defer r.Body.Close() was registered after the early-return error check, so a read failure leaked the body. Move defer before io.ReadAll. --- fanout.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fanout.go b/fanout.go index 709b91b..92493fa 100644 --- a/fanout.go +++ b/fanout.go @@ -370,13 +370,13 @@ func cloneHeaders(original http.Header) http.Header { } func echoHandler(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize)) if err != nil { logError("Error reading body: %v", err) http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge) return } - defer r.Body.Close() echoData := map[string]interface{}{ "headers": r.Header, From e7c5ff1a351ea0b7edd72d1e5432988777324b20 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:21:33 -0400 Subject: [PATCH 2/6] fix: detect and reject oversized bodies in echoHandler LimitReader silently truncated bodies at maxBodySize without any error or indication to the caller. Use the maxBodySize+1 probe (same approach as multiplex/sendRequest) to detect truncation and return 413. Also correct the misleading 'Payload too large' message on genuine I/O errors, which now returns 400 via writeJSONError. --- fanout.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/fanout.go b/fanout.go index 92493fa..21cb2a9 100644 --- a/fanout.go +++ b/fanout.go @@ -371,10 +371,15 @@ func cloneHeaders(original http.Header) http.Header { func echoHandler(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize)) + bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) if err != nil { logError("Error reading body: %v", err) - http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge) + writeJSONError(w, "Failed to read request body", http.StatusBadRequest) + return + } + if int64(len(bodyBytes)) > maxBodySize { + logError("Request body size exceeds limit (%d bytes read)", len(bodyBytes)) + writeJSONError(w, "Payload too large", http.StatusRequestEntityTooLarge) return } From f81bfeaf8a6ef91f222a2761fa33387fd509384f Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:21:45 -0400 Subject: [PATCH 3/6] fix: set Content-Type application/json on all JSON responses echoHandler default mode and healthCheck wrote JSON bodies without the corresponding Content-Type header, inconsistent with every other JSON-producing handler in the service. --- fanout.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fanout.go b/fanout.go index 21cb2a9..676fed5 100644 --- a/fanout.go +++ b/fanout.go @@ -400,6 +400,7 @@ func echoHandler(w http.ResponseWriter, r *http.Request) { return } default: + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) if err := json.NewEncoder(w).Encode(map[string]string{"status": "echoed"}); err != nil { logError("Failed to encode echo short response: %v", err) @@ -421,6 +422,7 @@ func echoHandler(w http.ResponseWriter, r *http.Request) { } func healthCheck(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]string{"status": "healthy"}); err != nil { logError("Failed to encode health response: %v", err) From 533dcac8af4f1f288baa66c3bf3a8aef87c6e293 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:22:08 -0400 Subject: [PATCH 4/6] fix: increment retriesTotal and retrySuccess Prometheus metrics Both counters were registered but never incremented, making them dead metrics. retriesTotal is now recorded at each retry point labelled by cause (network_error or server_error). retrySuccess is recorded after a successful response that required at least one retry, labelled by the total attempt count. --- fanout.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/fanout.go b/fanout.go index 676fed5..17a0b11 100644 --- a/fanout.go +++ b/fanout.go @@ -672,6 +672,9 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin } response = nil } + if metricsEnabled { + retriesTotal.WithLabelValues(target, "network_error").Inc() + } attempts++ continue } @@ -695,6 +698,9 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin if cerr := response.Body.Close(); cerr != nil { logWarn("Failed to close response body after server error: %v", cerr) } + if metricsEnabled { + retriesTotal.WithLabelValues(target, "server_error").Inc() + } attempts++ continue } @@ -733,6 +739,10 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin resp.Body = string(respBody) resp.Attempts = attempts + 1 + if metricsEnabled && attempts > 0 { + retrySuccess.WithLabelValues(target, strconv.Itoa(attempts+1)).Inc() + } + logDebugWithContext( map[string]string{ "target": target, From fa4aa998eaf7c85886cc4dd1d2ecfc1f15344e9a Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Fri, 22 May 2026 17:59:18 -0400 Subject: [PATCH 5/6] ci: fix gosec image tag (drop v prefix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit securego/gosec tags ≥2.10 are published without the 'v' prefix on Docker Hub, so the existing pin `securego/gosec:v2.22.4` resolves to `manifest unknown` and fails the gosec step with exit 125. Use `2.22.4`. --- .github/workflows/binary-release.yml | 2 +- .github/workflows/docker-image.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml index 4af586f..5967ddd 100644 --- a/.github/workflows/binary-release.yml +++ b/.github/workflows/binary-release.yml @@ -40,7 +40,7 @@ jobs: - name: Security scan (gosec) run: | - docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:v2.22.4 gosec ./... + docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:2.22.4 gosec ./... - name: Build for multiple platforms run: | diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 7c8d79b..da4140a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -60,7 +60,7 @@ jobs: - name: Security scan (gosec) run: | - docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:v2.22.4 gosec ./... + docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:2.22.4 gosec ./... - name: Run unit tests (inside Docker) run: | From f0dc2db4f40dc140c87c2dc4e6e5fa40041edbd3 Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Fri, 22 May 2026 18:01:45 -0400 Subject: [PATCH 6/6] fix: discard healthcheck Body.Close error (gosec G104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The -healthcheck CLI flag's Close() return value was unchecked; gosec G104 flagged it. We immediately os.Exit after this point so the close error is genuinely irrelevant — explicit `_ =` makes the intent clear and satisfies the scan. --- fanout.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fanout.go b/fanout.go index 17a0b11..f8b295c 100644 --- a/fanout.go +++ b/fanout.go @@ -840,7 +840,7 @@ func main() { if err != nil { os.Exit(1) } - hcResp.Body.Close() + _ = hcResp.Body.Close() if hcResp.StatusCode != http.StatusOK { os.Exit(1) }