From 63a507395f979344a64d1a18d0cd43ec4d244fee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:44:03 +0000 Subject: [PATCH 01/20] build: require patched Go toolchain Co-authored-by: Ben Davis --- .depot/workflows/ci.yml | 2 ++ .depot/workflows/release.yml | 2 ++ README.md | 2 +- go.mod | 4 ++-- go.sum | 3 ++- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.depot/workflows/ci.yml b/.depot/workflows/ci.yml index f70ad85..fb41e00 100644 --- a/.depot/workflows/ci.yml +++ b/.depot/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: git diff --exit-code go.mod go.sum - name: go vet run: go vet ./... + - name: Vulnerability scan + run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... - name: Tests (race detector) run: go test -race ./... - name: Build diff --git a/.depot/workflows/release.yml b/.depot/workflows/release.yml index 4739bd9..d1d1b94 100644 --- a/.depot/workflows/release.yml +++ b/.depot/workflows/release.yml @@ -55,6 +55,8 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod + - name: Vulnerability scan + run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... - name: Tests (race detector) run: go test -race ./... - name: Package all platforms diff --git a/README.md b/README.md index c2aeed7..a660e98 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ curl -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh | sh 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). -From source (Go 1.26+): `go install ./cmd/oytc` from a clone, or `make build`. +From source (Go 1.26.5+): `go install ./cmd/oytc` from a clone, or `make build`. ## Quick start 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= From 714d98d39c3b6dcee4996194e61fc9656bb28d69 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:44:03 +0000 Subject: [PATCH 02/20] fix: block credentialed cross-origin redirects Co-authored-by: Ben Davis --- internal/youtube/client.go | 29 ++++++++++++++++++-- internal/youtube/client_test.go | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/internal/youtube/client.go b/internal/youtube/client.go index b3946de..8946446 100644 --- a/internal/youtube/client.go +++ b/internal/youtube/client.go @@ -189,10 +189,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} } - return &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 &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 { diff --git a/internal/youtube/client_test.go b/internal/youtube/client_test.go index c02122c..3ff80a6 100644 --- a/internal/youtube/client_test.go +++ b/internal/youtube/client_test.go @@ -95,6 +95,53 @@ func TestGetWithoutAuthenticationSendsNoKey(t *testing.T) { } } +func TestGetRefusesCrossOriginCredentialRedirect(t *testing.T) { + var redirectedRequests atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + redirectedRequests.Add(1) + })) + defer target.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Goog-Api-Key") != "super-secret" { + t.Errorf("source key header = %q", r.Header.Get("X-Goog-Api-Key")) + } + http.Redirect(w, r, target.URL+"/stolen", http.StatusFound) + })) + defer source.Close() + + client := testClient(source, "super-secret") + _, err := client.Get(context.Background(), "videos", url.Values{}) + if err == nil || !strings.Contains(err.Error(), "origin-changing redirect") { + t.Fatalf("error = %v", err) + } + if redirectedRequests.Load() != 0 { + t.Fatalf("redirect target received %d request(s)", redirectedRequests.Load()) + } +} + +func TestGetAllowsSameOriginCredentialRedirect(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/youtube/v3/videos" { + http.Redirect(w, r, "/youtube/v3/redirected", http.StatusFound) + return + } + if r.Header.Get("X-Goog-Api-Key") != "super-secret" { + t.Errorf("redirected key header = %q", r.Header.Get("X-Goog-Api-Key")) + } + _, _ = w.Write([]byte(`{"items":[{"id":"ok"}]}`)) + })) + defer server.Close() + + client := testClient(server, "super-secret") + response, err := client.Get(context.Background(), "videos", url.Values{}) + if err != nil { + t.Fatal(err) + } + if response.Items[0]["id"] != "ok" { + t.Fatalf("response = %#v", response) + } +} + func TestStructuredAPIErrorAndRetry(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { From de64eb57b3974532719dc7dfd5a4478ac7702a9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:44:03 +0000 Subject: [PATCH 03/20] fix: sanitize terminal control sequences Co-authored-by: Ben Davis --- internal/output/output.go | 10 ++++++++-- internal/output/output_test.go | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/internal/output/output.go b/internal/output/output.go index 12d303c..9d3a88b 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) } @@ -147,5 +148,10 @@ 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) } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 71ea038..0202d92 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -25,12 +25,17 @@ 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) } From 344f1703ca8b7be6e703408578ebbcd018798b0c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:48:15 +0000 Subject: [PATCH 04/20] fix: harden update download boundaries Co-authored-by: Ben Davis --- internal/update/update.go | 57 +++++++++++++++++++++++++++++++--- internal/update/update_test.go | 46 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/internal/update/update.go b/internal/update/update.go index b1d783f..c3b3b78 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path" "path/filepath" @@ -145,6 +146,9 @@ func (u *Updater) Run(ctx context.Context, options Options) (Result, error) { if err != nil { return result, err } + if err := validateAssetURLs(u.apiBaseURL(), assetURL, checksumsURL); err != nil { + return result, err + } expected, err := u.fetchChecksum(ctx, checksumsURL, result.AssetName) if err != nil { return result, err @@ -196,10 +200,27 @@ 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 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 (u *Updater) apiBaseURL() string { @@ -286,6 +307,23 @@ func findAssets(release Release, assetName string) (assetURL, checksumsURL strin return assetURL, checksumsURL, nil } +func validateAssetURLs(apiBase string, assets ...string) error { + base, err := url.Parse(apiBase) + if err != nil { + return fmt.Errorf("parse release API URL: %w", err) + } + for _, asset := range assets { + parsed, err := url.Parse(asset) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("release metadata contains an invalid asset URL %q", asset) + } + 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) + } + } + 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 +378,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 +465,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 +478,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..1632810 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,51 @@ func TestUpdateMissingAssetForPlatform(t *testing.T) { } } +func TestValidateAssetURLsRejectsInsecureOrInvalidURLs(t *testing.T) { + if err := validateAssetURLs("https://api.github.com", "https://github.com/archive", "https://github.com/checksums"); err != nil { + t.Fatal(err) + } + for _, asset := range []string{"http://github.com/archive", "file:///tmp/archive", "/relative/archive"} { + if err := validateAssetURLs("https://api.github.com", asset); err == nil { + t.Fatalf("validateAssetURLs accepted %q", asset) + } + } + if err := validateAssetURLs("http://127.0.0.1:8080", "http://127.0.0.1:8080/archive"); err != nil { + t.Fatalf("local HTTP fixture was rejected: %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 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)) From 5469733f7a09be7d4777504866b5ce220136baa8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:48:15 +0000 Subject: [PATCH 05/20] fix: reject oversized API responses Co-authored-by: Ben Davis --- internal/youtube/client.go | 11 +++++++++-- internal/youtube/client_test.go | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/internal/youtube/client.go b/internal/youtube/client.go index 8946446..05dc6ce 100644 --- a/internal/youtube/client.go +++ b/internal/youtube/client.go @@ -17,6 +17,7 @@ import ( ) const DefaultBaseURL = "https://www.googleapis.com/youtube/v3" +const maxResponseBytes = 16 << 20 type TokenSource func(context.Context, bool) (string, error) @@ -117,11 +118,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 { diff --git a/internal/youtube/client_test.go b/internal/youtube/client_test.go index 3ff80a6..52c58db 100644 --- a/internal/youtube/client_test.go +++ b/internal/youtube/client_test.go @@ -1,6 +1,7 @@ package youtube import ( + "bytes" "context" "errors" "fmt" @@ -119,6 +120,19 @@ func TestGetRefusesCrossOriginCredentialRedirect(t *testing.T) { } } +func TestGetRejectsOversizedResponse(t *testing.T) { + payload := bytes.Repeat([]byte("x"), maxResponseBytes+1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(payload) + })) + defer server.Close() + + client := testClient(server, "key") + if _, err := client.Get(context.Background(), "videos", url.Values{}); err == nil || !strings.Contains(err.Error(), "response exceeds") { + t.Fatalf("error = %v", err) + } +} + func TestGetAllowsSameOriginCredentialRedirect(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/youtube/v3/videos" { From 51058ff09908c4da7658b598b40f0b9ba1b5bf66 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:48:15 +0000 Subject: [PATCH 06/20] fix: validate credential files before loading Co-authored-by: Ben Davis --- internal/config/config.go | 27 ++++++++++++++++++-- internal/config/config_test.go | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 04aea84..bec32a0 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,6 +18,7 @@ const ( envKey = "OYTC_API_KEY" envOAuthClientID = "OYTC_OAUTH_CLIENT_ID" envOAuthClientSecret = "OYTC_OAUTH_CLIENT_SECRET" + maxCredentialBytes = 1 << 20 ) type File struct { @@ -251,12 +253,33 @@ func acquireUpdateLock(path string) (func(), error) { } func loadFile(path string) (File, bool, error) { - data, err := os.ReadFile(path) + info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return File{}, false, nil } if err != nil { - return File{}, false, fmt.Errorf("read credentials: %w", err) + return File{}, false, fmt.Errorf("inspect credentials: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return File{}, true, errors.New("read credentials: auth.json must not be a symbolic link") + } + if !info.Mode().IsRegular() { + return File{}, true, errors.New("read credentials: auth.json must be a regular file") + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { + return File{}, true, fmt.Errorf("read credentials: insecure permissions %04o on auth.json; run chmod 600", info.Mode().Perm()) + } + handle, err := os.Open(path) + if err != nil { + return File{}, true, fmt.Errorf("read credentials: %w", err) + } + data, readErr := io.ReadAll(io.LimitReader(handle, maxCredentialBytes+1)) + closeErr := handle.Close() + if readErr != nil || closeErr != nil { + return File{}, true, fmt.Errorf("read credentials: %w", errors.Join(readErr, closeErr)) + } + if len(data) > maxCredentialBytes { + return File{}, true, fmt.Errorf("read credentials: file exceeds %d bytes", maxCredentialBytes) } var file File if err := json.Unmarshal(data, &file); err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2ab08cc..57bd29c 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,51 @@ 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 TestLoadRejectsInsecureCredentialFile(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) + } + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "insecure permissions") { + t.Fatalf("Load with permissive mode = %v", err) + } + + 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 TestEnvironmentKeyHasPrecedence(t *testing.T) { t.Setenv("OYTC_CONFIG_DIR", t.TempDir()) t.Setenv("OYTC_API_KEY", "environment-secret") From 4130d6e34a9e1d636f81bc4a80677cd0a47dc910 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:48:15 +0000 Subject: [PATCH 07/20] fix: bound live chat deduplication memory Co-authored-by: Ben Davis --- internal/cli/app_test.go | 16 ++++++++++++++++ internal/cli/live_chat.go | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 23abd13..0860e95 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -429,6 +429,22 @@ 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 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..d38e15c 100644 --- a/internal/cli/live_chat.go +++ b/internal/cli/live_chat.go @@ -14,6 +14,8 @@ import ( "open-yt-cli/internal/youtube" ) +const liveChatDedupWindow = 10000 + 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 +80,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 +95,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 { @@ -139,6 +138,32 @@ func (a *App) liveChatStreamCommand() *cobra.Command { return cmd } +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") From b731e4eae8153fb7f7fed88cf9b8ae5bebfd17f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:48:15 +0000 Subject: [PATCH 08/20] fix: harden release installers Co-authored-by: Ben Davis --- site/install.ps1 | 3 +++ site/install.sh | 39 ++++++++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/site/install.ps1 b/site/install.ps1 index 92bbe1b..2e2b366 100644 --- a/site/install.ps1 +++ b/site/install.ps1 @@ -51,6 +51,9 @@ 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) { diff --git a/site/install.sh b/site/install.sh index 717b5ab..5a6d290 100755 --- a/site/install.sh +++ b/site/install.sh @@ -37,6 +37,24 @@ fail() { command -v curl >/dev/null 2>&1 || fail "curl is required" command -v tar >/dev/null 2>&1 || fail "tar is required" +fetch() { + case "$1" in + https://*) curl --proto '=https' --proto-redir '=https' -fsSL "$1" ;; + http://127.0.0.1:* | http://localhost:*) curl -fsSL "$1" ;; + *) fail "refusing to download from insecure URL: $1" ;; + esac +} + +fetch_to() { + output="$1" + url="$2" + case "$url" in + https://*) curl --proto '=https' --proto-redir '=https' -fsSL -o "$output" "$url" ;; + http://127.0.0.1:* | http://localhost:*) curl -fsSL -o "$output" "$url" ;; + *) fail "refusing to download from insecure URL: $url" ;; + esac +} + # --- Detect platform ------------------------------------------------------- os="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$os" in @@ -58,7 +76,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)" @@ -74,15 +92,24 @@ 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)}')" @@ -116,10 +143,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 From 30b5b20ddf9012cf4961b395922d989b7434372b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:51:42 +0000 Subject: [PATCH 09/20] fix: validate analytics video filters Co-authored-by: Ben Davis --- internal/cli/analytics.go | 6 ++++++ internal/cli/app_test.go | 4 ++++ 2 files changed, 10 insertions(+) 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 0860e95..56e44d1 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -169,6 +169,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) { From fd62ac7ad215ec13c5880bdf93ac8362f8100e6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:54:02 +0000 Subject: [PATCH 10/20] fix: prevent release tag command injection Co-authored-by: Ben Davis --- .depot/workflows/ci.yml | 10 ++++++++++ .depot/workflows/release.yml | 20 ++++++++++++-------- scripts/package.sh | 11 ++++------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.depot/workflows/ci.yml b/.depot/workflows/ci.yml index fb41e00..dd79304 100644 --- a/.depot/workflows/ci.yml +++ b/.depot/workflows/ci.yml @@ -78,6 +78,16 @@ 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 + test ! -e "$marker" - name: Skill structure run: | python3 - <<'EOF' diff --git a/.depot/workflows/release.yml b/.depot/workflows/release.yml index d1d1b94..36b15a4 100644 --- a/.depot/workflows/release.yml +++ b/.depot/workflows/release.yml @@ -43,11 +43,11 @@ 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" + if ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-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 }} @@ -60,13 +60,17 @@ jobs: - 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/scripts/package.sh b/scripts/package.sh index 0ea671b..23b5cb4 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -23,13 +23,10 @@ if [ -z "$VERSION" ]; then echo "usage: scripts/package.sh [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 +if ! printf '%s\n' "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-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)}" From e36c7f0bbebcd8f7c055eca299084dfeedcc52db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:54:02 +0000 Subject: [PATCH 11/20] fix: harden installer extraction and guidance Co-authored-by: Ben Davis --- README.md | 9 +++++++-- docs/releasing.md | 7 +++++-- site/index.html | 7 +++++-- site/install.ps1 | 19 +++++++++++++++---- site/install.sh | 7 ++++++- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a660e98..1e714a3 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,12 @@ 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 +curl -fsSLO https://davis7dotsh.github.io/open-yt-cli/install.sh +sh install.sh +rm install.sh ``` -Windows (PowerShell): `irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 | iex`, +Windows (PowerShell): `irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 -OutFile install.ps1; & .\install.ps1`, or 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`. @@ -91,6 +93,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/site/index.html b/site/index.html index 749697c..568c698 100644 --- a/site/index.html +++ b/site/index.html @@ -102,11 +102,14 @@

oytc — open YouTube CLI

Install (macOS & Linux)

-
curl -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh | sh
+
curl -fsSLO https://davis7dotsh.github.io/open-yt-cli/install.sh
+sh install.sh
+rm install.sh

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 + (irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 -OutFile install.ps1; & .\install.ps1) + or grab a zip from the releases page.

Quick setup

diff --git a/site/install.ps1 b/site/install.ps1 index 2e2b366..b1db58e 100644 --- a/site/install.ps1 +++ b/site/install.ps1 @@ -1,6 +1,7 @@ # oytc installer for Windows — https://github.com/davis7dotsh/open-yt-cli # -# irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 | iex +# irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 -OutFile install.ps1 +# & .\install.ps1 # # Optional environment variables: # OYTC_VERSION release tag to install, e.g. v0.2.0 (default: latest) @@ -30,6 +31,9 @@ if (-not $version) { } elseif ($version -notmatch '^v') { $version = "v$version" } +if ($version -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-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" @@ -60,9 +64,16 @@ try { 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 5a6d290..9422c15 100755 --- a/site/install.sh +++ b/site/install.sh @@ -1,7 +1,8 @@ #!/bin/sh # oytc installer — https://github.com/davis7dotsh/open-yt-cli # -# curl -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh | sh +# curl -fsSLO https://davis7dotsh.github.io/open-yt-cli/install.sh +# sh install.sh # # Options (environment variables): # OYTC_VERSION release tag to install, e.g. v0.2.0 (default: latest) @@ -86,6 +87,9 @@ else *) version="v${version}" ;; esac fi +if ! printf '%s\n' "$version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-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})" @@ -125,6 +129,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 ---------------------------------------------------------------- From 93bce421db86c70a601f9500b9722ac362e36d9e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:59:09 +0000 Subject: [PATCH 12/20] fix: prevent cross-repository self-updates Co-authored-by: Ben Davis --- internal/update/update.go | 67 +++++++++++++++++++++++++++----- internal/update/update_test.go | 70 ++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 14 deletions(-) diff --git a/internal/update/update.go b/internal/update/update.go index c3b3b78..cfe48ed 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -24,6 +24,7 @@ import ( "os" "path" "path/filepath" + "regexp" "runtime" "strconv" "strings" @@ -33,6 +34,8 @@ import ( var ( defaultGOOS = runtime.GOOS defaultGOARCH = runtime.GOARCH + releaseTagRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-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. @@ -146,7 +149,7 @@ func (u *Updater) Run(ctx context.Context, options Options) (Result, error) { if err != nil { return result, err } - if err := validateAssetURLs(u.apiBaseURL(), assetURL, checksumsURL); err != nil { + 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) @@ -212,6 +215,9 @@ func (u *Updater) httpClient() *http.Client { 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) } @@ -223,6 +229,11 @@ func (u *Updater) httpClient() *http.Client { 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 { if u.APIBaseURL != "" { return strings.TrimRight(u.APIBaseURL, "/") @@ -238,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 { @@ -256,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 { @@ -307,18 +342,30 @@ func findAssets(release Release, assetName string) (assetURL, checksumsURL strin return assetURL, checksumsURL, nil } -func validateAssetURLs(apiBase string, assets ...string) error { +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 assets { - parsed, err := url.Parse(asset) + 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) + 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) + 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 diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 1632810..2cf0729 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -288,19 +288,61 @@ func TestUpdateMissingAssetForPlatform(t *testing.T) { } func TestValidateAssetURLsRejectsInsecureOrInvalidURLs(t *testing.T) { - if err := validateAssetURLs("https://api.github.com", "https://github.com/archive", "https://github.com/checksums"); err != nil { + 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/archive", "file:///tmp/archive", "/relative/archive"} { - if err := validateAssetURLs("https://api.github.com", asset); err == nil { + 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", "http://127.0.0.1:8080/archive"); err != nil { + 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", + } { + 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) { @@ -321,6 +363,26 @@ func TestUpdaterRefusesHTTPSRedirectDowngrade(t *testing.T) { } } +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) From c8a93f75b2b1463fbcd648d172b965803f1e2273 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 21:59:09 +0000 Subject: [PATCH 13/20] fix: bound network waits and OAuth redirects Co-authored-by: Ben Davis --- internal/cli/app_test.go | 13 +++++++++++++ internal/cli/live_chat.go | 16 ++++++++++++---- internal/oauth/oauth.go | 12 ++++++++++-- internal/oauth/oauth_test.go | 20 ++++++++++++++++++++ internal/youtube/client.go | 6 +++++- internal/youtube/client_test.go | 9 +++++++++ 6 files changed, 69 insertions(+), 7 deletions(-) diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 56e44d1..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" @@ -449,6 +450,18 @@ func TestRecentIDsEvictsOldEntries(t *testing.T) { } } +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 d38e15c..9c8f0aa 100644 --- a/internal/cli/live_chat.go +++ b/internal/cli/live_chat.go @@ -15,6 +15,7 @@ import ( ) 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"} @@ -121,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 @@ -138,6 +136,16 @@ 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 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/youtube/client.go b/internal/youtube/client.go index 05dc6ce..3eb55e8 100644 --- a/internal/youtube/client.go +++ b/internal/youtube/client.go @@ -18,6 +18,7 @@ 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) @@ -256,8 +257,11 @@ 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 } base := time.Duration(1< Date: Tue, 11 Aug 2026 21:59:09 +0000 Subject: [PATCH 14/20] fix: neutralize TSV spreadsheet formulas Co-authored-by: Ben Davis --- internal/output/output.go | 17 ++++++++++++++++- internal/output/output_test.go | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/output/output.go b/internal/output/output.go index 9d3a88b..db23683 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -88,7 +88,14 @@ 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))) + value := pathValue(item, column) + rendered := cell(value) + if options.Format == "tsv" { + if _, ok := value.(string); ok { + rendered = spreadsheetSafe(rendered) + } + } + fmt.Fprint(target, rendered) } fmt.Fprintln(target) } @@ -155,3 +162,11 @@ func clean(value string) string { return r }, value) } + +func spreadsheetSafe(value string) string { + trimmed := strings.TrimLeftFunc(value, unicode.IsSpace) + 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 0202d92..8a49fe8 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -41,6 +41,21 @@ func TestTSVColumnsAndSanitization(t *testing.T) { } } +func TestTSVNeutralizesSpreadsheetFormulasInStrings(t *testing.T) { + result := youtube.ListResult{Items: []map[string]any{{ + "title": "=HYPERLINK(\"https://example.invalid\")", + "count": json.Number("-5"), + }}} + var buffer bytes.Buffer + if err := Render(&buffer, result, Options{Format: "tsv", Columns: []string{"title", "count"}, NoHeader: true}); err != nil { + t.Fatal(err) + } + want := "'=HYPERLINK(\"https://example.invalid\")\t-5\n" + if buffer.String() != want { + t.Fatalf("TSV = %q, want %q", buffer.String(), want) + } +} + func TestJSONLEmitsOneItemPerLine(t *testing.T) { result := youtube.ListResult{Items: []map[string]any{{"id": "a"}, {"id": "b"}}} var buffer bytes.Buffer From 67374bdcbe97bc85030b4c3b20a87a31f7092d0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:17:02 +0000 Subject: [PATCH 15/20] fix: address security review feedback Co-authored-by: Ben Davis --- .depot/workflows/ci.yml | 21 ++++++++++++++++++-- .depot/workflows/release.yml | 15 ++++++++++++--- internal/output/output.go | 10 +++++----- internal/output/output_test.go | 5 +++-- scripts/package.sh | 4 +++- site/install.ps1 | 2 +- site/install.sh | 35 +++++++++++++++++++++++++++++----- 7 files changed, 73 insertions(+), 19 deletions(-) diff --git a/.depot/workflows/ci.yml b/.depot/workflows/ci.yml index dd79304..189fb82 100644 --- a/.depot/workflows/ci.yml +++ b/.depot/workflows/ci.yml @@ -38,8 +38,15 @@ jobs: git diff --exit-code go.mod go.sum - name: go vet run: go vet ./... - - name: Vulnerability scan - run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... + - 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 ==" + GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./... + done - name: Tests (race detector) run: go test -race ./... - name: Build @@ -87,6 +94,11 @@ jobs: 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 test ! -e "$marker" - name: Skill structure run: | @@ -130,3 +142,8 @@ 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 + if 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 diff --git a/.depot/workflows/release.yml b/.depot/workflows/release.yml index 36b15a4..c81fea3 100644 --- a/.depot/workflows/release.yml +++ b/.depot/workflows/release.yml @@ -43,7 +43,9 @@ jobs: else tag="${GITHUB_REF_NAME}" fi - if ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$'; then + tag_newlines="$(printf '%s' "$tag" | wc -l | tr -d '[:space:]')" + if [ "$tag_newlines" != "0" ] || + ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-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 @@ -55,8 +57,15 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - - name: Vulnerability scan - run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... + - 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 ==" + GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./... + done - name: Tests (race detector) run: go test -race ./... - name: Package all platforms diff --git a/internal/output/output.go b/internal/output/output.go index db23683..6be89c0 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -88,12 +88,9 @@ func renderRows(w io.Writer, items []map[string]any, options Options) error { if i > 0 { fmt.Fprint(target, "\t") } - value := pathValue(item, column) - rendered := cell(value) + rendered := cell(pathValue(item, column)) if options.Format == "tsv" { - if _, ok := value.(string); ok { - rendered = spreadsheetSafe(rendered) - } + rendered = spreadsheetSafe(rendered) } fmt.Fprint(target, rendered) } @@ -165,6 +162,9 @@ func clean(value string) string { 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 } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 8a49fe8..c56fa6b 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -45,12 +45,13 @@ 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"}, NoHeader: true}); err != nil { + 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\n" + 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/scripts/package.sh b/scripts/package.sh index 23b5cb4..8bfee25 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -23,7 +23,9 @@ if [ -z "$VERSION" ]; then echo "usage: scripts/package.sh [output-dir]" >&2 exit 2 fi -if ! printf '%s\n' "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$'; then +version_newlines="$(printf '%s' "$VERSION" | wc -l | tr -d '[:space:]')" +if [ "$version_newlines" != "0" ] || + ! printf '%s\n' "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-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 diff --git a/site/install.ps1 b/site/install.ps1 index b1db58e..1130ff5 100644 --- a/site/install.ps1 +++ b/site/install.ps1 @@ -31,7 +31,7 @@ if (-not $version) { } elseif ($version -notmatch '^v') { $version = "v$version" } -if ($version -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') { +if ($version -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-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')." } diff --git a/site/install.sh b/site/install.sh index 9422c15..81519af 100755 --- a/site/install.sh +++ b/site/install.sh @@ -38,11 +38,32 @@ 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%%/*}" + host="${authority%:*}" + port="${authority##*:}" + 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" ;; - http://127.0.0.1:* | http://localhost:*) curl -fsSL "$1" ;; - *) fail "refusing to download from insecure URL: $1" ;; + *) + is_loopback_http "$1" || fail "refusing to download from insecure URL: $1" + curl -fsSL "$1" + ;; esac } @@ -51,8 +72,10 @@ fetch_to() { url="$2" case "$url" in https://*) curl --proto '=https' --proto-redir '=https' -fsSL -o "$output" "$url" ;; - http://127.0.0.1:* | http://localhost:*) curl -fsSL -o "$output" "$url" ;; - *) fail "refusing to download from insecure URL: $url" ;; + *) + is_loopback_http "$url" || fail "refusing to download from insecure URL: $url" + curl -fsSL -o "$output" "$url" + ;; esac } @@ -87,7 +110,9 @@ else *) version="v${version}" ;; esac fi -if ! printf '%s\n' "$version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$'; then +version_newlines="$(printf '%s' "$version" | wc -l | tr -d '[:space:]')" +if [ "$version_newlines" != "0" ] || + ! printf '%s\n' "$version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-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 From 730373e7363f1719905e33ca3513c0ee5de8af00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:17:39 +0000 Subject: [PATCH 16/20] fix: use configured Go toolchain for scans Co-authored-by: Ben Davis --- .depot/workflows/ci.yml | 2 +- .depot/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.depot/workflows/ci.yml b/.depot/workflows/ci.yml index 189fb82..35949e8 100644 --- a/.depot/workflows/ci.yml +++ b/.depot/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: scanner="$(go env GOPATH)/bin/govulncheck" for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do echo "== $platform ==" - GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./... + GOTOOLCHAIN=local GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./... done - name: Tests (race detector) run: go test -race ./... diff --git a/.depot/workflows/release.yml b/.depot/workflows/release.yml index c81fea3..220cd98 100644 --- a/.depot/workflows/release.yml +++ b/.depot/workflows/release.yml @@ -64,7 +64,7 @@ jobs: scanner="$(go env GOPATH)/bin/govulncheck" for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do echo "== $platform ==" - GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./... + GOTOOLCHAIN=local GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./... done - name: Tests (race detector) run: go test -race ./... From 4c3d016013e4b26c756de80771f3a52317e8f48c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:28:04 +0000 Subject: [PATCH 17/20] fix: close remaining security review gaps Co-authored-by: Ben Davis --- .depot/workflows/ci.yml | 6 +++ .depot/workflows/release.yml | 2 +- README.md | 26 ++++++++-- internal/config/config.go | 40 +++++++++++----- internal/config/config_test.go | 30 ++++++++++++ internal/config/open_credential_unix.go | 21 ++++++++ internal/config/open_credential_unix_test.go | 28 +++++++++++ internal/config/open_credential_windows.go | 50 ++++++++++++++++++++ internal/update/update.go | 2 +- internal/update/update_test.go | 2 + internal/youtube/client.go | 6 +++ internal/youtube/client_test.go | 3 ++ scripts/package.sh | 2 +- site/index.html | 24 +++++++--- site/install.ps1 | 7 +-- site/install.sh | 8 ++-- 16 files changed, 223 insertions(+), 34 deletions(-) create mode 100644 internal/config/open_credential_unix.go create mode 100644 internal/config/open_credential_unix_test.go create mode 100644 internal/config/open_credential_windows.go diff --git a/.depot/workflows/ci.yml b/.depot/workflows/ci.yml index 35949e8..d39ec93 100644 --- a/.depot/workflows/ci.yml +++ b/.depot/workflows/ci.yml @@ -99,6 +99,12 @@ jobs: 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: | diff --git a/.depot/workflows/release.yml b/.depot/workflows/release.yml index 220cd98..f2dd312 100644 --- a/.depot/workflows/release.yml +++ b/.depot/workflows/release.yml @@ -45,7 +45,7 @@ jobs: fi tag_newlines="$(printf '%s' "$tag" | wc -l | tr -d '[:space:]')" if [ "$tag_newlines" != "0" ] || - ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$'; then + ! 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 diff --git a/README.md b/README.md index 1e714a3..122cbf0 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,29 @@ write commands. macOS / Linux (verifies SHA-256 before installing; no root needed): ```sh -curl -fsSLO https://davis7dotsh.github.io/open-yt-cli/install.sh -sh install.sh -rm install.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 -OutFile install.ps1; & .\install.ps1`, -or download a zip from [releases](https://github.com/davis7dotsh/open-yt-cli/releases). +Windows (PowerShell): + +```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`. diff --git a/internal/config/config.go b/internal/config/config.go index bec32a0..a193f4a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,8 @@ const ( 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"` @@ -253,39 +255,51 @@ func acquireUpdateLock(path string) (func(), error) { } func loadFile(path string) (File, bool, error) { - info, err := os.Lstat(path) + handle, err := openCredentialFile(path) if errors.Is(err, os.ErrNotExist) { return File{}, false, nil } if err != nil { - return File{}, false, fmt.Errorf("inspect 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 { - return File{}, true, errors.New("read credentials: auth.json must not be a symbolic link") + handle.Close() + return File{}, errors.New("read credentials: auth.json must not be a symbolic link") } if !info.Mode().IsRegular() { - return File{}, true, errors.New("read credentials: auth.json must be a regular file") + handle.Close() + return File{}, errors.New("read credentials: auth.json must be a regular file") } if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { - return File{}, true, fmt.Errorf("read credentials: insecure permissions %04o on auth.json; run chmod 600", info.Mode().Perm()) - } - handle, err := os.Open(path) - if err != nil { - return File{}, true, fmt.Errorf("read credentials: %w", err) + handle.Close() + return File{}, fmt.Errorf("read credentials: insecure permissions %04o on auth.json; run chmod 600", info.Mode().Perm()) } data, readErr := io.ReadAll(io.LimitReader(handle, maxCredentialBytes+1)) closeErr := handle.Close() if readErr != nil || closeErr != nil { - return File{}, true, fmt.Errorf("read credentials: %w", errors.Join(readErr, closeErr)) + return File{}, fmt.Errorf("read credentials: %w", errors.Join(readErr, closeErr)) } if len(data) > maxCredentialBytes { - return File{}, true, fmt.Errorf("read credentials: file exceeds %d bytes", 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 57bd29c..28da737 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -244,6 +244,36 @@ func TestLoadRejectsInsecureCredentialFile(t *testing.T) { } } +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/update/update.go b/internal/update/update.go index cfe48ed..b275dbe 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -34,7 +34,7 @@ import ( var ( defaultGOOS = runtime.GOOS defaultGOARCH = runtime.GOARCH - releaseTagRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$`) + 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_.-]+$`) ) diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 2cf0729..9f03aba 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -322,6 +322,8 @@ func TestResolveReleaseRejectsUnsafeOrMismatchedTags(t *testing.T) { "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) diff --git a/internal/youtube/client.go b/internal/youtube/client.go index 3eb55e8..5017c9d 100644 --- a/internal/youtube/client.go +++ b/internal/youtube/client.go @@ -262,6 +262,12 @@ func backoff(attempt int, retryAfter string) time.Duration { } return time.Duration(seconds) * time.Second } + if attempt < 0 { + attempt = 0 + } + if attempt >= 8 { + return maxRetryDelay + } base := time.Duration(1<&2 exit 2 fi diff --git a/site/index.html b/site/index.html index 568c698..4b86703 100644 --- a/site/index.html +++ b/site/index.html @@ -102,14 +102,24 @@

oytc — open YouTube CLI

Install (macOS & Linux)

-
curl -fsSLO https://davis7dotsh.github.io/open-yt-cli/install.sh
-sh install.sh
-rm install.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 -OutFile install.ps1; & .\install.ps1) - 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 1130ff5..e7a2205 100644 --- a/site/install.ps1 +++ b/site/install.ps1 @@ -1,7 +1,8 @@ # oytc installer for Windows — https://github.com/davis7dotsh/open-yt-cli # -# irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 -OutFile install.ps1 -# & .\install.ps1 +# $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) @@ -31,7 +32,7 @@ if (-not $version) { } elseif ($version -notmatch '^v') { $version = "v$version" } -if ($version -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?\z') { +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')." } diff --git a/site/install.sh b/site/install.sh index 81519af..3d36db5 100755 --- a/site/install.sh +++ b/site/install.sh @@ -1,8 +1,10 @@ #!/bin/sh # oytc installer — https://github.com/davis7dotsh/open-yt-cli # -# curl -fsSLO https://davis7dotsh.github.io/open-yt-cli/install.sh -# sh install.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) @@ -112,7 +114,7 @@ else fi version_newlines="$(printf '%s' "$version" | wc -l | tr -d '[:space:]')" if [ "$version_newlines" != "0" ] || - ! printf '%s\n' "$version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$'; then + ! 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 From f763006d25106093bb79505db6aa2b548c7d986e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:29:50 +0000 Subject: [PATCH 18/20] fix: self-heal credential permissions Co-authored-by: Ben Davis --- .depot/workflows/ci.yml | 16 +++++++++++++++- internal/config/config.go | 17 +++++++++++++++-- internal/config/config_test.go | 13 ++++++++++--- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.depot/workflows/ci.yml b/.depot/workflows/ci.yml index d39ec93..5711f55 100644 --- a/.depot/workflows/ci.yml +++ b/.depot/workflows/ci.yml @@ -148,8 +148,22 @@ 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 - if HOME="$PWD/fake-home" OYTC_VERSION=v0.0.0-ci \ + 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: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/internal/config/config.go b/internal/config/config.go index a193f4a..ff3bc1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -284,8 +284,21 @@ func readCredentialFile(handle *os.File) (File, error) { return File{}, errors.New("read credentials: auth.json must be a regular file") } if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { - handle.Close() - return File{}, fmt.Errorf("read credentials: insecure permissions %04o on auth.json; run chmod 600", info.Mode().Perm()) + 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") + } + 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() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 28da737..5f045c1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -211,7 +211,7 @@ func TestLoadRejectsOversizedCredentialFile(t *testing.T) { } } -func TestLoadRejectsInsecureCredentialFile(t *testing.T) { +func TestLoadSecuresCredentialFileAndRejectsSymlink(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("POSIX permission and symlink checks") } @@ -225,8 +225,15 @@ func TestLoadRejectsInsecureCredentialFile(t *testing.T) { if err := os.Chmod(path, 0o644); err != nil { t.Fatal(err) } - if _, err := Load(); err == nil || !strings.Contains(err.Error(), "insecure permissions") { - t.Fatalf("Load with permissive mode = %v", 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 { From a592c4d8e05360c275d33ca6776f04c25ef11bc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:34:25 +0000 Subject: [PATCH 19/20] fix: preserve credential close errors Co-authored-by: Ben Davis --- internal/config/config.go | 10 +++++----- internal/config/config_test.go | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index ff3bc1b..46f4c90 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -276,12 +276,12 @@ func readCredentialFile(handle *os.File) (File, error) { return File{}, fmt.Errorf("inspect credentials: %w", errors.Join(statErr, closeErr)) } if info.Mode()&os.ModeSymlink != 0 { - handle.Close() - return File{}, errors.New("read credentials: auth.json must not be a symbolic link") + validationErr := errors.New("read credentials: auth.json must not be a symbolic link") + return File{}, errors.Join(validationErr, handle.Close()) } if !info.Mode().IsRegular() { - handle.Close() - return File{}, errors.New("read credentials: auth.json must be a regular file") + 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() @@ -292,7 +292,7 @@ func readCredentialFile(handle *os.File) (File, error) { if secureErr == nil { secureErr = errors.New("filesystem did not apply mode 0600") } - handle.Close() + secureErr = errors.Join(secureErr, handle.Close()) return File{}, fmt.Errorf( "read credentials: insecure permissions %04o on auth.json; chmod 600 failed: %w", originalMode, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5f045c1..b21b46a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -269,7 +269,7 @@ func TestOpenedCredentialFileUnaffectedByPathReplacement(t *testing.T) { t.Fatal(err) } if err := os.Rename(replacement, path); err != nil { - handle.Close() + _ = handle.Close() t.Fatal(err) } file, err := readCredentialFile(handle) From 2772c20dd024fcf371ab9a197435b2ddeb279d84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:43:05 +0000 Subject: [PATCH 20/20] fix: accept default-port loopback URLs Co-authored-by: Ben Davis --- .depot/workflows/ci.yml | 11 +++++++++++ site/install.sh | 12 ++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.depot/workflows/ci.yml b/.depot/workflows/ci.yml index 5711f55..9dedf96 100644 --- a/.depot/workflows/ci.yml +++ b/.depot/workflows/ci.yml @@ -157,6 +157,17 @@ jobs: 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 diff --git a/site/install.sh b/site/install.sh index 3d36db5..b63105b 100755 --- a/site/install.sh +++ b/site/install.sh @@ -47,8 +47,16 @@ is_loopback_http() { esac authority="${1#http://}" authority="${authority%%/*}" - host="${authority%:*}" - port="${authority##*:}" + case "$authority" in + *:*) + host="${authority%:*}" + port="${authority##*:}" + ;; + *) + host="$authority" + port="80" + ;; + esac case "$host" in 127.0.0.1 | localhost) ;; *) return 1 ;;