Skip to content

fix(pkg): treat a successful response with no body as an error - #7010

Merged
otavio merged 2 commits into
masterfrom
fix/empty-response-body
Sep 1, 2026
Merged

fix(pkg): treat a successful response with no body as an error#7010
otavio merged 2 commits into
masterfrom
fix/empty-response-body

Conversation

@otavio

@otavio otavio commented Sep 1, 2026

Copy link
Copy Markdown
Member

What

An agent whose SHELLHUB_SERVER_ADDRESS points at something that answers GET /info with 200 and an empty body crashed on a nil dereference before it connected. The API client now reports that as an error, and the gateway no longer produces such a reply in the first place.

Why

resty decodes into the **models.Info handed to SetResult. With a success status and a zero-length body there is nothing to unmarshal, so the pointer stays nil and no error is raised; ErrorFromResponse reads only the status, so GetInfo returned (nil, nil). probeServerInfo stored that nil, Setup carried on, and Authorize dereferenced a.serverInfo.Endpoints.SSH while building its log fields — a segfault, exit 2, and under a supervisor a restart loop.

The reply is easy to arrive at by accident. The gateway's site block matches on SHELLHUB_DOMAIN, so a request with any other Host fell through to Caddy's own default, which answers 200 with an empty body rather than a 404. A reverse proxy fronting the wrong vhost, a load-balancer health stub or a captive portal all do the same. What the operator got was a stack trace instead of "cannot reach the server".

Changes

  • pkg/api/client: requireBody turns a nil decode target into the new ErrEmptyResponse, and every method that decodes into a pointer returns through it. The guard sits at the seam the nine methods share rather than at /info alone — AuthDevice had the identical hazard, and Authorize dereferences its response on the very next lines. ListDevices is untouched: a slice result cannot nil-deref.
  • agent/pkg/agentd: probeServerInfo checks the error before it stores, instead of storing first and returning the error unwrapped.
  • gateway: a fallback site answers 404 for a Host the proxy does not serve, so a misdirected agent gets an unambiguous signal.
  • gateway: an explicit http://<domain> redirect when TLS is on. The fallback matches every name, which shadows automatic HTTPS's blanket redirect — and a deployment serving SHELLHUB_TLS_CERT_FILE manages no certificate for its domain, so that blanket rule was the only redirect it had. Without this block, http://<domain> would answer 404 instead of redirecting.

Testing

The fallback's ordering is the part worth probing. It matches every Host, so anything after it is unreachable; TestTheFallbackIsTheLastRouteOnPortEighty asserts that on the adapted JSON across every configuration shape, because the template cannot show it. TestMain_smoke now sends an unserved Host to the real binary and expects 404.

Checked by hand against a running Caddy in four shapes — supplied certificate, automatic SSL, plain HTTP, and web endpoints on an internal certificate. In each: the site redirects (308) or proxies, a tunnel subdomain redirects, healthcheck.internal answers 200, and an unknown Host gets 404. The supplied-certificate shape is the one that regressed on the fallback alone, and the one to re-check if the redirect block is ever touched.

Thanks to @Edu0x01, who reported the crash and its trigger.

An agent pointed at something that answers `GET /info` with `200` and a
zero-length body segfaults before it connects. resty decodes into the
`**models.Info` given to SetResult, and with nothing to unmarshal it
leaves the pointer nil and reports no error; ErrorFromResponse reads only
the status, so the caller gets `(nil, nil)`. probeServerInfo stored that
nil, Setup carried on, and Authorize dereferenced it while building its
log fields.

This is not exotic: the gateway's site block matches on SHELLHUB_DOMAIN,
and a request arriving with any other Host falls through to Caddy's
default, which answers 200 with no body rather than a 404. Any reverse
proxy fronting the wrong vhost, load-balancer health stub or captive
portal does the same. The operator sees a stack trace instead of "cannot
reach the server", and anyone able to answer that request keeps the agent
down for as long as they keep replying.

Every method here decoded into a pointer this way, so the guard goes at
the seam they share rather than at /info alone: requireBody turns a nil
result into ErrEmptyResponse, and each returns through it. It asserts
that a body arrived, which is all it does -- resty has already decoded.
AuthDevice is the one that mattered as much as GetInfo, since Authorize
dereferences its response on the very next lines.

probeServerInfo now checks the error before it stores, which is what it
should have done regardless of what the client returns.

Reported-by: Edu0x01 <https://github.com/Edu0x01>
The site block matches on SHELLHUB_DOMAIN, so a request arriving with any
other Host fell through to Caddy's own default, which answers 200 with an
empty body. An agent pointed at the wrong vhost reads that as ShellHub's
/info and gets no server info and no error. A reverse proxy fronting the
wrong site, a load-balancer health stub and a captive portal all reply the
same way, and none of them should look like a healthy ShellHub.

The fallback matches every name, so it has to be the last route on port 80
-- and it is, because the Caddyfile adapter sorts by host specificity and
leaves healthcheck.internal and the site ahead of it. That is an ordering
property the template cannot show, so the test asserts it on the adapted
JSON instead.

What the fallback does shadow is automatic HTTPS's own redirect. Caddy
writes one rule per name it manages a certificate for, and a single blanket
rule for the rest at the end of the list. A deployment serving
SHELLHUB_TLS_CERT_FILE manages no certificate for its domain, so that
blanket rule was the only redirect it had, and the fallback swallowed it:
http://<domain> answered 404 instead of redirecting. Hence the explicit
redirect block -- the name gets a rule of its own, and neither redirect
depends on the other.

Checked against a running Caddy in four shapes (supplied certificate,
automatic SSL, plain HTTP, and web endpoints on an internal certificate):
the site redirects or proxies, a tunnel subdomain redirects,
healthcheck.internal answers, and an unknown Host gets 404.

Reported-by: Edu0x01 <https://github.com/Edu0x01>
@otavio
otavio requested a review from a team as a code owner September 1, 2026 22:31
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Claude finished @otavio's task in 1m 48s —— View job


Code Review Complete

Reviewed 10 files across code quality, security, testing, language patterns, and architecture — no new issues found. The code looks good as-is.

A few notes on why the change holds up:

  • requireBody[T any] correctly sits at the shared seam. Since resty leaves the **T target nil when a 2xx carries no body (or a JSON null), the result == nil guard catches both, and every decode-into-pointer method now routes through it. ListDevices is rightly left alone (slice result can't nil-deref).
  • No legitimate empty-body path is broken. ErrorFromResponse treats all 2xx as success, but none of these endpoints return 204/empty as a valid outcome — they all expect a payload — so converting a bodyless success into ErrEmptyResponse introduces no regression for existing callers.
  • Cross-repo (cloud/) impact: no callers of GetInfo/AuthDevice/GetDevice/Endpoints exist there, and the behavior change is crash → typed error, so nothing downstream regresses.
  • Gateway fallback ordering is verified at the adapted-JSON level (TestTheFallbackIsTheLastRouteOnPortEighty) rather than by file order, which is the right way to pin Caddy's route-specificity sorting. The TLS-only http://<domain> redirect block is a correct guard against the catch-all shadowing automatic-HTTPS's blanket redirect for the supplied-certificate shape.

Tests cover the new error path on both GetInfo/AuthDevice/GetDevice and the probeServerInfo store-vs-error behavior, plus the gateway 404 smoke test.


To request another review round, comment /review.

@otavio
otavio merged commit c33e2fe into master Sep 1, 2026
46 checks passed
@otavio
otavio deleted the fix/empty-response-body branch September 1, 2026 22:54
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