Compare commits

...
Author SHA1 Message Date
JATMNandPatrickNoFilter 17fcd26602 fix(update): authenticate GitHub release checks (supersedes #504) (#1216)
* fix(update): authenticate GitHub release checks

Rebuild PatrickNoFilter's final change from Twigpine/zero#504 on current
upstream main. Prefer ZERO_GITHUB_TOKEN, fall back to GITHUB_TOKEN, and
limit credentials to HTTPS GitHub API requests with downgrade protection.
Retain the original tests and update documentation.

Original implementation, tests and documentation by PatrickNoFilter.
Source-PR: https://github.com/Twigpine/zero/pull/504
Source-Head: 295b9d2a91
Integration: two equivalent strings.Contains substitutions satisfy current
advisory lint in the copied help test.

Fixes #507

* fix(update): normalize credentials and address review findings

* fix(update): enforce exact-host credentials on redirects

---------

Co-authored-by: PatrickNoFilter <[email protected]>
2026-11-09 16:56:44 -08:00
15ff1704a4 fix(codex): forward tool input that only arrives whole on an output item (#1215)
The Codex Responses handler kept arguments that arrived only on
response.output_item.done (and custom tool input that arrived on
response.output_item.added) in the builder for history replay, but never
emitted them as tool-call deltas, so the runtime executed the call with an
empty argument string ("path is required").

Route every builder write through one forwardInput helper that also emits
the delta, so the builder holds exactly what the runtime has received. The
added handler forwards whole custom input, the delta handler forwards each
fragment, and the done handler forwards the final input only when nothing
reached the runtime yet, so streamed fragments are never duplicated.

Handler-level coverage spans function and custom calls, with and without
the added item, streamed or final-only, plus custom input on the added
item; a second test drives the same shapes through StreamCompletion and
zeroruntime.CollectStream. Against the unfixed handler the final-only and
input-on-added cases report an empty argument string at tool-call-end.

Closes #1194

Co-authored-by: oniisanlabs <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-09 15:33:03 -08:00
7 changed files with 759 additions and 23 deletions

No files matched your search

+25
View File
@@ -75,6 +75,31 @@ Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
instead of reinstalling.
## Authentication
Unauthenticated GitHub API requests are subject to strict
[rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).
If you see `403 Forbidden` during update checks, set a GitHub personal access token:
| Environment variable | Role |
|---|---|
| `ZERO_GITHUB_TOKEN` | Used for update checks (takes precedence) |
| `GITHUB_TOKEN` | Fallback when `ZERO_GITHUB_TOKEN` is empty after trimming |
Surrounding whitespace is removed from both token values before use.
Note: `GITHUB_TOKEN` is also used by the GitHub Models provider
(the `github` descriptor in `internal/providercatalog/catalog.go`); both
recipients are GitHub so no trust boundary is crossed, but a token set for
the provider will also be sent on update checks.
Tokens are only sent to HTTPS URLs with the hostname `api.github.com`, including
URLs selected through `--endpoint` or `ZERO_UPDATE_RELEASE_URL`. Other hosts
and plain HTTP URLs never receive credentials. Redirects that downgrade to
`http` after a token-eligible GitHub API request are refused, so the bearer
token is never sent in cleartext (Go's standard library would otherwise copy
`Authorization` on same-host redirects based on host alone).
## Windows recovery state (standalone installs)
This section describes Windows only. On Linux and macOS a standalone update
+5 -1
View File
@@ -1596,11 +1596,15 @@ func TestRunUpdateHelpDocumentsCheckFlag(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
for _, want := range []string{"--check", "--repo", "--endpoint", "--timeout", "--target"} {
for _, want := range []string{"--check", "--repo", "--endpoint", "--timeout", "--target", "ZERO_GITHUB_TOKEN", "ZERO_UPDATE_RELEASE_URL"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("expected update help to document %s, got %q", want, stdout.String())
}
}
// The fallback entry must have its own line, apart from the precedence prose.
if !strings.Contains(stdout.String(), "\n GITHUB_TOKEN ") {
t.Fatalf("expected update help to document a standalone GITHUB_TOKEN entry, got %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
+7
View File
@@ -207,6 +207,13 @@ Flags:
--timeout <duration> Release check timeout (default 5s)
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
-h, --help Show this help
Environment:
ZERO_GITHUB_TOKEN Token for update checks (takes precedence over GITHUB_TOKEN)
Only sent to HTTPS URLs on api.github.com, including endpoint overrides;
never sent to other hosts or plain HTTP
GITHUB_TOKEN Fallback token when ZERO_GITHUB_TOKEN is empty after trimming
ZERO_UPDATE_RELEASE_URL Override the release API URL (same as --endpoint)
`)
return err
}
+38 -19
View File
@@ -212,15 +212,34 @@ type errorPayload struct {
// response.output_item.added, the argument deltas, and the final
// response.output_item.done.
type toolCallBuilder struct {
ID string
CallID string
Name string
ID string
CallID string
Name string
// Arguments holds exactly the input already forwarded to the runtime as
// tool-call deltas: every write goes through forwardInput, so an empty
// builder means the runtime has received nothing for this call yet. The
// done handler relies on that to forward input that only arrived whole
// (on the added or done item) without duplicating streamed fragments.
Arguments strings.Builder
Freeform bool
started bool
ended bool
}
// forwardInput appends fragment to the builder and emits the matching
// tool-call delta, keeping the builder and the runtime in step.
func (builder *toolCallBuilder) forwardInput(ctx context.Context, events chan<- zeroruntime.StreamEvent, fragment string) {
if fragment == "" {
return
}
builder.Arguments.WriteString(fragment)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: builder.ID,
ArgumentsFragment: fragment,
})
}
// responsesState tracks in-flight tool calls and whether the terminal
// event has been emitted. It is the Responses-API equivalent of the
// chat-completions toolState in provider.go.
@@ -726,9 +745,6 @@ func (p *CodexProvider) handleOutputItemAdded(
builder.Name = event.Item.Name
}
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
if builder.Freeform && event.Item.Input != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(event.Item.Input)
}
state.emitted = true
if !builder.started {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
@@ -740,6 +756,11 @@ func (p *CodexProvider) handleOutputItemAdded(
})
builder.started = true
}
// A custom tool call may arrive with its whole input on the added item
// instead of input deltas; the runtime needs it as a delta either way.
if builder.Freeform && builder.Arguments.Len() == 0 {
builder.forwardInput(ctx, events, event.Item.Input)
}
}
}
@@ -773,13 +794,8 @@ func (p *CodexProvider) handleToolInputDelta(
if event.CallID != "" && builder.CallID == "" {
builder.CallID = event.CallID
}
builder.Arguments.WriteString(event.Delta)
state.emitted = true
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: key,
ArgumentsFragment: event.Delta,
})
builder.forwardInput(ctx, events, event.Delta)
}
// handleOutputItemDone finalizes a tool call when the item type is
@@ -813,13 +829,6 @@ func (p *CodexProvider) handleOutputItemDone(
builder.CallID = event.Item.CallID
}
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
finalInput := event.Item.Arguments
if builder.Freeform {
finalInput = event.Item.Input
}
if finalInput != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(finalInput)
}
if !builder.started {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
@@ -830,6 +839,16 @@ func (p *CodexProvider) handleOutputItemDone(
})
builder.started = true
}
// The done item repeats the complete input. When nothing reached the
// runtime through deltas (or the added item), this is the only copy, so
// forward it before closing the call; otherwise the streamed copy stands.
if builder.Arguments.Len() == 0 {
finalInput := event.Item.Arguments
if builder.Freeform {
finalInput = event.Item.Input
}
builder.forwardInput(ctx, events, finalInput)
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallEnd,
ToolCallID: key,
+139
View File
@@ -903,6 +903,145 @@ func TestCodexProviderParsesResponsesToolCalls(t *testing.T) {
}
}
// The runtime only sees tool input through tool-call deltas. The Codex
// backend may deliver that input incrementally, whole on the done item, or
// (for custom tool calls) whole on the added item; whichever way it arrives,
// the deltas before tool-call-end must carry it exactly once, and the replayed
// history item must hold the same input.
func TestCodexOutputItemDoneForwardsUnstreamedArguments(t *testing.T) {
t.Parallel()
const (
functionArguments = `{"path":"README.md"}`
customInput = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
)
cases := []struct {
name string
freeform bool
added bool // response.output_item.added precedes the input
addedInput bool // the added item already carries the whole input
streamed bool // input arrives through *_delta events
}{
{name: "function/added/streamed", added: true, streamed: true},
{name: "function/added/final-only", added: true},
{name: "function/no-added/streamed", streamed: true},
{name: "function/no-added/final-only"},
{name: "custom/added/streamed", freeform: true, added: true, streamed: true},
{name: "custom/added/final-only", freeform: true, added: true},
{name: "custom/no-added/streamed", freeform: true, streamed: true},
{name: "custom/no-added/final-only", freeform: true},
{name: "custom/input-on-added", freeform: true, added: true, addedInput: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
state := newResponsesState()
events := make(chan zeroruntime.StreamEvent, 10)
provider := &CodexProvider{}
item := &itemPayload{Type: "function_call", ID: "item-1", CallID: "call-1", Name: "read_file", Arguments: functionArguments}
want := functionArguments
if tc.freeform {
item.Type = "custom_tool_call"
item.Name = "apply_patch"
item.Arguments = ""
item.Input = customInput
want = customInput
}
ctx := context.Background()
if tc.added {
initial := *item
if !tc.addedInput {
initial.Arguments = ""
initial.Input = ""
}
provider.handleOutputItemAdded(ctx, &responsesEvent{Item: &initial}, state, events)
}
if tc.streamed {
provider.handleToolInputDelta(ctx, &responsesEvent{ItemID: item.ID, Delta: want}, state, events, tc.freeform)
}
provider.handleOutputItemDone(ctx, &responsesEvent{Item: item}, state, events)
close(events)
var arguments strings.Builder
var starts, ends int
for event := range events {
switch event.Type {
case zeroruntime.StreamEventToolCallStart:
starts++
case zeroruntime.StreamEventToolCallDelta:
arguments.WriteString(event.ArgumentsFragment)
case zeroruntime.StreamEventToolCallEnd:
ends++
if arguments.String() != want {
t.Errorf("arguments at tool-call-end = %q, want %q", arguments.String(), want)
}
}
if event.ToolCallID != item.ID {
t.Errorf("tool call ID = %q, want %q", event.ToolCallID, item.ID)
}
}
if starts != 1 || ends != 1 {
t.Errorf("starts=%d ends=%d, want 1 each", starts, ends)
}
got := state.outputInputItems()
if len(got) != 1 {
t.Fatalf("output items = %v, want one tool call", got)
}
replayed := got[0].Arguments
if tc.freeform {
replayed = got[0].Input
}
if replayed != want {
t.Errorf("replayed history input = %q, want %q", replayed, want)
}
})
}
}
// The same cases through the runtime's collector, which is what the agent
// loop consumes: a function call whose arguments exist only on the done item
// and a custom tool call whose input exists only on the added item both reach
// the runtime with their input intact.
func TestCodexStreamCollectsToolInputDeliveredWhole(t *testing.T) {
t.Parallel()
var rec codexRequest
srv := newCodexResponsesServer(t, &rec,
`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"read_file"}}`,
`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"read_file","arguments":"{\"path\":\"README.md\"}"}}`,
`{"type":"response.output_item.added","output_index":1,"item":{"type":"custom_tool_call","id":"ct-1","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch\n"}}`,
`{"type":"response.output_item.done","output_index":1,"item":{"type":"custom_tool_call","id":"ct-1","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch\n"}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed","usage":{"input_tokens":3,"output_tokens":2}}}`,
)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "read the readme"}},
Tools: []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
{Name: "apply_patch", Type: zeroruntime.ToolDefinitionFreeform},
},
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
collected := zeroruntime.CollectStream(context.Background(), stream)
if collected.Error != "" {
t.Fatalf("unexpected stream error: %q", collected.Error)
}
if len(collected.ToolCalls) != 2 {
t.Fatalf("tool calls = %+v, want read_file and apply_patch", collected.ToolCalls)
}
if call := collected.ToolCalls[0]; call.Name != "read_file" || call.Arguments != `{"path":"README.md"}` || call.Freeform {
t.Errorf("function call = %+v, want read_file with the done item's arguments", call)
}
if call := collected.ToolCalls[1]; call.Name != "apply_patch" || call.Arguments != "*** Begin Patch\n*** End Patch\n" || !call.Freeform {
t.Errorf("custom call = %+v, want apply_patch with the added item's input", call)
}
}
func TestCodexProviderSendsAssistantToolCallsAsInputItems(t *testing.T) {
t.Parallel()
// When the runtime replays a prior assistant turn that issued a tool
+499
View File
@@ -0,0 +1,499 @@
package update
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// releaseJSON returns a minimal GitHub release JSON body.
func releaseJSON(tag string) string {
return fmt.Sprintf(`{"tag_name":"%s","html_url":"https://example.test/release","assets":[{"name":"%s-linux-x64.tar.gz","browser_download_url":"https://example.test/%s-linux-x64.tar.gz"}]}`, tag, tag, tag)
}
// fakeTransport records the Authorization header from the first request it receives
// and returns a canned response. Used to verify auth is sent for GitHub URLs
// without needing a real TLS server.
type fakeTransport struct {
gotAuth string
statusCode int
body string
}
func (ft *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
ft.gotAuth = req.Header.Get("Authorization")
if ft.statusCode == 0 {
ft.statusCode = 200
}
if ft.body == "" {
ft.body = releaseJSON("v0.2.0")
}
return &http.Response{
StatusCode: ft.statusCode,
Body: io.NopCloser(strings.NewReader(ft.body)),
Header: make(http.Header),
Request: req,
}, nil
}
func TestFetchReleaseSendsAuthToHttpsGithub(t *testing.T) {
ft := &fakeTransport{}
old := httpClient
httpClient = &http.Client{Transport: ft}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "zero_token")
t.Setenv(EnvGitHubToken, "github_fallback")
_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Fatalf("fetchRelease error: %v", err)
}
if ft.gotAuth != "Bearer zero_token" {
t.Fatalf("expected Bearer zero_token, got %q", ft.gotAuth)
}
}
func TestFetchReleaseSendsAuthCaseInsensitive(t *testing.T) {
ft := &fakeTransport{}
old := httpClient
httpClient = &http.Client{Transport: ft}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "zero_token")
t.Setenv(EnvGitHubToken, "")
_, err := fetchRelease(context.Background(), "https://API.GITHUB.COM/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Fatalf("fetchRelease error: %v", err)
}
if ft.gotAuth != "Bearer zero_token" {
t.Fatalf("expected Bearer zero_token, got %q", ft.gotAuth)
}
}
func TestFetchReleaseFallsBackToGithubToken(t *testing.T) {
ft := &fakeTransport{}
old := httpClient
httpClient = &http.Client{Transport: ft}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvGitHubToken, "fallback_token")
t.Setenv(EnvUpdateToken, "")
_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Fatalf("fetchRelease error: %v", err)
}
if ft.gotAuth != "Bearer fallback_token" {
t.Fatalf("expected Bearer fallback_token, got %q", ft.gotAuth)
}
}
func TestFetchReleaseNormalizesTokens(t *testing.T) {
for _, tc := range []struct {
name string
updateToken string
githubToken string
wantAuth string
}{
{"blank preferred falls back", " \t\n", "fallback_token", "Bearer fallback_token"},
{"trim preferred", " \tzero_token\r\n", "fallback_token", "Bearer zero_token"},
{"trim fallback", "", " \tfallback_token\r\n", "Bearer fallback_token"},
{"both blank", " \t\n", "\r\n ", ""},
} {
t.Run(tc.name, func(t *testing.T) {
ft := &fakeTransport{}
old := httpClient
httpClient = &http.Client{Transport: ft}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, tc.updateToken)
t.Setenv(EnvGitHubToken, tc.githubToken)
_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Fatalf("fetchRelease error: %v", err)
}
if ft.gotAuth != tc.wantAuth {
t.Fatalf("Authorization = %q, want %q", ft.gotAuth, tc.wantAuth)
}
})
}
}
func TestFetchReleaseNoAuthToCustomEndpoint(t *testing.T) {
var gotAuth string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, releaseJSON("v0.2.0"))
}))
defer srv.Close()
old := httpClient
httpClient = srv.Client()
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "secret")
t.Setenv(EnvGitHubToken, "fallback")
_, err := fetchRelease(context.Background(), srv.URL+"/releases/latest")
if err != nil {
t.Fatalf("fetchRelease error: %v", err)
}
if gotAuth != "" {
t.Fatalf("expected no auth for non-GitHub endpoint, got %q", gotAuth)
}
}
func TestFetchReleaseNoAuthToHttpGithub(t *testing.T) {
// Verify the scheme guard independently of the hostname guard: an http://
// api.github.com request must not carry a token even when tokens are
// configured. Use the transport seam with the logical URL so the
// githubAPIToken precondition (https + api.github.com) is exercised.
calls := 0
var firstAuth, firstURL string
tr := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls == 1 {
firstAuth = req.Header.Get("Authorization")
firstURL = req.URL.String()
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(releaseJSON("v0.2.0"))),
Header: make(http.Header),
Request: req,
}, nil
})
old := httpClient
httpClient = &http.Client{Transport: tr}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "secret")
t.Setenv(EnvGitHubToken, "fallback")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := fetchRelease(ctx, "http://api.github.com/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Fatalf("fetchRelease error: %v", err)
}
if calls != 1 {
t.Fatalf("expected 1 request, got %d", calls)
}
if firstAuth != "" {
t.Fatalf("auth should not be sent over HTTP even with tokens: got %q", firstAuth)
}
if firstURL != "http://api.github.com/repos/Gitlawb/zero/releases/latest" {
t.Fatalf("unexpected URL %q", firstURL)
}
}
// roundTripperFunc adapts a function to http.RoundTripper.
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestFetchReleaseNoAuthWhenTokensNotSet(t *testing.T) {
ft := &fakeTransport{}
old := httpClient
httpClient = &http.Client{Transport: ft}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "")
t.Setenv(EnvGitHubToken, "")
_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Fatalf("fetchRelease error: %v", err)
}
if ft.gotAuth != "" {
t.Fatalf("expected no auth when no tokens set, got %q", ft.gotAuth)
}
}
func TestFetchReleaseRefusesRedirectToHttp(t *testing.T) {
calls := 0
var firstAuth, firstURL string
tr := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls == 1 {
firstAuth = req.Header.Get("Authorization")
firstURL = req.URL.String()
return &http.Response{
StatusCode: 301,
Header: http.Header{"Location": []string{"http://api.github.com/repos/Gitlawb/zero/releases/latest"}},
Body: io.NopCloser(strings.NewReader("")),
Request: req,
}, nil
}
t.Fatalf("HTTP downgrade destination should not be contacted: %s %s calls=%d", req.Method, req.URL.String(), calls)
return nil, fmt.Errorf("unexpected request to HTTP destination")
})
old := httpClient
httpClient = &http.Client{Transport: tr}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "secret")
t.Setenv(EnvGitHubToken, "")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := fetchRelease(ctx, "https://api.github.com/repos/Gitlawb/zero/releases/latest")
if err == nil || !strings.Contains(err.Error(), "refusing redirect") {
t.Fatalf("expected redirect refusal error, got %v", err)
}
if calls != 1 {
t.Fatalf("expected 1 transport call, HTTP destination should not be contacted: got %d", calls)
}
if firstAuth != "Bearer secret" {
t.Fatalf("initial HTTPS request should carry Bearer token: got %q", firstAuth)
}
if firstURL != "https://api.github.com/repos/Gitlawb/zero/releases/latest" {
t.Fatalf("unexpected initial URL %q", firstURL)
}
}
func TestFetchReleaseRejectsUserinfoHostTrick(t *testing.T) {
// Userinfo trick: "https://[email protected]/..." — the Go
// URL parser treats "api.github.com" as userinfo, making Hostname() =
// "evil.example.com". githubAPIToken sees a non-GitHub host and returns "",
// so no auth is sent.
ft := &fakeTransport{}
old := httpClient
httpClient = &http.Client{Transport: ft}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "secret")
t.Setenv(EnvGitHubToken, "")
_, _ = fetchRelease(context.Background(), "https://[email protected]/repos/Gitlawb/zero/releases/latest")
if strings.HasPrefix(ft.gotAuth, "Bearer ") {
t.Fatalf("bearer auth should not be sent when userinfo tricks hostname, got %q", ft.gotAuth)
}
}
// --- Redirect regression tests (jatmn review) ---
func TestHTTPMirrorRedirectAllowedWithoutCredentials(t *testing.T) {
// Mirror server redirects HTTP→HTTP (no credentials on original request).
var gotAuth string
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, releaseJSON("v0.2.0"))
}))
defer mirror.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, mirror.URL+"/releases/latest", http.StatusFound)
}))
defer origin.Close()
old := httpClient
httpClient = origin.Client()
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "")
t.Setenv(EnvGitHubToken, "")
_, err := fetchRelease(context.Background(), origin.URL+"/releases/latest")
if err != nil {
t.Fatalf("HTTP mirror redirect should succeed, got: %v", err)
}
if gotAuth != "" {
t.Fatalf("expected no auth on mirror, got %q", gotAuth)
}
}
func TestHTTPMirrorRedirectAllowedWithDummyTokens(t *testing.T) {
// Mirror redirect works even with tokens set — tokens are not sent to non-GitHub hosts.
var gotAuth string
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, releaseJSON("v0.2.0"))
}))
defer mirror.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, mirror.URL+"/releases/latest", http.StatusFound)
}))
defer origin.Close()
old := httpClient
httpClient = origin.Client()
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "dummy_token")
t.Setenv(EnvGitHubToken, "dummy_fallback")
_, err := fetchRelease(context.Background(), origin.URL+"/releases/latest")
if err != nil {
t.Fatalf("HTTP mirror redirect should succeed even with tokens, got: %v", err)
}
if gotAuth != "" {
t.Fatalf("expected no auth for non-GitHub mirror, got %q", gotAuth)
}
}
func TestHTTPSDowngradeBlockedWhenCredentialsPresent(t *testing.T) {
calls := 0
var firstAuth string
tr := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls == 1 {
firstAuth = req.Header.Get("Authorization")
return &http.Response{
StatusCode: 301,
Header: http.Header{"Location": []string{"http://api.github.com/other"}},
Body: io.NopCloser(strings.NewReader("")),
Request: req,
}, nil
}
t.Fatalf("HTTP downgrade destination should not be contacted: %s %s calls=%d", req.Method, req.URL.String(), calls)
return nil, fmt.Errorf("unexpected request")
})
old := httpClient
httpClient = &http.Client{Transport: tr}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "secret")
t.Setenv(EnvGitHubToken, "fallback_should_not_be_used")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := fetchRelease(ctx, "https://api.github.com/repos/Gitlawb/zero/releases/latest")
if err == nil || !strings.Contains(err.Error(), "refusing redirect") {
t.Fatalf("expected redirect refusal, got %v", err)
}
if calls != 1 {
t.Fatalf("expected 1 call, got %d", calls)
}
if firstAuth != "Bearer secret" {
t.Fatalf("expected Bearer secret on first request, got %q", firstAuth)
}
}
func TestHTTPSSameHostRedirectAllowed(t *testing.T) {
// HTTPS server redirects to same HTTPS host — should succeed.
var gotPath string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.URL.Path == "/repos/Gitlawb/zero/releases" {
http.Redirect(w, r, "/repos/Gitlawb/zero/releases/latest", http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, releaseJSON("v0.2.0"))
}))
defer srv.Close()
old := httpClient
httpClient = srv.Client()
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "token123")
t.Setenv(EnvGitHubToken, "")
_, err := fetchRelease(context.Background(), srv.URL+"/repos/Gitlawb/zero/releases")
if err != nil {
t.Fatalf("HTTPS→HTTPS same-host redirect should succeed, got: %v", err)
}
if gotPath != "/repos/Gitlawb/zero/releases/latest" {
t.Fatalf("expected final path /repos/Gitlawb/zero/releases/latest, got %q", gotPath)
}
}
func TestFetchReleaseAuthenticatedHTTPSRedirects(t *testing.T) {
for _, tc := range []struct {
name string
location string
wantAuth string
}{
{"same host", "https://api.github.com/redirected", "Bearer secret"},
{"other host", "https://mirror.example.test/redirected", ""},
{"subdomain", "https://sub.api.github.com/redirected", ""},
} {
t.Run(tc.name, func(t *testing.T) {
var urls, auth []string
tr := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
urls = append(urls, req.URL.String())
auth = append(auth, req.Header.Get("Authorization"))
response := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(releaseJSON("v0.2.0"))),
Request: req,
}
if len(urls) == 1 {
response.StatusCode = http.StatusFound
response.Header.Set("Location", tc.location)
}
return response, nil
})
old := httpClient
httpClient = &http.Client{Transport: tr}
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "secret")
t.Setenv(EnvGitHubToken, "")
const endpoint = "https://api.github.com/repos/Gitlawb/zero/releases/latest"
release, err := fetchRelease(context.Background(), endpoint)
if err != nil {
t.Fatalf("fetchRelease error: %v", err)
}
if len(urls) != 2 || urls[0] != endpoint || urls[1] != tc.location {
t.Fatalf("unexpected redirect route: %v", urls)
}
if auth[0] != "Bearer secret" || auth[1] != tc.wantAuth {
t.Fatalf("Authorization per hop = %q, want [%q %q]", auth, "Bearer secret", tc.wantAuth)
}
if release.TagName != "v0.2.0" {
t.Fatalf("release tag = %q, want v0.2.0", release.TagName)
}
})
}
}
func TestRedirectLimitEnforced(t *testing.T) {
count := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
if count > 10 {
http.Error(w, "too many", http.StatusLoopDetected)
return
}
http.Redirect(w, r, "/loop", http.StatusFound)
}))
defer srv.Close()
old := httpClient
httpClient = srv.Client()
t.Cleanup(func() { httpClient = old })
t.Setenv(EnvUpdateToken, "")
t.Setenv(EnvGitHubToken, "")
_, err := fetchRelease(context.Background(), srv.URL+"/loop")
if err == nil {
t.Fatal("expected error for redirect loop")
}
if !strings.Contains(err.Error(), "10 redirects") {
t.Fatalf("expected redirect limit error, got: %v", err)
}
}
+46 -3
View File
@@ -3,6 +3,7 @@ package update
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -17,8 +18,19 @@ import (
const (
DefaultRepository = "Twigpine/zero"
DefaultTimeout = 5 * time.Second
// EnvUpdateToken is read by fetchRelease to authenticate GitHub API
// requests. ZERO_GITHUB_TOKEN takes precedence over GITHUB_TOKEN.
EnvUpdateToken = "ZERO_GITHUB_TOKEN"
// EnvGitHubToken is the fallback token variable used for GitHub API requests.
EnvGitHubToken = "GITHUB_TOKEN"
// EnvUpdateReleaseURL overrides the release API endpoint.
EnvUpdateReleaseURL = "ZERO_UPDATE_RELEASE_URL"
)
// httpClient is the client used by fetchRelease. Overridden in tests to
// intercept requests without mutating the global http.DefaultClient.
var httpClient = http.DefaultClient
type Release struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
@@ -129,7 +141,7 @@ func Check(ctx context.Context, options Options) (Result, error) {
currentVersion = "0.0.0"
}
repository := strings.TrimSpace(firstNonEmpty(options.Repository, DefaultRepository))
endpoint, err := resolveEndpoint(firstNonEmpty(options.Endpoint, os.Getenv("ZERO_UPDATE_RELEASE_URL")), repository)
endpoint, err := resolveEndpoint(firstNonEmpty(options.Endpoint, os.Getenv(EnvUpdateReleaseURL)), repository)
if err != nil {
return Result{}, err
}
@@ -203,7 +215,7 @@ func upgradeSourceFlag(options Options) string {
if endpoint := strings.TrimSpace(options.Endpoint); endpoint != "" {
return "--endpoint " + shellQuote(endpoint)
}
if strings.TrimSpace(os.Getenv("ZERO_UPDATE_RELEASE_URL")) != "" {
if strings.TrimSpace(os.Getenv(EnvUpdateReleaseURL)) != "" {
return ""
}
if repository := strings.TrimSpace(options.Repository); repository != "" && repository != DefaultRepository {
@@ -340,6 +352,14 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallM
return "Run `zero upgrade` to download, verify, and install the latest release."
}
// githubAPIToken selects a trimmed token only for HTTPS GitHub API URLs.
func githubAPIToken(u *url.URL) string {
if u.Scheme != "https" || !strings.EqualFold(u.Hostname(), "api.github.com") {
return ""
}
return firstNonEmpty(os.Getenv(EnvUpdateToken), os.Getenv(EnvGitHubToken))
}
func fetchRelease(ctx context.Context, endpoint string) (release Release, err error) {
if strings.HasPrefix(endpoint, "data:") {
return fetchDataRelease(endpoint)
@@ -350,7 +370,30 @@ func fetchRelease(ctx context.Context, endpoint string) (release Release, err er
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("User-Agent", "zero/update")
response, err := http.DefaultClient.Do(request)
if token := githubAPIToken(request.URL); token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{
Transport: httpClient.Transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if req.URL.Scheme != "https" {
for _, prev := range via {
if githubAPIToken(prev.URL) != "" {
return errors.New("refusing redirect to non-HTTPS URL")
}
}
}
// net/http also forwards Authorization to subdomains; keep the exact-host boundary.
if req.URL.Scheme != "https" || !strings.EqualFold(req.URL.Hostname(), "api.github.com") {
req.Header.Del("Authorization")
}
return nil
},
}
response, err := client.Do(request)
if err != nil {
return Release{}, err
}