chore: forbid direct response body JSON decode in codersdk (#27859)

Add a ruleguard rule forbidding direct
`json.NewDecoder(res.Body).Decode(...)` on `*http.Response` in codersdk
packages, so new typed endpoints use `codersdk.ReadBodyAsJSON` and keep
returning structured errors for non-JSON bodies. The rule matches both
the chained call form and decoders assigned to a variable first.

Intentional raw-body paths carry documented `//nolint:gocritic`
exceptions: the 16 agent-direct HTTP decodes in
`workspacesdk/agentconn.go` route through a single `decodeAgentJSON`
helper (agent-direct over tailnet, so `ReadBodyAsJSON`'s reverse
proxy/SSO error guidance does not apply), and the Azure IMDS
attested-document decode in `agentsdk/azure.go` keeps an inline
exception.

The two `UseNumber` decoders in `licenses.go` are migrated to a new
`codersdk.ReadBodyAsJSONUseNumber`, so `coder licenses add/list` also
return structured errors for non-JSON bodies instead of `invalid
character '<' looking for beginning of value`.

Note for local verification: golangci-lint caches results, so run
`golangci-lint cache clean` after modifying `scripts/rules.go` or the
rule may silently not fire.

Final PR of the stack on #27804, #27857, and #27858. Refs #27044.


Stack plan

Inventory (full-tree audit): 280 migratable call sites across 47 files;
17 excluded (16 agent-direct HTTP sites in `workspacesdk/agentconn.go`,
1 Azure IMDS decode in `agentsdk/azure.go`).

1. **#27857** `refactor(codersdk): use ReadBodyAsJSON in typed
endpoints`: mechanical migration of all sites except `chats.go` (224
sites, 46 files).
2. **#27858** `refactor(codersdk): use shared error helpers in chat
endpoints`: migrate the 56 `chats.go` sites and consolidate the
duplicated `readRawBodyAsError`/`newResponseError` helpers onto the
shared `client.go` error path, with regression tests for the 409
usage-limit flow.
3. **#27859** `chore: forbid direct response body JSON decode in
codersdk`: ruleguard rule with documented exceptions for the intentional
raw-body paths, plus `ReadBodyAsJSONUseNumber` for the `licenses.go`
decoders.



Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.
This commit is contained in:
dylanhuff-at-coder
2026-08-06 08:03:47 -07:00
committed by GitHub
parent 4e2620d64f
commit 9b27d12929
6 changed files with 99 additions and 24 deletions
+29
View File
@@ -529,3 +529,32 @@ func netAddrNil(m dsl.Matcher) {
m.Match("$_.RemoteAddr().Network()").Report("RemoteAddr() may return nil and segfault if you call Network()")
m.Match("$_.LocalAddr().Network()").Report("LocalAddr() may return nil and segfault if you call Network()")
}
// codersdkResponseBodyDecode ensures that codersdk typed endpoint methods
// decode HTTP response bodies through codersdk.ReadBodyAsJSON, which
// returns a structured *codersdk.Error when an intermediary such as a
// reverse proxy or SSO portal responds with HTML, an empty body, or other
// non-JSON content. Responses that intentionally bypass the Coder API
// error contract (agent-direct HTTP over tailnet, cloud metadata
// services) suppress this rule with a nolint:gocritic comment explaining
// why.
//
// Both the chained call form and decoders assigned to a variable first
// are matched.
//
//nolint:unused,deadcode,varnamelen
func codersdkResponseBodyDecode(m dsl.Matcher) {
m.Import("encoding/json")
m.Import("net/http")
m.Match(
`json.NewDecoder($res.Body).Decode($_)`,
`$_ := json.NewDecoder($res.Body)`,
`$_ = json.NewDecoder($res.Body)`,
).
Where(
(m["res"].Type.Is("*http.Response") || m["res"].Type.Is("http.Response")) &&
m.File().PkgPath.Matches(`github.com/coder/coder/v2/codersdk`) &&
!m.File().Name.Matches(`_test\.go$`),
).
Report("Use codersdk.ReadBodyAsJSON to decode typed API responses so non-JSON bodies produce a structured error. For responses that are intentionally not Coder API JSON, add a nolint:gocritic comment explaining why.")
}