diff --git a/.depot/workflows/ci.yml b/.depot/workflows/ci.yml index f70ad85..9dedf96 100644 --- a/.depot/workflows/ci.yml +++ b/.depot/workflows/ci.yml @@ -38,6 +38,15 @@ jobs: git diff --exit-code go.mod go.sum - name: go vet run: go vet ./... + - name: Vulnerability scan (all release targets) + run: | + set -eu + go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + scanner="$(go env GOPATH)/bin/govulncheck" + for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + echo "== $platform ==" + GOTOOLCHAIN=local GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./... + done - name: Tests (race detector) run: go test -race ./... - name: Build @@ -76,6 +85,27 @@ jobs: run: | sudo apt-get update -q && sudo apt-get install -y -q shellcheck shellcheck site/install.sh scripts/package.sh + - name: Reject unsafe release versions + run: | + set -eu + marker="/tmp/oytc-tag-injection" + rm -f "$marker" + if ./scripts/package.sh 'v1.2.3"; touch /tmp/oytc-tag-injection; echo "' dist-invalid; then + echo "unsafe release version was accepted" >&2 + exit 1 + fi + newline_version="$(printf 'v1.2.3\n../../escape')" + if ./scripts/package.sh "$newline_version" dist-invalid; then + echo "multiline release version was accepted" >&2 + exit 1 + fi + for invalid_version in v01.2.3 v1.2.3-01; do + if ./scripts/package.sh "$invalid_version" dist-invalid; then + echo "non-canonical release version was accepted: $invalid_version" >&2 + exit 1 + fi + done + test ! -e "$marker" - name: Skill structure run: | python3 - <<'EOF' @@ -118,3 +148,33 @@ jobs: OYTC_DOWNLOAD_BASE=http://127.0.0.1:8931 sh site/install.sh "$PWD/fake-home/.local/bin/oytc" version "$PWD/fake-home/.local/bin/oytc_update" --help >/dev/null + mkdir curl-shim + cat >curl-shim/curl <<'EOF' + #!/bin/sh + : >"${CURL_MARKER:?}" + exit 99 + EOF + chmod +x curl-shim/curl + request_marker="$PWD/unexpected-curl-request" + rm -f "$request_marker" + if PATH="$PWD/curl-shim:$PATH" CURL_MARKER="$request_marker" \ + HOME="$PWD/fake-home" OYTC_VERSION=v0.0.0-ci \ + OYTC_DOWNLOAD_BASE='http://localhost' sh site/install.sh; then + echo "recording curl shim unexpectedly succeeded" >&2 + exit 1 + fi + test -e "$request_marker" || { + echo "installer rejected a loopback HTTP authority without a port" >&2 + exit 1 + } + rm -f "$request_marker" + if PATH="$PWD/curl-shim:$PATH" CURL_MARKER="$request_marker" \ + HOME="$PWD/fake-home" OYTC_VERSION=v0.0.0-ci \ + OYTC_DOWNLOAD_BASE='http://localhost:80@attacker.invalid' sh site/install.sh; then + echo "installer accepted a non-loopback HTTP authority" >&2 + exit 1 + fi + test ! -e "$request_marker" || { + echo "installer attempted an HTTP request for a hostile authority" >&2 + exit 1 + } diff --git a/.depot/workflows/release.yml b/.depot/workflows/release.yml index 4739bd9..f2dd312 100644 --- a/.depot/workflows/release.yml +++ b/.depot/workflows/release.yml @@ -43,11 +43,13 @@ jobs: else tag="${GITHUB_REF_NAME}" fi - case "$tag" in - v[0-9]*) ;; - *) echo "error: '$tag' is not a v-prefixed semantic version tag" >&2; exit 1 ;; - esac - echo "tag=$tag" >>"$GITHUB_OUTPUT" + tag_newlines="$(printf '%s' "$tag" | wc -l | tr -d '[:space:]')" + if [ "$tag_newlines" != "0" ] || + ! printf '%s\n' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then + echo "error: '$tag' is not a valid v-prefixed semantic version tag" >&2 + exit 1 + fi + printf 'tag=%s\n' "$tag" >>"$GITHUB_OUTPUT" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ steps.tag.outputs.tag }} @@ -55,16 +57,29 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod + - name: Vulnerability scan (all release targets) + run: | + set -eu + go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + scanner="$(go env GOPATH)/bin/govulncheck" + for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + echo "== $platform ==" + GOTOOLCHAIN=local GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./... + done - name: Tests (race detector) run: go test -race ./... - name: Package all platforms - run: ./scripts/package.sh "${{ steps.tag.outputs.tag }}" dist + env: + TAG: ${{ steps.tag.outputs.tag }} + run: ./scripts/package.sh "$TAG" dist - name: Smoke-test a packaged binary + env: + TAG: ${{ steps.tag.outputs.tag }} run: | set -eu - tar -xzf "dist/oytc_${{ steps.tag.outputs.tag }}_linux_amd64.tar.gz" -C /tmp oytc + tar -xzf "dist/oytc_${TAG}_linux_amd64.tar.gz" -C /tmp oytc /tmp/oytc version - /tmp/oytc version --format json | grep -q '"version": "${{ steps.tag.outputs.tag }}"' + /tmp/oytc version --format json | grep -Fq "\"version\": \"${TAG}\"" - name: Create GitHub Release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: diff --git a/README.md b/README.md index c2aeed7..122cbf0 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,31 @@ write commands. macOS / Linux (verifies SHA-256 before installing; no root needed): ```sh -curl -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh | sh +tmp="$(mktemp)" && { + curl --proto '=https' --proto-redir '=https' -fsSL \ + https://davis7dotsh.github.io/open-yt-cli/install.sh -o "$tmp" && + sh "$tmp" + status=$? + rm -f "$tmp" + (exit "$status") +} ``` -Windows (PowerShell): `irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 | iex`, -or download a zip from [releases](https://github.com/davis7dotsh/open-yt-cli/releases). +Windows (PowerShell): -From source (Go 1.26+): `go install ./cmd/oytc` from a clone, or `make build`. +```powershell +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("oytc-install-" + [Guid]::NewGuid() + ".ps1") +try { + irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 -OutFile $tmp -ErrorAction Stop + & $tmp +} finally { + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue +} +``` + +Alternatively, download a zip from [releases](https://github.com/davis7dotsh/open-yt-cli/releases). + +From source (Go 1.26.5+): `go install ./cmd/oytc` from a clone, or `make build`. ## Quick start @@ -91,6 +109,9 @@ hard-block unverified apps requesting it, so verify the consent app for those ac - `status` shows a key fingerprint plus OAuth client ID, scopes, and expiry. It never prints tokens or the client secret. `logout` attempts OAuth revocation, then removes the file. - `oytc update` verifies release checksums and never reads or transmits credentials. +- Release checksums detect corruption or in-transit tampering. Because the checksum manifest + ships in the same release, publisher authenticity still relies on the GitHub repository and + release workflow; releases do not yet have an independent signature. ## Scope: read-only public data + your analytics diff --git a/docs/releasing.md b/docs/releasing.md index ce0cea1..5f09020 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -23,6 +23,10 @@ Platforms: `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `window - `site/install.sh` / `site/install.ps1` — download + verify + install - `internal/update/update.go` (`AssetName`) — the self-updater +The checksum manifest and archives share the GitHub Release trust root. Checksums detect +corruption and in-transit tampering, but they do not protect against a compromised publisher. +Independent artifact signing is not currently configured. + Version metadata is injected via `-ldflags -X open-yt-cli/internal/version.{Version,Commit,Date}=…` and surfaced by `oytc version`. @@ -111,8 +115,7 @@ Then verify: `depot ci run list`) and shows six archives plus `checksums.txt`. -2. `curl -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh | sh` installs and - `oytc version` prints `v0.1.0`. +2. Download and run `install.sh`; `oytc version` prints `v0.1.0`. 3. `oytc update --check` reports up-to-date. Subsequent releases: bump the tag (`v0.1.1`, `v0.2.0`, …) and push it. Prereleases: use a diff --git a/go.mod b/go.mod index c221511..4e39e4b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module open-yt-cli -go 1.26.0 +go 1.26.5 require ( github.com/spf13/cobra v1.10.2 @@ -11,5 +11,5 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.10 // indirect ) diff --git a/go.sum b/go.sum index b014642..e079ee1 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,9 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= diff --git a/internal/cli/analytics.go b/internal/cli/analytics.go index ff6198c..882ac80 100644 --- a/internal/cli/analytics.go +++ b/internal/cli/analytics.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "regexp" "strings" "time" @@ -12,6 +13,8 @@ import ( "open-yt-cli/internal/youtube" ) +var analyticsVideoIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + type analyticsFlags struct { start string end string @@ -90,6 +93,9 @@ func (a *App) analyticsVideoCommand() *cobra.Command { Short: "Show core analytics metrics for one owned video", Args: exactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if !analyticsVideoIDPattern.MatchString(args[0]) { + return &UsageError{Message: "VIDEO_ID may contain only letters, digits, underscores, and hyphens"} + } query := analytics.Query{Metrics: metrics, Filters: "video==" + args[0]} return a.runAnalytics(cmd, flags, query, metrics) }, diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 23abd13..7d9e398 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -12,6 +12,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "open-yt-cli/internal/config" "open-yt-cli/internal/youtube" @@ -169,6 +170,10 @@ func TestAnalyticsRequiresOAuthAndValidDates(t *testing.T) { if !errors.Is(err, youtube.ErrMissingOAuth) || !bytes.Contains([]byte(err.Error()), []byte("login --oauth")) { t.Fatalf("expected missing OAuth hint, got %T: %v", err, err) } + err = execute(t, app, "analytics", "video", "video;country==US") + if !errors.As(err, &usage) { + t.Fatalf("expected invalid video ID usage error, got %T: %v", err, err) + } } func TestStatusHidesOAuthSecretsAndLogoutRevokes(t *testing.T) { @@ -429,6 +434,34 @@ func TestLiveChatStreamPollsWithTokenAndDeduplicates(t *testing.T) { } } +func TestRecentIDsEvictsOldEntries(t *testing.T) { + seen := newRecentIDs(2) + if !seen.Add("a") || !seen.Add("b") || seen.Add("a") { + t.Fatal("recent ID set did not detect a duplicate") + } + if !seen.Add("c") { + t.Fatal("recent ID set rejected a new ID") + } + if len(seen.values) != 2 { + t.Fatalf("stored IDs = %d, want 2", len(seen.values)) + } + if !seen.Add("a") { + t.Fatal("oldest ID was not evicted") + } +} + +func TestLiveChatPollingIntervalIsBounded(t *testing.T) { + if got := liveChatPollingInterval(0); got != time.Second { + t.Fatalf("zero interval = %v", got) + } + if got := liveChatPollingInterval(2500); got != 2500*time.Millisecond { + t.Fatalf("normal interval = %v", got) + } + if got := liveChatPollingInterval(999999999); got != maxLiveChatPollingInterval { + t.Fatalf("large interval = %v, want %v", got, maxLiveChatPollingInterval) + } +} + func TestCommentThreadsRejectsIncompatibleFiltersWithoutRequest(t *testing.T) { t.Setenv("OYTC_CONFIG_DIR", t.TempDir()) t.Setenv("OYTC_API_KEY", "key") diff --git a/internal/cli/live_chat.go b/internal/cli/live_chat.go index c0bda91..9c8f0aa 100644 --- a/internal/cli/live_chat.go +++ b/internal/cli/live_chat.go @@ -14,6 +14,9 @@ import ( "open-yt-cli/internal/youtube" ) +const liveChatDedupWindow = 10000 +const maxLiveChatPollingInterval = 60 * time.Second + func (a *App) liveChatCommand() *cobra.Command { live := &cobra.Command{Use: "live-chat", Short: "Read public live chat using REST polling"} live.AddCommand(a.liveChatListCommand(), a.liveChatStreamCommand()) @@ -78,7 +81,7 @@ func (a *App) liveChatStreamCommand() *cobra.Command { if err != nil { return err } - seen := make(map[string]struct{}) + seen := newRecentIDs(liveChatDedupWindow) emitted := 0 firstPage := true for { @@ -93,11 +96,8 @@ func (a *App) liveChatStreamCommand() *cobra.Command { items := make([]map[string]any, 0, len(response.Items)) for _, item := range response.Items { id, _ := item["id"].(string) - if id != "" { - if _, exists := seen[id]; exists { - continue - } - seen[id] = struct{}{} + if id != "" && !seen.Add(id) { + continue } items = append(items, item) if flags.limit > 0 && emitted+len(items) >= flags.limit { @@ -122,10 +122,7 @@ func (a *App) liveChatStreamCommand() *cobra.Command { return nil } flags.pageToken = response.NextPageToken - interval := time.Duration(response.PollingIntervalMillis) * time.Millisecond - if interval <= 0 { - interval = time.Second - } + interval := liveChatPollingInterval(response.PollingIntervalMillis) if err := waitFor(cmd.Context(), interval); err != nil { if errors.Is(err, context.Canceled) { return nil @@ -139,6 +136,42 @@ func (a *App) liveChatStreamCommand() *cobra.Command { return cmd } +func liveChatPollingInterval(milliseconds int64) time.Duration { + if milliseconds <= 0 { + return time.Second + } + if milliseconds >= int64(maxLiveChatPollingInterval/time.Millisecond) { + return maxLiveChatPollingInterval + } + return time.Duration(milliseconds) * time.Millisecond +} + +type recentIDs struct { + values map[string]struct{} + order []string + next int + capacity int +} + +func newRecentIDs(capacity int) *recentIDs { + return &recentIDs{values: make(map[string]struct{}, capacity), order: make([]string, 0, capacity), capacity: capacity} +} + +func (r *recentIDs) Add(value string) bool { + if _, exists := r.values[value]; exists { + return false + } + if len(r.order) < r.capacity { + r.order = append(r.order, value) + } else { + delete(r.values, r.order[r.next]) + r.order[r.next] = value + r.next = (r.next + 1) % r.capacity + } + r.values[value] = struct{}{} + return true +} + func addLiveChatFlags(cmd *cobra.Command, flags *liveChatFlags) { cmd.Flags().StringVar(&flags.videoID, "video", "", "live video ID (resolved to activeLiveChatId)") cmd.Flags().StringVar(&flags.chatID, "chat-id", "", "live chat ID") diff --git a/internal/config/config.go b/internal/config/config.go index 04aea84..46f4c90 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "runtime" @@ -17,8 +18,11 @@ const ( envKey = "OYTC_API_KEY" envOAuthClientID = "OYTC_OAUTH_CLIENT_ID" envOAuthClientSecret = "OYTC_OAUTH_CLIENT_SECRET" + maxCredentialBytes = 1 << 20 ) +var errCredentialSymlink = errors.New("credential file is a symbolic link or reparse point") + type File struct { APIKey string `json:"api_key,omitempty"` OAuth *OAuthCredentials `json:"oauth,omitempty"` @@ -251,18 +255,64 @@ func acquireUpdateLock(path string) (func(), error) { } func loadFile(path string) (File, bool, error) { - data, err := os.ReadFile(path) + handle, err := openCredentialFile(path) if errors.Is(err, os.ErrNotExist) { return File{}, false, nil } if err != nil { - return File{}, false, fmt.Errorf("read credentials: %w", err) + if errors.Is(err, errCredentialSymlink) { + return File{}, true, errors.New("read credentials: auth.json must not be a symbolic link") + } + return File{}, true, fmt.Errorf("open credentials: %w", err) + } + file, err := readCredentialFile(handle) + return file, true, err +} + +func readCredentialFile(handle *os.File) (File, error) { + info, statErr := handle.Stat() + if statErr != nil { + closeErr := handle.Close() + return File{}, fmt.Errorf("inspect credentials: %w", errors.Join(statErr, closeErr)) + } + if info.Mode()&os.ModeSymlink != 0 { + validationErr := errors.New("read credentials: auth.json must not be a symbolic link") + return File{}, errors.Join(validationErr, handle.Close()) + } + if !info.Mode().IsRegular() { + validationErr := errors.New("read credentials: auth.json must be a regular file") + return File{}, errors.Join(validationErr, handle.Close()) + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { + originalMode := info.Mode().Perm() + chmodErr := handle.Chmod(0o600) + info, statErr = handle.Stat() + if chmodErr != nil || statErr != nil || info.Mode().Perm()&0o077 != 0 { + secureErr := errors.Join(chmodErr, statErr) + if secureErr == nil { + secureErr = errors.New("filesystem did not apply mode 0600") + } + secureErr = errors.Join(secureErr, handle.Close()) + return File{}, fmt.Errorf( + "read credentials: insecure permissions %04o on auth.json; chmod 600 failed: %w", + originalMode, + secureErr, + ) + } + } + data, readErr := io.ReadAll(io.LimitReader(handle, maxCredentialBytes+1)) + closeErr := handle.Close() + if readErr != nil || closeErr != nil { + return File{}, fmt.Errorf("read credentials: %w", errors.Join(readErr, closeErr)) + } + if len(data) > maxCredentialBytes { + return File{}, fmt.Errorf("read credentials: file exceeds %d bytes", maxCredentialBytes) } var file File if err := json.Unmarshal(data, &file); err != nil { - return File{}, true, fmt.Errorf("parse credentials: %w", err) + return File{}, fmt.Errorf("parse credentials: %w", err) } - return file, true, nil + return file, nil } func saveFile(path string, file File) (string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2ab08cc..b21b46a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -198,6 +199,88 @@ func TestLoadFallsBackToEnvironmentKeyWhenFileCorrupt(t *testing.T) { } } +func TestLoadRejectsOversizedCredentialFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("OYTC_CONFIG_DIR", dir) + t.Setenv("OYTC_API_KEY", "") + if err := os.WriteFile(filepath.Join(dir, "auth.json"), bytes.Repeat([]byte("x"), maxCredentialBytes+1), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "file exceeds") { + t.Fatalf("Load error = %v", err) + } +} + +func TestLoadSecuresCredentialFileAndRejectsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission and symlink checks") + } + dir := t.TempDir() + t.Setenv("OYTC_CONFIG_DIR", dir) + t.Setenv("OYTC_API_KEY", "") + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, []byte(`{"api_key":"secret"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + credentials, err := Load() + if err != nil { + t.Fatalf("Load with repairable permissions: %v", err) + } + if credentials.Key != "secret" { + t.Fatalf("loaded API key = %q", credentials.Key) + } + if mode := mustStat(t, path).Mode().Perm(); mode != 0o600 { + t.Fatalf("repaired credential mode = %04o, want 0600", mode) + } + + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "credentials.json") + if err := os.WriteFile(target, []byte(`{"api_key":"secret"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "symbolic link") { + t.Fatalf("Load through symbolic link = %v", err) + } +} + +func TestOpenedCredentialFileUnaffectedByPathReplacement(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("replacement semantics differ while a file handle is open") + } + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + replacement := filepath.Join(dir, "replacement.json") + if err := os.WriteFile(path, []byte(`{"api_key":"original"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(replacement, []byte(`{"api_key":"replacement"}`), 0o600); err != nil { + t.Fatal(err) + } + handle, err := openCredentialFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, path); err != nil { + _ = handle.Close() + t.Fatal(err) + } + file, err := readCredentialFile(handle) + if err != nil { + t.Fatal(err) + } + if file.APIKey != "original" { + t.Fatalf("loaded API key = %q, want original descriptor contents", file.APIKey) + } +} + func TestEnvironmentKeyHasPrecedence(t *testing.T) { t.Setenv("OYTC_CONFIG_DIR", t.TempDir()) t.Setenv("OYTC_API_KEY", "environment-secret") diff --git a/internal/config/open_credential_unix.go b/internal/config/open_credential_unix.go new file mode 100644 index 0000000..f719d52 --- /dev/null +++ b/internal/config/open_credential_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package config + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +func openCredentialFile(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + if errors.Is(err, unix.ELOOP) { + return nil, errCredentialSymlink + } + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + return os.NewFile(uintptr(fd), path), nil +} diff --git a/internal/config/open_credential_unix_test.go b/internal/config/open_credential_unix_test.go new file mode 100644 index 0000000..bfccb35 --- /dev/null +++ b/internal/config/open_credential_unix_test.go @@ -0,0 +1,28 @@ +//go:build !windows + +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestLoadRejectsFIFOWithoutBlocking(t *testing.T) { + dir := t.TempDir() + t.Setenv("OYTC_CONFIG_DIR", dir) + t.Setenv("OYTC_API_KEY", "") + path := filepath.Join(dir, "auth.json") + if err := unix.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("Load FIFO error = %v", err) + } + if info, err := os.Lstat(path); err != nil || info.Mode()&os.ModeNamedPipe == 0 { + t.Fatalf("test path is not a FIFO: %v, %v", info, err) + } +} diff --git a/internal/config/open_credential_windows.go b/internal/config/open_credential_windows.go new file mode 100644 index 0000000..3f28d59 --- /dev/null +++ b/internal/config/open_credential_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package config + +import ( + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +type fileAttributeTagInfo struct { + FileAttributes uint32 + ReparseTag uint32 +} + +func openCredentialFile(path string) (*os.File, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile( + name, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + var info fileAttributeTagInfo + err = windows.GetFileInformationByHandleEx( + handle, + windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ) + if err != nil { + windows.CloseHandle(handle) + return nil, &os.PathError{Op: "inspect", Path: path, Err: err} + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + windows.CloseHandle(handle) + return nil, errCredentialSymlink + } + return os.NewFile(uintptr(handle), path), nil +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index c18c4b3..cc8fb3a 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -223,7 +223,7 @@ func Revoke(ctx context.Context, cfg Config, token string) error { } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - resp, err := cfg.HTTPClient.Do(req) + resp, err := noRedirectClient(cfg.HTTPClient).Do(req) if err != nil { return fmt.Errorf("revoke OAuth token: %w", err) } @@ -285,7 +285,15 @@ func (c Config) library() *oauth2.Config { // context injects cfg.HTTPClient into the oauth2 library, which only accepts // a custom client via context. func (c Config) context(ctx context.Context) context.Context { - return context.WithValue(ctx, oauth2.HTTPClient, c.HTTPClient) + return context.WithValue(ctx, oauth2.HTTPClient, noRedirectClient(c.HTTPClient)) +} + +func noRedirectClient(client *http.Client) *http.Client { + clone := *client + clone.CheckRedirect = func(*http.Request, []*http.Request) error { + return errors.New("OAuth endpoint redirects are not allowed") + } + return &clone } // fromLibrary converts an oauth2 token, inheriting the refresh token and diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index e3b51a8..ccb21b7 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -135,6 +135,26 @@ func TestRevoke(t *testing.T) { } } +func TestRevokeRefusesRedirects(t *testing.T) { + var targetRequests atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + targetRequests.Add(1) + })) + defer target.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) + })) + defer source.Close() + + err := Revoke(context.Background(), Config{RevokeURL: source.URL, HTTPClient: source.Client()}, "refresh-secret") + if err == nil || !strings.Contains(err.Error(), "redirects are not allowed") { + t.Fatalf("Revoke error = %v", err) + } + if targetRequests.Load() != 0 { + t.Fatalf("redirect target received %d request(s)", targetRequests.Load()) + } +} + // getCallback issues the loopback callback request with the test's context so // a stalled listener cannot outlive the test. func getCallback(t *testing.T, callback string) { diff --git a/internal/output/output.go b/internal/output/output.go index 12d303c..6be89c0 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -10,6 +10,7 @@ import ( "sort" "strings" "text/tabwriter" + "unicode" "open-yt-cli/internal/youtube" ) @@ -78,7 +79,7 @@ func renderRows(w io.Writer, items []map[string]any, options Options) error { if i > 0 { fmt.Fprint(target, "\t") } - fmt.Fprint(target, strings.ToUpper(column)) + fmt.Fprint(target, clean(strings.ToUpper(column))) } fmt.Fprintln(target) } @@ -87,7 +88,11 @@ func renderRows(w io.Writer, items []map[string]any, options Options) error { if i > 0 { fmt.Fprint(target, "\t") } - fmt.Fprint(target, cell(pathValue(item, column))) + rendered := cell(pathValue(item, column)) + if options.Format == "tsv" { + rendered = spreadsheetSafe(rendered) + } + fmt.Fprint(target, rendered) } fmt.Fprintln(target) } @@ -147,5 +152,21 @@ func cell(value any) string { } func clean(value string) string { - return strings.NewReplacer("\t", " ", "\r", " ", "\n", " ").Replace(value) + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, value) +} + +func spreadsheetSafe(value string) string { + trimmed := strings.TrimLeftFunc(value, unicode.IsSpace) + if strings.HasPrefix(trimmed, "-") && json.Valid([]byte(trimmed)) { + return value + } + if trimmed != "" && strings.ContainsRune("=+-@", rune(trimmed[0])) { + return "'" + value + } + return value } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 71ea038..c56fa6b 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -25,12 +25,33 @@ func TestJSONPreservesLargeCounterString(t *testing.T) { } func TestTSVColumnsAndSanitization(t *testing.T) { - result := youtube.ListResult{Items: []map[string]any{{"id": "v", "snippet": map[string]any{"title": "line one\nline two"}}}} + result := youtube.ListResult{Items: []map[string]any{{ + "id": "v", + "snippet": map[string]any{ + "title": "line one\nline two\x1b]52;c;YXR0YWNr\a\u0085", + }, + }}} var buffer bytes.Buffer if err := Render(&buffer, result, Options{Format: "tsv", Columns: []string{"id", "snippet.title"}}); err != nil { t.Fatal(err) } - want := "ID\tSNIPPET.TITLE\nv\tline one line two\n" + want := "ID\tSNIPPET.TITLE\nv\tline one line two ]52;c;YXR0YWNr \n" + if buffer.String() != want { + t.Fatalf("TSV = %q, want %q", buffer.String(), want) + } +} + +func TestTSVNeutralizesSpreadsheetFormulasInStrings(t *testing.T) { + result := youtube.ListResult{Items: []map[string]any{{ + "title": "=HYPERLINK(\"https://example.invalid\")", + "count": json.Number("-5"), + "list": []any{"=cmd"}, + }}} + var buffer bytes.Buffer + if err := Render(&buffer, result, Options{Format: "tsv", Columns: []string{"title", "count", "list"}, NoHeader: true}); err != nil { + t.Fatal(err) + } + want := "'=HYPERLINK(\"https://example.invalid\")\t-5\t'=cmd\n" if buffer.String() != want { t.Fatalf("TSV = %q, want %q", buffer.String(), want) } diff --git a/internal/update/update.go b/internal/update/update.go index b1d783f..b275dbe 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -20,9 +20,11 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path" "path/filepath" + "regexp" "runtime" "strconv" "strings" @@ -32,6 +34,8 @@ import ( var ( defaultGOOS = runtime.GOOS defaultGOARCH = runtime.GOARCH + releaseTagRE = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) + repositoryRE = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) ) // DefaultRepo is the canonical GitHub repository for oytc releases. @@ -145,6 +149,9 @@ func (u *Updater) Run(ctx context.Context, options Options) (Result, error) { if err != nil { return result, err } + if err := validateAssetURLs(u.apiBaseURL(), u.repo(), release.TagName, assetURL, result.AssetName, checksumsURL, ChecksumsName); err != nil { + return result, err + } expected, err := u.fetchChecksum(ctx, checksumsURL, result.AssetName) if err != nil { return result, err @@ -196,10 +203,35 @@ func (u *Updater) goarch() string { } func (u *Updater) httpClient() *http.Client { + var client *http.Client if u.HTTPClient != nil { - return u.HTTPClient + client = u.HTTPClient + } else { + client = &http.Client{Timeout: 5 * time.Minute} + } + clone := *client + originalRedirectPolicy := client.CheckRedirect + clone.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) > 0 && strings.EqualFold(via[0].URL.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") { + return errors.New("refusing to follow an HTTPS download redirect to an insecure URL") + } + if len(via) > 0 && u.APIBaseURL == "" && !isGitHubReleaseHost(req.URL.Hostname()) { + return fmt.Errorf("refusing update redirect to unexpected host %q", req.URL.Hostname()) + } + if originalRedirectPolicy != nil { + return originalRedirectPolicy(req, via) + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil } - return &http.Client{Timeout: 5 * time.Minute} + return &clone +} + +func isGitHubReleaseHost(host string) bool { + host = strings.ToLower(host) + return host == "api.github.com" || host == "github.com" || strings.HasSuffix(host, ".githubusercontent.com") } func (u *Updater) apiBaseURL() string { @@ -217,12 +249,19 @@ func (u *Updater) repo() string { } func (u *Updater) resolveRelease(ctx context.Context, tag string) (Release, error) { - endpoint := u.apiBaseURL() + "/repos/" + u.repo() + "/releases/latest" + repository := u.repo() + if !repositoryRE.MatchString(repository) { + return Release{}, fmt.Errorf("invalid GitHub repository %q", repository) + } + endpoint := u.apiBaseURL() + "/repos/" + repository + "/releases/latest" + requestedTag := "" if tag != "" { - if !strings.HasPrefix(tag, "v") { - tag = "v" + tag + var err error + requestedTag, err = normalizeReleaseTag(tag) + if err != nil { + return Release{}, err } - endpoint = u.apiBaseURL() + "/repos/" + u.repo() + "/releases/tags/" + tag + endpoint = u.apiBaseURL() + "/repos/" + repository + "/releases/tags/" + url.PathEscape(requestedTag) } body, err := u.get(ctx, endpoint, maxMetadataBytes, "application/vnd.github+json") if err != nil { @@ -235,9 +274,26 @@ func (u *Updater) resolveRelease(ctx context.Context, tag string) (Release, erro if release.TagName == "" { return Release{}, errors.New("release metadata is missing a tag name") } + if requestedTag != "" && release.TagName != requestedTag { + return Release{}, fmt.Errorf("release metadata tag %q does not match requested tag %q", release.TagName, requestedTag) + } + if !releaseTagRE.MatchString(release.TagName) { + return Release{}, fmt.Errorf("release metadata contains invalid tag %q", release.TagName) + } return release, nil } +func normalizeReleaseTag(tag string) (string, error) { + tag = strings.TrimSpace(tag) + if !strings.HasPrefix(tag, "v") { + tag = "v" + tag + } + if !releaseTagRE.MatchString(tag) { + return "", fmt.Errorf("invalid release version %q: expected a v-prefixed semantic version", tag) + } + return tag, nil +} + func (u *Updater) get(ctx context.Context, url string, limit int64, accept string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -286,6 +342,35 @@ func findAssets(release Release, assetName string) (assetURL, checksumsURL strin return assetURL, checksumsURL, nil } +func validateAssetURLs(apiBase, repository, tag, assetURL, assetName, checksumsURL, checksumsName string) error { + base, err := url.Parse(apiBase) + if err != nil { + return fmt.Errorf("parse release API URL: %w", err) + } + for _, asset := range []struct { + url string + name string + }{ + {assetURL, assetName}, + {checksumsURL, checksumsName}, + } { + parsed, err := url.Parse(asset.url) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("release metadata contains an invalid asset URL %q", asset.url) + } + if strings.EqualFold(base.Scheme, "https") && !strings.EqualFold(parsed.Scheme, "https") { + return fmt.Errorf("release metadata points an HTTPS update at an insecure asset URL %q", asset.url) + } + if strings.EqualFold(base.Hostname(), "api.github.com") { + expectedPath := "/" + repository + "/releases/download/" + tag + "/" + asset.name + if !strings.EqualFold(parsed.Hostname(), "github.com") || parsed.Path != expectedPath { + return fmt.Errorf("release metadata contains an unexpected GitHub asset URL %q", asset.url) + } + } + } + return nil +} + func (u *Updater) fetchChecksum(ctx context.Context, url, assetName string) (string, error) { body, err := u.get(ctx, url, maxChecksumBytes, "") if err != nil { @@ -340,7 +425,7 @@ func (u *Updater) downloadVerified(ctx context.Context, url, expected string) (s } tmpName := tmp.Name() hasher := sha256.New() - _, copyErr := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(resp.Body, maxArchiveBytes)) + _, copyErr := copyWithLimit(io.MultiWriter(tmp, hasher), resp.Body, maxArchiveBytes) closeErr := tmp.Close() if copyErr != nil || closeErr != nil { os.Remove(tmpName) @@ -427,7 +512,7 @@ func writeBinaryTemp(content io.Reader, want string) (string, error) { return "", err } tmpName := tmp.Name() - _, copyErr := io.Copy(tmp, io.LimitReader(content, maxArchiveBytes)) + _, copyErr := copyWithLimit(tmp, content, maxArchiveBytes) closeErr := tmp.Close() if copyErr != nil || closeErr != nil { os.Remove(tmpName) @@ -440,6 +525,17 @@ func writeBinaryTemp(content io.Reader, want string) (string, error) { return tmpName, nil } +func copyWithLimit(destination io.Writer, source io.Reader, limit int64) (int64, error) { + written, err := io.Copy(destination, io.LimitReader(source, limit+1)) + if err != nil { + return written, err + } + if written > limit { + return written, fmt.Errorf("content exceeds %d bytes", limit) + } + return written, nil +} + // replaceExecutable atomically swaps the new binary into place. The staged // copy lives in the same directory as the target so the final rename is // atomic on POSIX filesystems. On Windows a running executable cannot be diff --git a/internal/update/update_test.go b/internal/update/update_test.go index d3a92a1..9f03aba 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -15,6 +15,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" ) @@ -286,6 +287,115 @@ func TestUpdateMissingAssetForPlatform(t *testing.T) { } } +func TestValidateAssetURLsRejectsInsecureOrInvalidURLs(t *testing.T) { + validArchive := "https://github.com/owner/repo/releases/download/v1.2.3/oytc_v1.2.3_linux_amd64.tar.gz" + validChecksums := "https://github.com/owner/repo/releases/download/v1.2.3/checksums.txt" + if err := validateAssetURLs("https://api.github.com", "owner/repo", "v1.2.3", validArchive, "oytc_v1.2.3_linux_amd64.tar.gz", validChecksums, ChecksumsName); err != nil { + t.Fatal(err) + } + for _, asset := range []string{ + "http://github.com/owner/repo/releases/download/v1.2.3/oytc_v1.2.3_linux_amd64.tar.gz", + "https://github.com/attacker/repo/releases/download/v1.2.3/oytc_v1.2.3_linux_amd64.tar.gz", + "https://example.com/owner/repo/releases/download/v1.2.3/oytc_v1.2.3_linux_amd64.tar.gz", + "file:///tmp/archive", + "/relative/archive", + } { + if err := validateAssetURLs("https://api.github.com", "owner/repo", "v1.2.3", asset, "oytc_v1.2.3_linux_amd64.tar.gz", validChecksums, ChecksumsName); err == nil { + t.Fatalf("validateAssetURLs accepted %q", asset) + } + } + if err := validateAssetURLs("http://127.0.0.1:8080", "owner/repo", "v1.2.3", "http://127.0.0.1:8080/archive", "archive", "http://127.0.0.1:8080/checksums", ChecksumsName); err != nil { + t.Fatalf("local HTTP fixture was rejected: %v", err) + } +} + +func TestResolveReleaseRejectsUnsafeOrMismatchedTags(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + _ = json.NewEncoder(w).Encode(Release{TagName: "v9.9.9"}) + })) + defer server.Close() + updater := Updater{Repo: "owner/repo", APIBaseURL: server.URL, HTTPClient: server.Client()} + + for _, tag := range []string{ + "v../../../../../../attacker/repo/releases/tags/v9.9.9", + "v1.2.3/../../attacker", + "v1.2", + "v01.2.3", + "v1.2.3-01", + } { + if _, err := updater.resolveRelease(context.Background(), tag); err == nil || !strings.Contains(err.Error(), "invalid release version") { + t.Fatalf("resolveRelease(%q) error = %v", tag, err) + } + } + if requests.Load() != 0 { + t.Fatalf("unsafe tags made %d HTTP request(s)", requests.Load()) + } + + if _, err := updater.resolveRelease(context.Background(), "v1.2.3"); err == nil || !strings.Contains(err.Error(), "does not match requested tag") { + t.Fatalf("mismatched release error = %v", err) + } +} + +func TestResolveReleaseRejectsUnsafeRepository(t *testing.T) { + updater := Updater{Repo: "owner/repo/../../attacker/repo"} + if _, err := updater.resolveRelease(context.Background(), "v1.2.3"); err == nil || !strings.Contains(err.Error(), "invalid GitHub repository") { + t.Fatalf("error = %v", err) + } +} + +func TestUpdaterRefusesHTTPSRedirectDowngrade(t *testing.T) { + var targetRequests atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + targetRequests.Add(1) + })) + defer target.Close() + source := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer source.Close() + + updater := Updater{HTTPClient: source.Client()} + if _, err := updater.get(context.Background(), source.URL, 1024, ""); err == nil || !strings.Contains(err.Error(), "insecure URL") { + t.Fatalf("error = %v", err) + } + if targetRequests.Load() != 0 { + t.Fatalf("insecure redirect target received %d request(s)", targetRequests.Load()) + } +} + +func TestUpdaterRefusesNonGitHubRedirectForDefaultAPI(t *testing.T) { + var targetRequests atomic.Int32 + target := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + targetRequests.Add(1) + })) + defer target.Close() + source := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer source.Close() + + updater := Updater{HTTPClient: source.Client()} + if _, err := updater.get(context.Background(), source.URL, 1024, ""); err == nil || !strings.Contains(err.Error(), "unexpected host") { + t.Fatalf("error = %v", err) + } + if targetRequests.Load() != 0 { + t.Fatalf("unexpected redirect target received %d request(s)", targetRequests.Load()) + } +} + +func TestCopyWithLimitRejectsOversizedContent(t *testing.T) { + var output bytes.Buffer + written, err := copyWithLimit(&output, strings.NewReader("12345"), 4) + if err == nil || !strings.Contains(err.Error(), "exceeds 4 bytes") { + t.Fatalf("copyWithLimit error = %v", err) + } + if written != 5 { + t.Fatalf("written = %d, want 5", written) + } +} + func TestParseChecksums(t *testing.T) { digest := strings.Repeat("ab", 32) manifest := []byte(fmt.Sprintf("%s oytc_v1.0.0_linux_amd64.tar.gz\n%s *oytc_v1.0.0_darwin_arm64.tar.gz\n", digest, digest)) diff --git a/internal/youtube/client.go b/internal/youtube/client.go index b3946de..5017c9d 100644 --- a/internal/youtube/client.go +++ b/internal/youtube/client.go @@ -17,6 +17,8 @@ import ( ) const DefaultBaseURL = "https://www.googleapis.com/youtube/v3" +const maxResponseBytes = 16 << 20 +const maxRetryDelay = 60 * time.Second type TokenSource func(context.Context, bool) (string, error) @@ -117,11 +119,17 @@ func (c *Client) GetJSON(ctx context.Context, resource string, params url.Values transientAttempt++ continue } - body, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) - resp.Body.Close() + body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + closeErr := resp.Body.Close() if readErr != nil { return fmt.Errorf("read YouTube API response: %w", readErr) } + if closeErr != nil { + return fmt.Errorf("close YouTube API response: %w", closeErr) + } + if len(body) > maxResponseBytes { + return fmt.Errorf("read YouTube API response: response exceeds %d bytes", maxResponseBytes) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { if authenticate && c.TokenSource != nil && resp.StatusCode == http.StatusUnauthorized && !authRetried { if _, err := c.TokenSource(ctx, true); err != nil { @@ -189,10 +197,35 @@ func parseAPIError(status int, body []byte) *APIError { } func (c *Client) httpClient() *http.Client { + var client *http.Client if c.HTTPClient != nil { - return c.HTTPClient + client = c.HTTPClient + } else { + client = &http.Client{Timeout: 20 * time.Second} + } + clone := *client + originalRedirectPolicy := client.CheckRedirect + clone.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) > 0 && hasCredentials(via[0]) && !sameOrigin(via[0].URL, req.URL) { + return errors.New("refusing to forward API credentials across an origin-changing redirect") + } + if originalRedirectPolicy != nil { + return originalRedirectPolicy(req, via) + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil } - return &http.Client{Timeout: 20 * time.Second} + return &clone +} + +func hasCredentials(req *http.Request) bool { + return req.Header.Get("Authorization") != "" || req.Header.Get("X-Goog-Api-Key") != "" +} + +func sameOrigin(left, right *url.URL) bool { + return strings.EqualFold(left.Scheme, right.Scheme) && strings.EqualFold(left.Host, right.Host) } func (c *Client) wait(ctx context.Context, d time.Duration) error { @@ -224,8 +257,17 @@ func isTransientStatus(status int) bool { func backoff(attempt int, retryAfter string) time.Duration { if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 { + if seconds >= int(maxRetryDelay/time.Second) { + return maxRetryDelay + } return time.Duration(seconds) * time.Second } + if attempt < 0 { + attempt = 0 + } + if attempt >= 8 { + return maxRetryDelay + } base := time.Duration(1< [output-dir]" >&2 exit 2 fi -case "$VERSION" in - v[0-9]*) ;; - *) - echo "error: version must look like v0.1.0 (got '$VERSION')" >&2 - exit 2 - ;; -esac +version_newlines="$(printf '%s' "$VERSION" | wc -l | tr -d '[:space:]')" +if [ "$version_newlines" != "0" ] || + ! printf '%s\n' "$VERSION" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then + echo "error: version must be a v-prefixed semantic version (got '$VERSION')" >&2 + exit 2 +fi COMMIT="${OYTC_COMMIT:-$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)}" DATE="${OYTC_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" diff --git a/site/index.html b/site/index.html index 749697c..4b86703 100644 --- a/site/index.html +++ b/site/index.html @@ -102,11 +102,24 @@

oytc — open YouTube CLI

Install (macOS & Linux)

-
curl -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh | sh
+
tmp="$(mktemp)" && {
+  curl --proto '=https' --proto-redir '=https' -fsSL \
+    https://davis7dotsh.github.io/open-yt-cli/install.sh -o "$tmp" && sh "$tmp"
+  status=$?
+  rm -f "$tmp"
+  (exit "$status")
+}

The installer detects your platform, verifies the release's SHA-256 checksum before - installing, and defaults to ~/.local/bin — no root required. - Windows: use install.ps1 - (irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 | iex) or grab a zip from the + installing, and defaults to ~/.local/bin — no root required.

+

Windows (PowerShell):

+
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("oytc-install-" + [Guid]::NewGuid() + ".ps1")
+try {
+    irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 -OutFile $tmp -ErrorAction Stop
+    & $tmp
+} finally {
+    Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
+}
+

Alternatively, grab a zip from the releases page.

Quick setup

diff --git a/site/install.ps1 b/site/install.ps1 index 92bbe1b..e7a2205 100644 --- a/site/install.ps1 +++ b/site/install.ps1 @@ -1,6 +1,8 @@ # oytc installer for Windows — https://github.com/davis7dotsh/open-yt-cli # -# irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 | iex +# $tmp = Join-Path ([IO.Path]::GetTempPath()) ("oytc-install-" + [Guid]::NewGuid() + ".ps1") +# try { irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 -OutFile $tmp -ErrorAction Stop; & $tmp } +# finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } # # Optional environment variables: # OYTC_VERSION release tag to install, e.g. v0.2.0 (default: latest) @@ -30,6 +32,9 @@ if (-not $version) { } elseif ($version -notmatch '^v') { $version = "v$version" } +if ($version -notmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?\z') { + throw "Release version must be a v-prefixed semantic version (got '$version')." +} $asset = "oytc_${version}_windows_${arch}.zip" $base = "https://github.com/$Repo/releases/download/$version" @@ -51,15 +56,25 @@ try { } } if (-not $expected) { throw "checksums.txt has no entry for $asset; refusing to install." } + if ($expected -notmatch '^[0-9a-f]{64}$') { + throw "checksums.txt contains a malformed SHA-256 digest for $asset; refusing to install." + } $actual = (Get-FileHash -Algorithm SHA256 -Path $zipPath).Hash.ToLowerInvariant() if ($actual -ne $expected) { throw "SHA-256 mismatch for ${asset}: expected $expected, got $actual; refusing to install." } - Expand-Archive -Path $zipPath -DestinationPath (Join-Path $work 'extracted') -Force - $binary = Join-Path $work 'extracted\oytc.exe' - if (-not (Test-Path $binary)) { throw 'Archive did not contain oytc.exe.' } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($zipPath) + try { + $entries = @($archive.Entries | Where-Object { $_.FullName -ceq 'oytc.exe' -and $_.Name -ceq 'oytc.exe' }) + if ($entries.Count -ne 1) { throw 'Archive must contain exactly one top-level oytc.exe.' } + $binary = Join-Path $work 'oytc.exe' + [IO.Compression.ZipFileExtensions]::ExtractToFile($entries[0], $binary) + } finally { + $archive.Dispose() + } $destination = $env:OYTC_INSTALL_DIR if (-not $destination) { $destination = Join-Path $env:LOCALAPPDATA 'Programs\oytc' } diff --git a/site/install.sh b/site/install.sh index 717b5ab..b63105b 100755 --- a/site/install.sh +++ b/site/install.sh @@ -1,7 +1,10 @@ #!/bin/sh # oytc installer — https://github.com/davis7dotsh/open-yt-cli # -# curl -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh | sh +# tmp="$(mktemp)" && { +# curl --proto '=https' --proto-redir '=https' -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh -o "$tmp" && sh "$tmp" +# status=$?; rm -f "$tmp"; (exit "$status") +# } # # Options (environment variables): # OYTC_VERSION release tag to install, e.g. v0.2.0 (default: latest) @@ -37,6 +40,55 @@ fail() { command -v curl >/dev/null 2>&1 || fail "curl is required" command -v tar >/dev/null 2>&1 || fail "tar is required" +is_loopback_http() { + case "$1" in + http://*/*) ;; + *) return 1 ;; + esac + authority="${1#http://}" + authority="${authority%%/*}" + case "$authority" in + *:*) + host="${authority%:*}" + port="${authority##*:}" + ;; + *) + host="$authority" + port="80" + ;; + esac + case "$host" in + 127.0.0.1 | localhost) ;; + *) return 1 ;; + esac + case "$port" in + "" | *[!0-9]*) return 1 ;; + esac + [ "${#port}" -le 5 ] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] +} + +fetch() { + case "$1" in + https://*) curl --proto '=https' --proto-redir '=https' -fsSL "$1" ;; + *) + is_loopback_http "$1" || fail "refusing to download from insecure URL: $1" + curl -fsSL "$1" + ;; + esac +} + +fetch_to() { + output="$1" + url="$2" + case "$url" in + https://*) curl --proto '=https' --proto-redir '=https' -fsSL -o "$output" "$url" ;; + *) + is_loopback_http "$url" || fail "refusing to download from insecure URL: $url" + curl -fsSL -o "$output" "$url" + ;; + esac +} + # --- Detect platform ------------------------------------------------------- os="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$os" in @@ -58,7 +110,7 @@ esac # --- Resolve version -------------------------------------------------------- version="${OYTC_VERSION:-}" if [ -z "$version" ]; then - version="$(curl -fsSL -H 'Accept: application/vnd.github+json' "${API}/releases/latest" | + version="$(fetch "${API}/releases/latest" | awk -F '"' '/"tag_name"/ { print $4; exit }')" || fail "could not query the latest release from GitHub (network or rate limit?)" [ -n "$version" ] || fail "no published release found for ${REPO} (releases page: https://github.com/${REPO}/releases)" @@ -68,21 +120,35 @@ else *) version="v${version}" ;; esac fi +version_newlines="$(printf '%s' "$version" | wc -l | tr -d '[:space:]')" +if [ "$version_newlines" != "0" ] || + ! printf '%s\n' "$version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then + fail "release version must be a v-prefixed semantic version (got '$version')" +fi asset="oytc_${version}_${goos}_${goarch}.tar.gz" say "installing oytc ${version} (${goos}/${goarch})" # --- Download and verify ---------------------------------------------------- workdir="$(mktemp -d "${TMPDIR:-/tmp}/oytc-install.XXXXXX")" -trap 'rm -rf "$workdir"' EXIT INT TERM +staged="" +cleanup() { + rm -rf "$workdir" + [ -z "$staged" ] || rm -f "$staged" +} +trap cleanup EXIT INT TERM -curl -fsSL -o "${workdir}/${asset}" "${DOWNLOAD}/${version}/${asset}" || +fetch_to "${workdir}/${asset}" "${DOWNLOAD}/${version}/${asset}" || fail "failed to download ${asset} — check that release ${version} exists and includes ${goos}/${goarch}" -curl -fsSL -o "${workdir}/checksums.txt" "${DOWNLOAD}/${version}/checksums.txt" || +fetch_to "${workdir}/checksums.txt" "${DOWNLOAD}/${version}/checksums.txt" || fail "failed to download checksums.txt for ${version}; refusing to install an unverified binary" expected="$(awk -v name="$asset" '$2 == name || $2 == "*"name { print tolower($1); exit }' "${workdir}/checksums.txt")" [ -n "$expected" ] || fail "checksums.txt has no entry for ${asset}" +case "$expected" in + *[!0-9a-f]*) fail "checksums.txt contains a malformed SHA-256 digest for ${asset}" ;; +esac +[ "${#expected}" -eq 64 ] || fail "checksums.txt contains a malformed SHA-256 digest for ${asset}" if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "${workdir}/${asset}" | awk '{print tolower($1)}')" @@ -98,6 +164,7 @@ fi tar -xzf "${workdir}/${asset}" -C "$workdir" oytc || fail "failed to extract oytc from ${asset}" [ -f "${workdir}/oytc" ] || fail "archive did not contain the oytc binary" +[ ! -L "${workdir}/oytc" ] || fail "archive contained a symbolic link instead of the oytc binary" chmod 0755 "${workdir}/oytc" # --- Install ---------------------------------------------------------------- @@ -116,10 +183,12 @@ else fi # Atomic move into place (staging file in the destination directory). -staged="${destination}/.oytc.new.$$" +staged="$(mktemp "${destination}/.oytc.new.XXXXXX")" || + fail "cannot create a staging file in ${destination}" cp "${workdir}/oytc" "$staged" chmod 0755 "$staged" mv -f "$staged" "${destination}/oytc" +staged="" say "installed ${destination}/oytc" if [ "${OYTC_NO_SYMLINKS:-0}" != "1" ]; then