package selfupdate import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" ) // Repo is the release source. Hardcoded rather than configurable: a configurable // update source hands anyone who can change the config a remote code execution // channel — a door a penetration testing platform must never open. const Repo = "Autumn-27/artex" // latestURL is GitHub's "latest stable release" endpoint. It automatically skips prereleases and drafts. const latestURL = "https://api.github.com/repos/" + Repo + "/releases/latest" // allowedHosts restricts the domains the upgrade chain may visit. Together with // checkRedirect below, any hop redirected to a host outside this list fails // immediately — this is the first gate against DNS poisoning / MITM swapping out // the binary; the second is the SHA256SUMS comparison. var allowedHosts = map[string]bool{ "api.github.com": true, "github.com": true, "objects.githubusercontent.com": true, // the object storage where release assets actually land "release-assets.githubusercontent.com": true, "raw.githubusercontent.com": true, } // Release holds the fields we care about in a GitHub Release. type Release struct { TagName string `json:"tag_name"` Name string `json:"name"` Body string `json:"body"` Draft bool `json:"draft"` Prerelease bool `json:"prerelease"` PublishedAt time.Time `json:"published_at"` HTMLURL string `json:"html_url"` Assets []Asset `json:"assets"` } // Asset is a file attached to a Release. type Asset struct { Name string `json:"name"` URL string `json:"browser_download_url"` Size int64 `json:"size"` } // NewClient builds an HTTP client that only trusts GitHub domains. Direct // connection when proxy is empty. // // Deliberately does not reuse the default Transport: the upgrade chain must force // TLS with certificate verification, and must not be affected by things like // InsecureSkipVerify set elsewhere. func NewClient(proxy string) *http.Client { tr := &http.Transport{ ForceAttemptHTTP2: true, TLSHandshakeTimeout: 15 * time.Second, } if p := strings.TrimSpace(proxy); p != "" { if pu, err := url.Parse(p); err == nil { tr.Proxy = http.ProxyURL(pu) } } return &http.Client{ Transport: tr, Timeout: 30 * time.Minute, // downloading a full package must not be killed by a request-level timeout CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") } return checkURL(req.URL) }, } } // checkURL enforces https + a domain whitelist. func checkURL(u *url.URL) error { if u.Scheme != "https" { return fmt.Errorf("refusing non-HTTPS address: %s", u.Scheme+"://"+u.Host) } if !allowedHosts[strings.ToLower(u.Hostname())] { return fmt.Errorf("refusing non-GitHub domain: %s", u.Hostname()) } return nil } // FetchLatest queries the latest stable release. func FetchLatest(ctx context.Context, c *http.Client) (*Release, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestURL, nil) if err != nil { return nil, err } if err := checkURL(req.URL); err != nil { return nil, err } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "artex-selfupdate") resp, err := c.Do(req) if err != nil { return nil, fmt.Errorf("failed to reach GitHub (a global proxy can be configured in system settings): %w", err) } defer resp.Body.Close() switch { case resp.StatusCode == http.StatusForbidden, resp.StatusCode == http.StatusTooManyRequests: // Unauthenticated GitHub API allows 60 requests per IP per hour; easy to hit when sharing an egress IP. return nil, fmt.Errorf("GitHub API rate limit (60 per hour), please try again later") case resp.StatusCode == http.StatusNotFound: return nil, fmt.Errorf("repository %s has no stable release yet", Repo) case resp.StatusCode != http.StatusOK: return nil, fmt.Errorf("GitHub returned %d", resp.StatusCode) } var rel Release if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { return nil, fmt.Errorf("failed to parse Release: %w", err) } if strings.TrimSpace(rel.TagName) == "" { return nil, fmt.Errorf("Release is missing a tag") } return &rel, nil } // AssetName returns the release package name for the current platform, matching // build.sh's package_binary: artex---.zip (version without the v prefix). func AssetName(tag, goos, goarch string) string { return fmt.Sprintf("artex-%s-%s-%s.zip", strings.TrimPrefix(tag, "v"), goos, goarch) } // FindAsset finds an asset by name in the Release. func (r *Release) FindAsset(name string) (Asset, bool) { for _, a := range r.Assets { if strings.EqualFold(a.Name, name) { return a, true } } return Asset{}, false }