Skip to content

echo: don't echo Go error messages into HTTP responses#2370

Open
paulo-raca wants to merge 1 commit into
oapi-codegen:mainfrom
paulo-raca:chore/setinternal-pentest-echo
Open

echo: don't echo Go error messages into HTTP responses#2370
paulo-raca wants to merge 1 commit into
oapi-codegen:mainfrom
paulo-raca:chore/setinternal-pentest-echo

Conversation

@paulo-raca
Copy link
Copy Markdown

@paulo-raca paulo-raca commented May 7, 2026

What this is

A small change to the echo and echo-v5 server templates so that the error responses generated servers send back to clients don't include the underlying Go error text.

What's changing today

When a request to a generated echo server has a parameter that doesn't parse (e.g. a path parameter expecting an int but the client sent "foo"), the response currently looks like this:

HTTP/1.1 400 Bad Request

Invalid format for parameter id: parsing "foo" as int: strconv.ParseInt: parsing "foo": invalid syntax

Everything after the colon is whatever err.Error() happened to return — it comes from runtime.BindStyledParameterWithOptions / BindQueryParameterWithOptions and the underlying strconv / time parser. That text isn't part of any contract — it's whatever Go's standard library or oapi-codegen-runtime felt like saying at the time. Sending it on the wire means:

  • API consumers can come to depend on its exact wording (and it can change between Go releases).
  • It can leak internal-looking detail (struct field names from the runtime, package-private wording, etc.).
  • Operators of pentest-conscious deployments end up writing custom middleware to strip it.

Why

Two reasons:

Security / info disclosure. Pentest reports routinely flag echoing raw err.Error() back to API callers as low-but-real info leakage. The current message can include Go stdlib parser internals (strconv.ParseInt: …), runtime/types implementation detail, or library-version-dependent wording. Each message in isolation looks innocuous, but the cumulative shape exposes how parameters get parsed server-side. Production-conscious teams currently have to fork or post-process this template to satisfy that finding — that's the whole reason we've maintained a local override for over a year.

More useful for the actual caller. When somebody sends id=foo, the helpful thing to tell them is "the value 'foo' didn't fit parameter id". The current message tells them which Go function failed and how, which is information for whoever wrote the server, not whoever is calling it. Quoting the offending value puts the response back in the API consumer's frame.

The original error isn't lost — it stays attached to the HTTPError (via SetInternal in v4 / Wrap in v5), so server-side error handlers still log it.

What it changes to

The same response becomes:

HTTP/1.1 400 Bad Request

Invalid format for parameter id: 'foo'

i.e. the value the caller actually sent. The original error is still attached to the echo HTTPError (via SetInternal(err) in v4 and Wrap(err) in v5), so server-side error handlers — including echo's default — still log the diagnostic. Just nothing leaks to the wire.

Concretely, the diff

In pkg/codegen/templates/echo/echo-wrappers.tmpl and pkg/codegen/templates/echo/v5/echo-wrappers.tmpl, four spots (path / query / header / cookie parameter binds) change from:

-return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter X: %s", err))
+return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter X: '%s'", <user-value>)).SetInternal(err)

<user-value> is the right input source for that location:

location value
path ctx.Param("X")
query ctx.QueryParams().Get("X")
header valueList[0]
cookie cookie.Value

For pkg/codegen/templates/echo/v5/echo-wrappers.tmpl, .SetInternal(err) becomes .Wrap(err) (v5's equivalent — *echo.HTTPError doesn't have SetInternal, but it does have a Wrap(err) method that attaches an inner error).

What about the other framework templates?

Out of scope for this PR. The chi / gorilla / fiber / gin / iris / stdhttp templates have the same shape (http.Error(w, err.Error(), …)), and the same argument applies, but I want this PR to be small and easy to review. Happy to do those in follow-ups if this lands.

Why this is safe

  • The HTTP status code is unchanged.
  • The HTTP body shape is the same string template — only the substituted token changes.
  • Server-side error handlers receive the same error (now via Internal / wrapped) so logging is unaffected.
  • Valid traffic is byte-identical on the wire.

Tests

Regenerated all the affected test fixtures (internal/test/parameters/echo, internal/test/parameters/echov5, internal/test/strict-server/echo, the petstore-expanded examples). go test ./... is clean across the root, examples, and internal/test modules.

Background

We've shipped this as a local template override for over a year and it's surfaced no issues — the change is small, the contract is preserved, and the request/response shape is unchanged for valid traffic. Upstreaming so we don't have to maintain the override.

The echo wrapper template emits four parameter-bind error paths
(path / query / header / cookie) that currently surface the raw
runtime/Bind*ParameterWithOptions error into the HTTP response:

    return echo.NewHTTPError(
        http.StatusBadRequest,
        fmt.Sprintf("Invalid format for parameter X: %s", err))

The string returned by err.Error() is implementation detail of
runtime/types/parsing — it can include internal struct field names,
package-local error wording, or library version-dependent text.
Production servers shouldn't echo that to API consumers.

Replace each with:

    // echo (v4)
    return echo.NewHTTPError(
        http.StatusBadRequest,
        fmt.Sprintf("Invalid format for parameter X: '%s'", <user-value>),
    ).SetInternal(err)

    // echo v5 — same idea, different API:
    return echo.NewHTTPError(
        http.StatusBadRequest,
        fmt.Sprintf("Invalid format for parameter X: '%s'", <user-value>),
    ).Wrap(err)

The HTTP body now reflects what the caller sent (`ctx.Param(...)` /
`ctx.QueryParams().Get(...)` / `valueList[0]` / `cookie.Value`), and
the original err is preserved on the echo HTTPError as either
SetInternal (v4) or Wrap (v5) so it still flows to server logs but
never to the wire.

Regenerated test fixtures and examples to match.
@paulo-raca paulo-raca requested a review from a team as a code owner May 7, 2026 15:49
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 7, 2026

Greptile Summary

This PR changes the echo and echo-v5 server templates so that 400 Bad Request error responses no longer include the raw Go error string — instead they echo back the user-supplied value in quotes (e.g. 'foo') while preserving the original error via .SetInternal/.Wrap for server-side logging. All nine affected *.gen.go fixtures are regenerated consistently with the template edits.

  • Template changes in echo-wrappers.tmpl (v4 + v5) update four parameter-binding error paths (path, query, header, cookie) to expose the caller's value rather than the Go error text; the v4 template uses .SetInternal(err) and v5 uses .Wrap(err), matching each framework's API correctly.
  • The change is unconditional — every generated echo server gets the new message format with no opt-out, which may silently break downstream users whose clients or test suites match the current error wording.
  • Other framework backends (chi, fiber, gin, gorilla, iris, stdhttp) are acknowledged as out of scope by the author; follow-up PRs are planned.

Confidence Score: 3/5

The template logic is correct and generated fixtures are internally consistent, but the change unconditionally rewrites the HTTP 400 response body for all echo users without a migration path.

The four error-message sites in both echo templates are updated correctly and symmetrically, and all regenerated files match the templates with no unrelated drift. However, every generated echo server will emit a different 400 body on the next codegen run — clients, integration tests, or monitoring rules that pattern-match on the previous error wording will silently break. The project's convention is to gate unconditional wire-format changes behind a compatibility or output flag, which is absent here. There is also no new behavioral test to lock in the new format going forward.

pkg/codegen/templates/echo/echo-wrappers.tmpl and its v5 counterpart are the source of truth; the generated files are fine on their own.

Important Files Changed

Filename Overview
pkg/codegen/templates/echo/echo-wrappers.tmpl Core source of all changes: replaces %s err-in-message with '%s' user-value + .SetInternal(err) for path/query/header/cookie error paths; unconditional behavior change with no compatibility flag.
pkg/codegen/templates/echo/v5/echo-wrappers.tmpl Echo v5 equivalent of the same four-site change; uses .Wrap(err) instead of .SetInternal(err) matching v5's API — correct and symmetric with the v4 template.
internal/test/parameters/echo/gen/server.gen.go All 44 error-message sites regenerated consistently with the template; no unrelated drift observed, but no new behavior test accompanies the fixture update.
internal/test/parameters/echov5/server.gen.go Echo v5 parameter test fixtures regenerated symmetrically with v4; changes are consistent with the template.
examples/petstore-expanded/echo/api/petstore-server.gen.go Petstore echo example regenerated; 4 error-path lines updated correctly, no unrelated changes.
examples/petstore-expanded/echo-v5/api/petstore-server.gen.go Petstore echo-v5 example regenerated; 4 error-path lines updated with .Wrap(err) correctly.

Reviews (1): Last reviewed commit: "echo: don't put Go err.Error() into HTTP..." | Re-trigger Greptile

Comment on lines 22 to 24
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err))
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: '%s'", ctx.Param("{{.ParamName}}"))).SetInternal(err)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Unconditional behavior change without a compatibility flag

This modifies the HTTP 400 response body for every generated echo server unconditionally. Per the project's practice, any change to generated output that alters observable wire behavior should be opt-in via a new compatibility or output-options flag, not a silent change to all users. Downstream consumers — integration tests, API gateways checking error bodies, client libraries parsing these messages — will silently break without a migration path. The same applies to the query, header, and cookie error paths below (and in the v5 template). Consider gating behind a flag like output-options.echo-error-include-value (or similar) so existing deployments can keep the current behavior.

Comment on lines 40 to 43
err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", ctx.QueryParams(), &params.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err))
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: '%s'", ctx.QueryParams().Get("{{.ParamName}}"))).SetInternal(err)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 QueryParams().Get() drops all but the first value for multi-value parameters

For array-typed query parameters sent as repeated keys (e.g. ?tags=foo&tags=bar), ctx.QueryParams().Get("tags") returns only "foo". When binding fails, the error message Invalid format for parameter tags: 'foo' omits bar entirely, which may be confusing when the actual submitted multi-value list was the real source of the parsing failure. Consider using ctx.QueryParams()["paramName"] joined with commas, or ctx.Request().URL.RawQuery, to give a more faithful representation of what the caller actually sent.

Comment on lines 137 to +140
var value int32
err = runtime.BindStyledParameterWithOptions("simple", "p", cookie.Value, &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationCookie, Explode: false, Required: false, Type: "integer", Format: "int32"})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter p: %s", err))
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter p: '%s'", cookie.Value)).SetInternal(err)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 No regression test verifying the new error message format

The test fixtures were regenerated, but no test was added to internal/test/parameters/echo/ (or the echo-v5 counterpart) that actually issues a malformed request and asserts the new '<value>'-style response body. Without this, a future template edit could silently revert the format without CI catching it. A table-driven test covering path/query/header/cookie bad-value cases would make the contract explicit.

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.

1 participant