fix: use Hinln/ARTEX for authenticated release updates

This commit is contained in:
Hinln committed 2026-10-08 19:05:50 +08:00
1 parent e6ec569991
commit f3e3f54b6c
6 files changed
+223 -15

No files matched your search

+45 -9
View File
@@ -6,13 +6,17 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Repo 是发布源。写死而不是做成配置项:更新源可配等于给任何能改配置的人一条
// 远程代码执行通道,对一个渗透测试平台来说这个口子开不得。
const Repo = "Autumn-27/artex"
const Repo = "Hinln/ARTEX"
// GitHubTokenEnv 只由后端进程读取,不通过前端或配置 API 传递令牌。
const GitHubTokenEnv = "ARTEX_UPDATE_GITHUB_TOKEN"
// latestURL 是 GitHub 的"最新正式版"接口。它会自动跳过 prerelease 和 draft。
const latestURL = "https://api.github.com/repos/" + Repo + "/releases/latest"
@@ -42,9 +46,33 @@ type Release struct {
// Asset 是 Release 上挂的一个文件。
type Asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
Size int64 `json:"size"`
Name string `json:"name"`
URL string `json:"browser_download_url"`
APIURL string `json:"url"`
Size int64 `json:"size"`
}
// 私有仓库的附件需通过 Release Asset API 携带认证下载。
func (a Asset) downloadURL() string {
if a.APIURL != "" {
return a.APIURL
}
return a.URL
}
// setUpdateAuth 仅向固定发布仓库的 HTTPS API 发送令牌。附件重定向到
// GitHub 对象存储时使用签名 URL,不得把 Authorization 一并转发。
func setUpdateAuth(req *http.Request) {
req.Header.Del("Authorization")
u := req.URL
if u.Scheme != "https" || !strings.EqualFold(u.Hostname(), "api.github.com") ||
(u.Port() != "" && u.Port() != "443") || u.User != nil ||
!strings.HasPrefix(strings.ToLower(u.EscapedPath()), strings.ToLower("/repos/"+Repo+"/releases/")) {
return
}
if token := strings.TrimSpace(os.Getenv(GitHubTokenEnv)); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
// NewClient 构造一个只认 GitHub 域名的 HTTP 客户端。proxy 为空则直连。
@@ -68,7 +96,11 @@ func NewClient(proxy string) *http.Client {
if len(via) >= 10 {
return fmt.Errorf("重定向次数过多")
}
return checkURL(req.URL)
if err := checkURL(req.URL); err != nil {
return err
}
setUpdateAuth(req)
return nil
},
}
}
@@ -95,6 +127,7 @@ func FetchLatest(ctx context.Context, c *http.Client) (*Release, error) {
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "artex-selfupdate")
setUpdateAuth(req)
resp, err := c.Do(req)
if err != nil {
@@ -103,11 +136,14 @@ func FetchLatest(ctx context.Context, c *http.Client) (*Release, error) {
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusForbidden, resp.StatusCode == http.StatusTooManyRequests:
// 未认证的 GitHub API 是每 IP 每小时 60 次,共用出口 IP 时很容易撞上。
return nil, fmt.Errorf("GitHub 接口限流(每小时 60 次),请稍后再试")
case resp.StatusCode == http.StatusUnauthorized:
return nil, fmt.Errorf("GitHub 认证失败,请检查后端环境变量 %s", GitHubTokenEnv)
case resp.StatusCode == http.StatusForbidden:
return nil, fmt.Errorf("GitHub 拒绝访问:请检查令牌的仓库 Contents 读取权限或 API 限额")
case resp.StatusCode == http.StatusTooManyRequests:
return nil, fmt.Errorf("GitHub 接口限流,请稍后再试")
case resp.StatusCode == http.StatusNotFound:
return nil, fmt.Errorf("仓库 %s 尚未发布任何正式版本", Repo)
return nil, fmt.Errorf("仓库 %s 不可访问或尚未发布正式版本;私有仓库请在后端设置 %s(需 Contents 读取权限)", Repo, GitHubTokenEnv)
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("GitHub 返回 %d", resp.StatusCode)
}
+151
View File
@@ -0,0 +1,151 @@
package selfupdate
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"testing"
)
type updateTransportFunc func(*http.Request) (*http.Response, error)
func (f updateTransportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func updateResponse(req *http.Request, status int, body string) *http.Response {
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}
}
func TestPrivateReleaseMetadataChecksumsAndDownload(t *testing.T) {
t.Setenv(GitHubTokenEnv, "test-private-release-token")
payload := "release archive bytes"
sum := sha256.Sum256([]byte(payload))
wantSum := hex.EncodeToString(sum[:])
client := NewClient("")
seen := map[string]bool{}
client.Transport = updateTransportFunc(func(req *http.Request) (*http.Response, error) {
seen[req.URL.String()] = true
if req.URL.Host == "api.github.com" {
if req.Header.Get("Authorization") != "Bearer test-private-release-token" {
t.Fatal("private Release API request missing authentication")
}
} else if req.Header.Get("Authorization") != "" {
t.Fatal("token leaked to release object storage")
}
switch req.URL.String() {
case latestURL:
if req.Header.Get("Accept") != "application/vnd.github+json" {
t.Fatal("metadata request must ask for JSON")
}
return updateResponse(req, 200, fmt.Sprintf(`{"tag_name":"v0.3.16","assets":[
{"name":"SHA256SUMS","url":"https://api.github.com/repos/%s/releases/assets/1","browser_download_url":"https://github.com/unusable-sums"},
{"name":"artex-0.3.16-darwin-arm64.zip","url":"https://api.github.com/repos/%s/releases/assets/2","browser_download_url":"https://github.com/unusable-zip","size":%d}
]}`, Repo, Repo, len(payload))), nil
case "https://api.github.com/repos/" + Repo + "/releases/assets/1":
if req.Header.Get("Accept") != "application/octet-stream" {
t.Fatal("checksum request must ask for binary data")
}
return updateResponse(req, 200, wantSum+" artex-0.3.16-darwin-arm64.zip\n"), nil
case "https://api.github.com/repos/" + Repo + "/releases/assets/2":
if req.Header.Get("Accept") != "application/octet-stream" {
t.Fatal("archive request must ask for binary data")
}
resp := updateResponse(req, 302, "")
resp.Header.Set("Location", "https://release-assets.githubusercontent.com/signed-archive")
return resp, nil
case "https://release-assets.githubusercontent.com/signed-archive":
return updateResponse(req, 200, payload), nil
default:
t.Fatalf("unexpected update URL: %s", req.URL)
return nil, nil
}
})
rel, err := FetchLatest(t.Context(), client)
if err != nil {
t.Fatal(err)
}
sums, err := fetchSums(t.Context(), client, rel)
if err != nil {
t.Fatal(err)
}
asset, ok := rel.FindAsset("artex-0.3.16-darwin-arm64.zip")
if !ok {
t.Fatal("archive missing")
}
dst := filepath.Join(t.TempDir(), "release.zip")
got, err := download(t.Context(), client, asset, dst, func(Phase, int, string) {})
if err != nil {
t.Fatal(err)
}
if got != sums[asset.Name] || got != wantSum || readAll(t, dst) != payload {
t.Fatal("downloaded archive/checksum mismatch")
}
if len(seen) != 4 {
t.Fatalf("expected metadata, sums, archive API and CDN requests, got %d", len(seen))
}
}
func TestUpdateTokenNeverLeavesReleaseAPI(t *testing.T) {
t.Setenv(GitHubTokenEnv, "test-token")
for _, raw := range []string{
"https://github.com/" + Repo + "/releases/download/v1/file.zip",
"https://api.github.com/repos/other/repo/releases/assets/1",
"https://api.github.com/repos/" + Repo + "-other/releases/assets/1",
"https://api.github.com:8443/repos/" + Repo + "/releases/assets/1",
"https://api.github.com/repos/" + Repo + "/releases%2Fassets/1",
"https://objects.githubusercontent.com/archive",
"http://api.github.com/repos/" + Repo + "/releases/assets/1",
} {
t.Run(raw, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, raw, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer inherited-token")
setUpdateAuth(req)
if req.Header.Get("Authorization") != "" {
t.Fatal("unsafe destination retained token")
}
})
}
// A same-host redirect must not inherit authorization for a different repo.
req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/repos/other/repo/releases/assets/1", nil)
req.Header.Set("Authorization", "Bearer test-token")
if err := NewClient("").CheckRedirect(req, nil); err != nil {
t.Fatal(err)
}
if req.Header.Get("Authorization") != "" {
t.Fatal("same-host redirect leaked token to another repo")
}
}
func TestPublicReleaseDownloadWithoutToken(t *testing.T) {
t.Setenv(GitHubTokenEnv, "")
asset := Asset{Name: "archive", URL: "https://github.com/" + Repo + "/releases/download/v1/archive.zip"}
client := NewClient("")
client.Transport = updateTransportFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.String() != asset.URL || req.Header.Get("Authorization") != "" {
t.Fatal("invalid public asset request")
}
return updateResponse(req, 200, "archive"), nil
})
if _, err := download(t.Context(), client, asset, filepath.Join(t.TempDir(), "archive"), func(Phase, int, string) {}); err != nil {
t.Fatal(err)
}
}
func TestReleaseAuthenticationErrors(t *testing.T) {
t.Setenv(GitHubTokenEnv, "")
for status, message := range map[int]string{401: GitHubTokenEnv, 403: "Contents", 404: Repo, 429: "限流"} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
client := &http.Client{Transport: updateTransportFunc(func(req *http.Request) (*http.Response, error) { return updateResponse(req, status, "{}"), nil })}
_, err := FetchLatest(t.Context(), client)
if err == nil || !strings.Contains(err.Error(), message) {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
+4 -2
View File
@@ -133,7 +133,7 @@ func fetchSums(ctx context.Context, c *http.Client, rel *Release) (map[string]st
if !ok {
return nil, fmt.Errorf("该 Release 没有 %s,无法校验完整性,拒绝升级", sumsAsset)
}
body, err := get(ctx, c, asset.URL)
body, err := get(ctx, c, asset.downloadURL())
if err != nil {
return nil, fmt.Errorf("下载 %s: %w", sumsAsset, err)
}
@@ -189,7 +189,7 @@ func isHexSHA256(s string) bool {
// download 把资产写入 dst,同时计算 SHA256 并按 Content-Length 汇报进度。
func download(ctx context.Context, c *http.Client, a Asset, dst string, prog Progress) (string, error) {
body, err := get(ctx, c, a.URL)
body, err := get(ctx, c, a.downloadURL())
if err != nil {
return "", fmt.Errorf("下载 %s: %w", a.Name, err)
}
@@ -225,6 +225,8 @@ func get(ctx context.Context, c *http.Client, rawURL string) (io.ReadCloser, err
return nil, err
}
req.Header.Set("User-Agent", "artex-selfupdate")
req.Header.Set("Accept", "application/octet-stream")
setUpdateAuth(req)
resp, err := c.Do(req)
if err != nil {
return nil, err