Configurable redirect-following for the SDK remote source
Overview
The SDK remote.HTTP source refuses any redirect that changes scheme or host. GitHub now serves release-asset downloads as a 302 from github.com to release-assets.githubusercontent.com (a different host), so the agent's self-update binary download — which goes through remote.HTTP — is refused. This spec adds a Redirect RedirectPolicy field to remote.HTTPConfig with three ordered levels — RedirectNone, RedirectSameOrigin (the default and zero value), and RedirectCrossOrigin. On top of that mechanism, the self-update action gains an operator-settable allow_redirect flag (proto, default false) so an operator explicitly decides whether a self-update download may follow a cross-origin redirect; package downloads (deb/rpm/appimage) follow cross-origin redirects only when sha256-pinned. A redirect must never strip TLS, so an https → http downgrade stays refused at every level.
Motivation
control/agent self-update is broken for the standard GitHub-release distribution path:
download binary: GET https://github.com/manchtools/power-manage-agent/releases/download/v2026.07.01-rc2/power-manage-agent-linux-arm64:
remote: invalid source config: refusing cross-origin redirect https://github.com -> https://release-assets.githubusercontent.com
The refusal comes from the strict redirect guard in defaultHTTPClient (sdk/sys/remote/http.go). The guard exists for good reasons (anti-substitution on unpinned fetches, anti-SSRF, anti-TLS-downgrade), but it has no opt-out, and GitHub-CDN host changes are a legitimate, expected redirect. Two further facts make this worth fixing cleanly:
- The agent's checksum download (
downloadAndExtractChecksum) uses a different client (Executor.httpClient, Go's default redirect policy), so it silently follows the redirect and works — an asymmetry that made the failure confusing and that also lets the checksum fetch be downgraded tohttp. - The binary fetch is always sha256-pinned, so a followed redirect cannot substitute bytes undetected; the same-origin guard adds real protection only for the unpinned case.
Acceptance criteria
- Given the default
RedirectSameOrigin(the zero value), when a fetch encounters a same-scheme, same-host redirect (path/query changes only), then it is followed; when it encounters a host or scheme change, then it is refused withErrInvalidConfig(unchanged historical behaviour). - Given
RedirectNone, when a fetch encounters any redirect — including a same-origin path redirect — then it is refused withErrInvalidConfig. - Given
RedirectCrossOrigin, when a fetch encounters anhttps → httpscross-host redirect, then it is followed and the fetch succeeds; anhttp → httpsupgrade is likewise followed. - Given
RedirectCrossOriginand aChecksumSHA256pin, when the redirect target serves bytes that do not match the pin, then the fetch fails withErrIntegrity(the pin still governs integrity). - Given any policy level, when a redirect attempts an
https → httpscheme downgrade, then it is refused withErrInvalidConfig(TLS strip is never allowed — including atRedirectCrossOrigin). - Given
RedirectSameOriginorRedirectCrossOrigin, when a redirect chain exceeds 10 hops, then it is refused withErrInvalidConfig(the hop bound is preserved). - The
Redirectpolicy is honoured identically by bothremote.NewHTTP(...).Fetchandremote.FetchBytes(...)(both funnel throughnewHTTPSource). - Given an
AGENT_UPDATEaction withallow_redirect = truewhose binary URL 302-redirects to a different host over https, when the agent downloads and the bytes match the resolved checksum, then the update proceeds (the reported bug is fixed). - Given an
AGENT_UPDATEaction withallow_redirect = false(the proto default), when the binary or checksum URL redirects to a different host, then the download is refused — the operator has not opted in. - The agent's checksum download follows the same hardened path (
remote.FetchByteswith the operator-resolved policy), so it too refuses anhttps → httpdowngrade on redirect. - Given a package action (deb/rpm/appimage) whose URL redirects cross-origin, when a
checksum_sha256is set, then the redirect is followed (the pin keeps the bytes honest); when it is absent, then the redirect is refused. - The web self-update form exposes an "allow redirect" toggle, defaulting on (its prefilled URLs are GitHub release downloads, which redirect), round-trips through
allow_redirect, and the agent honours the operator's choice.
Out of scope
- A per-host redirect allow-list. The consumer opts in per fetch with a policy level; GitHub renames its CDN hosts over time, so a hostname allow-list would be both more config and more brittle.
- Any policy level that permits an
https → httpdowngrade. The downgrade refusal is retained as a hard invariant across all levels. - Changing the default redirect behaviour for any existing caller —
Redirectdefaults toRedirectSameOriginand the protoallow_redirectdefaults false. - A redirect knob on package actions (deb/rpm/appimage). Those stay pin-aware: cross-origin only when the operator already pinned a checksum.
- Database or server-logic changes. The server only rebuilds against the regenerated SDK; the
allow_redirectbool needs no new validation.
Technical design
Affected packages
sdk/sys/remote/http.go— addtype RedirectPolicy intwithRedirectSameOrigin(zero value),RedirectNone,RedirectCrossOrigin; add aRedirect RedirectPolicyfield toHTTPConfig; extract the policy intoredirectPolicy(p RedirectPolicy);defaultHTTPClienttakes the policy;newHTTPSourcepassescfg.Redirect. Fix the stale comment that claims redirects are left to Go's default.sdk/proto/pm/v1/actions.proto— addbool allow_redirect = 4toAgentUpdateParams(mirrorsallow_downgrade: an explicit, CA-signed operator decision,@gotags validate:"omitempty"). Regenerate Go + TS.agent/internal/executor/download.go—fetchArtifactgains aredirect remote.RedirectPolicy;redirectForArtifact(checksum)returnsRedirectCrossOriginonly when pinned, elseRedirectSameOrigin.agent/internal/executor/agent_update.go—updateRedirectPolicy(params)resolves the policy fromallow_redirect(true → cross-origin, default same-origin); both the binary download and the checksum download (now viaremote.FetchBytes, replacing the bespokeExecutor.httpClientloop) use it.agent/internal/executor/action_deb.go,action_rpm.go,action_appimage.go— passredirectForArtifact(params.ChecksumSha256)(pin-aware).web/.../forms/AgentUpdateParamsForm.svelte,forms/types.ts,ActionParamsDisplay.svelte,messages/{en,de}.json— operator toggle bound toallow_redirect, defaulting on (prefilled GitHub URLs redirect).
Redirect policy
Ordered levels, zero value = historical default:
RedirectNone— refuse every redirect.RedirectSameOrigin(default) — follow only same-scheme, same-host redirects (path/query changes); refuse host or scheme changes.RedirectCrossOrigin— additionally follow host changes andhttp → httpsupgrades (CDNs like GitHub releases).
At every level: an https → http downgrade is refused (a redirect must never strip TLS), and the chain is bounded to 10 hops.
New dependencies
None.
Security considerations
- Integrity: the agent binary fetch is always sha256-pinned (
expected_sha256or the hash resolved fromchecksum_url), so a followed redirect cannot substitute bytes — a mismatch isErrIntegrity. TheRedirectpolicy does not relax any pin, size cap, scheme-https gate (enforced upstream bysdk.ValidateHTTPSURL), or atomic-write guarantee. - TLS:
https → httpdowngrade on redirect is refused at every policy level (includingRedirectCrossOrigin), so a redirect can never strip transport integrity. - SSRF: the source URL rides inside a signed
AGENT_UPDATEaction and is operator-configured; redirect targets are reached only for an operator-specified, signed origin. The downgrade refusal and hop bound remain. - Operator control / fail-closed:
allow_redirectdefaults false, so an action that does not set it keeps the strict same-origin guard — following a cross-origin redirect on a self-update is an explicit, CA-signed operator decision (the same posture asallow_downgrade). The web form prefills it on only because its default URLs are GitHub releases. - Trust boundary: the new
allow_redirectproto field carries@gotags validate:"omitempty"; it is a proto3 scalar bool (no presence), so the type-aware protovalidate gate exempts it, likeallow_downgrade.
Test requirements
SDK unit tests (sdk/sys/remote/http_test.go)
redirectPolicy(RedirectNone): same-origin path redirect refused; any redirect refused.redirectPolicy(RedirectSameOrigin): same-origin path redirect allowed; cross-host refused; scheme change refused; >10 hops refused.redirectPolicy(RedirectCrossOrigin): cross-hosthttps → httpsallowed;http → httpsupgrade allowed;https → httpdowngrade refused; >10 hops refused.
SDK integration tests (httptest)
- Two
httptestservers (cross-port = cross-origin): server A 302 → server B.RedirectCrossOrigin+ correct pin →Fetch/FetchBytessucceed and bytes match. DefaultRedirectSameOrigin→ErrInvalidConfig.RedirectCrossOrigin- wrong pin →
ErrIntegrity.
- wrong pin →
Agent regression test (agent/internal/executor)
fetchArtifactagainst a redirecting plain-HTTP httptest pair (cross-port = cross-origin) withremoteHTTPClientforced nil so the policy is live:RedirectSameOriginis refused;RedirectCrossOriginfollows and the pin verifies the bytes.updateRedirectPolicy:allow_redirect=true → RedirectCrossOrigin; default →RedirectSameOrigin.redirectForArtifact: pinned → cross-origin, unpinned → same-origin.
Rejection paths
| Scenario | Error | Client-visible message | Logged context |
|---|---|---|---|
Any redirect at RedirectNone | ErrInvalidConfig | "refusing redirect (policy: none)" | from/to scheme+host |
Cross-host redirect at RedirectSameOrigin | ErrInvalidConfig | "refusing cross-origin redirect A -> B" | from/to scheme+host |
https→http downgrade at any level | ErrInvalidConfig | "refusing scheme downgrade https -> http" | from/to scheme+host |
| >10 hops | ErrInvalidConfig | "stopped after 10 redirects" | hop count |
| Redirect target bytes ≠ pin | ErrIntegrity | "binary does not match the expected_sha256 pin" | expected vs actual digest |
Rollout and migration
Backward-compatible: AllowRedirect defaults to false, so existing callers are unchanged. Ships as: SDK PR → SDK tag (v0.5.x) → agent go.mod bump → new agent RC. No DB migration, no feature flag.
References
- WS7 agent self-update authenticity — [[project_ws7_agent_update_authenticity]]
sdk/sys/remote/http.godefaultHTTPClientredirect guard- GitHub release-asset host change:
github.com→release-assets.githubusercontent.com