From 510ee451bd9e42682838972f64bd1faf027bd244 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sun, 19 Jul 2026 11:02:56 +0800 Subject: [PATCH] feat: support GitHub token for update checks --- .../repository/github_release_service.go | 51 +++++++-- .../repository/github_release_service_test.go | 92 ++++++++++++++++ deploy/.env.example | 4 + deploy/README.md | 1 + deploy/docker-compose.local.yml | 1 + deploy/docker-compose.standalone.yml | 1 + deploy/docker-compose.yml | 1 + deploy/install.sh | 54 ++++++++- deploy/tests/install-github-token-test.sh | 103 ++++++++++++++++++ 9 files changed, 298 insertions(+), 10 deletions(-) create mode 100755 deploy/tests/install-github-token-test.sh diff --git a/backend/internal/repository/github_release_service.go b/backend/internal/repository/github_release_service.go index 85fab2b34..282a21536 100644 --- a/backend/internal/repository/github_release_service.go +++ b/backend/internal/repository/github_release_service.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "os" "strings" "time" @@ -18,6 +19,7 @@ import ( type githubReleaseClient struct { httpClient *http.Client downloadHTTPClient *http.Client + updateGitHubToken string } type githubReleaseClientError struct { @@ -43,6 +45,8 @@ func NewGitHubReleaseClient(proxyURL string, allowDirectOnProxyError bool) servi } sharedClient = &http.Client{Timeout: 30 * time.Second} } + apiClient := cloneHTTPClient(sharedClient) + apiClient.CheckRedirect = githubAPICheckRedirect(apiClient.CheckRedirect) // 下载客户端需要更长的超时时间 downloadClient, err := httpclient.GetClient(httpclient.Options{ @@ -56,13 +60,50 @@ func NewGitHubReleaseClient(proxyURL string, allowDirectOnProxyError bool) servi } downloadClient = &http.Client{Timeout: 10 * time.Minute} } + downloadClient = cloneHTTPClient(downloadClient) return &githubReleaseClient{ - httpClient: sharedClient, + httpClient: apiClient, downloadHTTPClient: downloadClient, + updateGitHubToken: os.Getenv("UPDATE_GITHUB_TOKEN"), } } +func cloneHTTPClient(client *http.Client) *http.Client { + cloned := *client + return &cloned +} + +func isGitHubAPIURL(url *url.URL) bool { + return url != nil && strings.EqualFold(url.Scheme, "https") && url.User == nil && + strings.EqualFold(url.Host, "api.github.com") +} + +func githubAPICheckRedirect(previous func(*http.Request, []*http.Request) error) func(*http.Request, []*http.Request) error { + return func(req *http.Request, via []*http.Request) error { + if !isGitHubAPIURL(req.URL) { + req.Header.Del("Authorization") + } + if previous != nil { + return previous(req, via) + } + return nil + } +} + +func (c *githubReleaseClient) newAPIRequest(ctx context.Context, url string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", "Sub2API-Updater") + if c.updateGitHubToken != "" && isGitHubAPIURL(req.URL) { + req.Header.Set("Authorization", "Bearer "+c.updateGitHubToken) + } + return req, nil +} + func (c *githubReleaseClientError) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) { return nil, c.err } @@ -82,12 +123,10 @@ func (c *githubReleaseClientError) FetchChecksumFile(ctx context.Context, url st func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := c.newAPIRequest(ctx, url) if err != nil { return nil, err } - req.Header.Set("Accept", "application/vnd.github.v3+json") - req.Header.Set("User-Agent", "Sub2API-Updater") resp, err := c.httpClient.Do(req) if err != nil { @@ -116,12 +155,10 @@ func (c *githubReleaseClient) FetchRecentReleases(ctx context.Context, repo stri } url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, perPage) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := c.newAPIRequest(ctx, url) if err != nil { return nil, err } - req.Header.Set("Accept", "application/vnd.github.v3+json") - req.Header.Set("User-Agent", "Sub2API-Updater") resp, err := c.httpClient.Do(req) if err != nil { diff --git a/backend/internal/repository/github_release_service_test.go b/backend/internal/repository/github_release_service_test.go index abc9a604e..3f93f0dfb 100644 --- a/backend/internal/repository/github_release_service_test.go +++ b/backend/internal/repository/github_release_service_test.go @@ -3,6 +3,7 @@ package repository import ( "bytes" "context" + "io" "net/http" "net/http/httptest" "os" @@ -47,6 +48,97 @@ func newTestGitHubReleaseClient() *githubReleaseClient { } } +func TestGitHubReleaseClientAPIRequestAuthorization(t *testing.T) { + tests := []struct { + name string + url string + wantAuth string + }{ + {name: "exact HTTPS authority", url: "https://api.github.com/repos/test/repo", wantAuth: "Bearer update-secret"}, + {name: "HTTP", url: "http://api.github.com/repos/test/repo"}, + {name: "subdomain", url: "https://sub.api.github.com/repos/test/repo"}, + {name: "userinfo", url: "https://user@api.github.com/repos/test/repo"}, + {name: "explicit default port", url: "https://api.github.com:443/repos/test/repo"}, + {name: "custom port", url: "https://api.github.com:8443/repos/test/repo"}, + {name: "different host", url: "https://github.com/test/repo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newTestGitHubReleaseClient() + client.updateGitHubToken = "update-secret" + req, err := client.newAPIRequest(context.Background(), tt.url) + require.NoError(t, err) + require.Equal(t, tt.wantAuth, req.Header.Get("Authorization")) + }) + } + + client := newTestGitHubReleaseClient() + req, err := client.newAPIRequest(context.Background(), "https://api.github.com/repos/test/repo") + require.NoError(t, err) + require.Empty(t, req.Header.Get("Authorization")) +} + +func TestGitHubReleaseClientRedirectAuthorization(t *testing.T) { + tests := []struct { + name string + url string + wantAuth string + }{ + {name: "same HTTPS authority", url: "https://api.github.com/redirected", wantAuth: "Bearer update-secret"}, + {name: "HTTP", url: "http://api.github.com/redirected"}, + {name: "subdomain", url: "https://sub.api.github.com/redirected"}, + {name: "userinfo", url: "https://user@api.github.com/redirected"}, + {name: "custom port", url: "https://api.github.com:8443/redirected"}, + {name: "different host", url: "https://example.com/redirected"}, + } + + checkRedirect := githubAPICheckRedirect(nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, tt.url, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer update-secret") + + require.NoError(t, checkRedirect(req, nil)) + require.Equal(t, tt.wantAuth, req.Header.Get("Authorization")) + }) + } +} + +func TestGitHubReleaseClientDoesNotAuthorizeDownloads(t *testing.T) { + client := newTestGitHubReleaseClient() + client.updateGitHubToken = "update-secret" + + var headers []http.Header + transport := githubReleaseRoundTripFunc(func(req *http.Request) (*http.Response, error) { + headers = append(headers, req.Header.Clone()) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("checksum")), + Request: req, + }, nil + }) + client.httpClient.Transport = transport + client.downloadHTTPClient.Transport = transport + + dest := filepath.Join(t.TempDir(), "asset") + require.NoError(t, client.DownloadFile(context.Background(), "https://objects.githubusercontent.com/asset", dest, 100)) + _, err := client.FetchChecksumFile(context.Background(), "https://github.com/test/repo/releases/download/v1/checksums.txt") + require.NoError(t, err) + require.Len(t, headers, 2) + for _, header := range headers { + require.Empty(t, header.Get("Authorization")) + } +} + +type githubReleaseRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f githubReleaseRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + func (s *GitHubReleaseServiceSuite) SetupTest() { s.tempDir = s.T().TempDir() } diff --git a/deploy/.env.example b/deploy/.env.example index 57056b490..89b5ea0e9 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -23,6 +23,10 @@ SERVER_PORT=8080 # Server mode: release or debug SERVER_MODE=release +# Optional token used only for GitHub Release API update checks. Release asset +# downloads remain anonymous. GITHUB_TOKEN and GH_TOKEN are not used. +UPDATE_GITHUB_TOKEN= + # Return Server-Timing for authenticated requests made by the Admin web UI ENABLE_SERVER_TIMING=false diff --git a/deploy/README.md b/deploy/README.md index 84050b188..e87f33fa2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -238,6 +238,7 @@ docker compose down -v | `ADMIN_EMAIL` | No | `admin@sub2api.local` | Admin email | | `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password | | `TZ` | No | `Asia/Shanghai` | Timezone | +| `UPDATE_GITHUB_TOKEN` | No | *(empty)* | Token for `api.github.com` release checks only; asset downloads remain anonymous. | | `GEMINI_OAUTH_CLIENT_ID` | No | *(builtin)* | Google OAuth client ID (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. | | `GEMINI_OAUTH_CLIENT_SECRET` | No | *(builtin)* | Google OAuth client secret (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. | | `GEMINI_OAUTH_SCOPES` | No | *(default)* | OAuth scopes (Gemini OAuth) | diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 5fb161603..137d42c8d 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -53,6 +53,7 @@ services: - SERVER_MODE=${SERVER_MODE:-release} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - RUN_MODE=${RUN_MODE:-standard} + - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} # ======================================================================= # Database Configuration (PostgreSQL) diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index 40ed4751d..c5f0be7e6 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -39,6 +39,7 @@ services: - SERVER_MODE=${SERVER_MODE:-release} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - RUN_MODE=${RUN_MODE:-standard} + - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} # ======================================================================= # Database Configuration (PostgreSQL) - Required diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e60776e58..7cca28a90 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -49,6 +49,7 @@ services: - SERVER_MODE=${SERVER_MODE:-release} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - RUN_MODE=${RUN_MODE:-standard} + - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} # ======================================================================= # Database Configuration (PostgreSQL) diff --git a/deploy/install.sh b/deploy/install.sh index 1846dede3..246347c62 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -480,10 +480,58 @@ check_dependencies() { fi } +# Authenticate only GitHub REST API requests. Release asset downloads must stay anonymous. +github_api_curl() { + local arg + local expect_value=false + local url + + if [ "$#" -lt 1 ]; then + echo "github_api_curl requires exactly one GitHub API URL" >&2 + return 2 + fi + url="${!#}" + + # Keep authenticated invocations constrained to the options used below. In + # particular, curl config, --url, and --next could add another destination. + for arg in "${@:1:$#-1}"; do + if [ "$expect_value" = true ]; then + expect_value=false + continue + fi + case "$arg" in + -s|--silent) + ;; + --connect-timeout|--max-time|-o|--output|-w|--write-out) + expect_value=true + ;; + *) + echo "Unsafe github_api_curl argument: $arg" >&2 + return 2 + ;; + esac + done + + if [ "$expect_value" = true ] || [[ "$url" != https://api.github.com/* ]]; then + echo "github_api_curl requires exactly one GitHub API URL" >&2 + return 2 + fi + + if [ -n "${UPDATE_GITHUB_TOKEN:-}" ]; then + if [[ "$UPDATE_GITHUB_TOKEN" == *$'\n'* || "$UPDATE_GITHUB_TOKEN" == *$'\r'* || "$UPDATE_GITHUB_TOKEN" == *'"'* || "$UPDATE_GITHUB_TOKEN" == *'\'* ]]; then + echo "UPDATE_GITHUB_TOKEN contains unsupported characters" >&2 + return 2 + fi + printf 'header = "Authorization: Bearer %s"\n' "$UPDATE_GITHUB_TOKEN" | UPDATE_GITHUB_TOKEN= GITHUB_TOKEN= GH_TOKEN= curl -q --globoff --config - "$@" + else + UPDATE_GITHUB_TOKEN= GITHUB_TOKEN= GH_TOKEN= curl -q --globoff "$@" + fi +} + # Get latest release version get_latest_version() { print_info "$(msg 'fetching_version')" - LATEST_VERSION=$(curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') + LATEST_VERSION=$(github_api_curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') if [ -z "$LATEST_VERSION" ]; then print_error "$(msg 'failed_get_version')" @@ -499,7 +547,7 @@ list_versions() { print_info "$(msg 'fetching_versions')" local versions - versions=$(curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases" 2>/dev/null | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/' | head -20) + versions=$(github_api_curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases" 2>/dev/null | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/' | head -20) if [ -z "$versions" ]; then print_error "$(msg 'failed_get_version')" @@ -536,7 +584,7 @@ validate_version() { # Check if the release exists local http_code - http_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${version}" 2>/dev/null) + http_code=$(github_api_curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${version}" 2>/dev/null) # Check for network errors (empty or non-numeric response) if [ -z "$http_code" ] || ! [[ "$http_code" =~ ^[0-9]+$ ]]; then diff --git a/deploy/tests/install-github-token-test.sh b/deploy/tests/install-github-token-test.sh new file mode 100755 index 000000000..4bfdb5fee --- /dev/null +++ b/deploy/tests/install-github-token-test.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT + +cat > "$TEMP_DIR/curl" <<'EOF' +#!/bin/bash +printf '%s\n' "$@" > "$CURL_ARGS_LOG" +env > "${CURL_ARGS_LOG}.env" +cat > "${CURL_ARGS_LOG}.stdin" +EOF +chmod +x "$TEMP_DIR/curl" + +mkdir "$TEMP_DIR/home" +cat > "$TEMP_DIR/home/.curlrc" <<'EOF' +url = "https://example.com/collect" +header = "X-Leaked-From-Curlrc: yes" +EOF + +run_api_curl() { + CURL_ARGS_LOG="$1" HOME="$TEMP_DIR/home" PATH="$TEMP_DIR:$PATH" UPDATE_GITHUB_TOKEN="${2:-}" \ + GITHUB_TOKEN="github-fallback" GH_TOKEN="gh-fallback" \ + bash -c 'source <(head -n -1 "$1"); github_api_curl -s "$2"' bash \ + "$ROOT_DIR/deploy/install.sh" "https://api.github.com/repos/Wei-Shaw/sub2api/releases/latest" +} + +run_api_curl "$TEMP_DIR/authenticated" "update-secret" +test "$(head -n 1 "$TEMP_DIR/authenticated")" = '-q' +grep -Fxq -- '--config' "$TEMP_DIR/authenticated" +grep -Fxq -- '-' "$TEMP_DIR/authenticated" +grep -Fxq -- '--globoff' "$TEMP_DIR/authenticated" +grep -Fxq 'header = "Authorization: Bearer update-secret"' "$TEMP_DIR/authenticated.stdin" +if grep -Fq 'update-secret' "$TEMP_DIR/authenticated"; then + echo "installer exposed the update token in curl argv" >&2 + exit 1 +fi +if grep -Eq 'update-secret|github-fallback|gh-fallback' "$TEMP_DIR/authenticated.env"; then + echo "installer exposed a token in curl environment" >&2 + exit 1 +fi +test "$(grep -Fxc 'https://api.github.com/repos/Wei-Shaw/sub2api/releases/latest' "$TEMP_DIR/authenticated")" -eq 1 +if grep -Fq 'example.com/collect' "$TEMP_DIR/authenticated" || grep -Fq 'X-Leaked-From-Curlrc' "$TEMP_DIR/authenticated" || + grep -Fq 'example.com/collect' "$TEMP_DIR/authenticated.stdin" || grep -Fq 'X-Leaked-From-Curlrc' "$TEMP_DIR/authenticated.stdin"; then + echo "installer allowed hostile curl config into authenticated invocation" >&2 + exit 1 +fi + +run_api_curl "$TEMP_DIR/anonymous" +test "$(head -n 1 "$TEMP_DIR/anonymous")" = '-q' +if grep -Eq 'github-fallback|gh-fallback' "$TEMP_DIR/anonymous.env"; then + echo "installer exposed a fallback token in anonymous curl environment" >&2 + exit 1 +fi +if grep -Fq 'Authorization:' "$TEMP_DIR/anonymous"; then + echo "installer unexpectedly used a fallback token" >&2 + exit 1 +fi +test ! -s "$TEMP_DIR/anonymous.stdin" +test "$(grep -Fxc 'https://api.github.com/repos/Wei-Shaw/sub2api/releases/latest' "$TEMP_DIR/anonymous")" -eq 1 +if grep -Fq 'example.com/collect' "$TEMP_DIR/anonymous" || grep -Fq 'X-Leaked-From-Curlrc' "$TEMP_DIR/anonymous"; then + echo "installer allowed hostile curl config into anonymous invocation" >&2 + exit 1 +fi + +assert_unsafe_invocation_rejected() { + local name=$1 + shift + rm -f "$TEMP_DIR/$name" "$TEMP_DIR/$name.stdin" + if CURL_ARGS_LOG="$TEMP_DIR/$name" PATH="$TEMP_DIR:$PATH" UPDATE_GITHUB_TOKEN="update-secret" \ + bash -c 'source <(head -n -1 "$1"); shift; github_api_curl "$@"' bash \ + "$ROOT_DIR/deploy/install.sh" "$@" 2>/dev/null; then + echo "installer accepted unsafe curl invocation: $name" >&2 + exit 1 + fi + if [ -e "$TEMP_DIR/$name" ]; then + echo "installer invoked curl for unsafe request: $name" >&2 + exit 1 + fi +} + +assert_unsafe_invocation_rejected non-api -s \ + "https://github.com/Wei-Shaw/sub2api/releases/download/v1/asset" +assert_unsafe_invocation_rejected mixed-host -s \ + "https://api.github.com/repos/Wei-Shaw/sub2api/releases/latest" \ + "https://example.com/collect" +assert_unsafe_invocation_rejected multiple-api -s \ + "https://api.github.com/repos/Wei-Shaw/sub2api/releases/latest" \ + "https://api.github.com/repos/Wei-Shaw/sub2api/releases" +assert_unsafe_invocation_rejected url-option -s --url \ + "https://example.com/collect" \ + "https://api.github.com/repos/Wei-Shaw/sub2api/releases/latest" + +# Every installer release API request must use the scoped helper. +test "$(grep -c 'github_api_curl .*https://api.github.com/' "$ROOT_DIR/deploy/install.sh")" -eq 3 + +# Asset and checksum downloads must continue to call curl directly. +grep -Fq 'curl -sL "$download_url"' "$ROOT_DIR/deploy/install.sh" +grep -Fq 'curl -sL "$checksum_url"' "$ROOT_DIR/deploy/install.sh" + +echo "install GitHub token checks passed"