mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Comments, doc comments, UI labels, log and error messages, LLM prompt strings, markdown docs, test data and assertions. Identifiers, enum keys, routes, placeholders and code structure are unchanged. Notes: - docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md - tests that exercised CJK specific handling (GBK zip names, Chinese full text search) now use ASCII equivalents; go build passes and the test suite matches the pre translation baseline (remaining failures need a PostgreSQL DSN and fail identically on the original) - frontend mention syntax now uses English kinds (finding, asset, ...) matching the backend, chat mentions tests pass
144 lines
4.8 KiB
Go
144 lines
4.8 KiB
Go
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-<version>-<os>-<arch>.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
|
|
}
|