Prevent GitHub CLI and extensions from executing arbitrary commands from compromised GitHub Enterprise Server
Description
go-gh is a collection of Go modules to make authoring GitHub CLI extensions easier. A security vulnerability has been identified in versions prior to 2.12.1 where an attacker-controlled GitHub Enterprise Server could result in executing arbitrary commands on a user's machine by replacing HTTP URLs provided by GitHub with local file paths for browsing. In 2.12.1, Browser.Browse() has been enhanced to allow and disallow a variety of scenarios to avoid opening or executing files on the filesystem without unduly impacting HTTP URLs. No known workarounds are available other than upgrading.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2025-48938: go-gh prior to 2.12.1 allows arbitrary command execution via attacker-controlled GitHub Enterprise Server by replacing HTTP URLs with local file paths for browsing.
Root
Cause
The vulnerability resides in the Browser.Browse() function of the go-gh library (versions prior to 2.12.1). This function is used by GitHub CLI and its extensions to open URLs provided by API responses from authenticated GitHub hosts. The flaw arises because Browser.Browse() would attempt to open the provided URL using OS-specific approaches regardless of the URL scheme, without validating whether the URL points to an HTTP resource or a local file path [1][3].
Exploitation
An attacker controlling a GitHub Enterprise Server can modify API responses to return specially crafted URLs that are not HTTP links but instead point to local executable paths or files. When a user executes a gh command that triggers a browser transition (e.g., using --web flag or gh codespace commands), Browser.Browse() would attempt to open the attacker-supplied URL, which could be a local executable path. This allows arbitrary command execution on the user's machine without requiring additional authentication beyond the attacker's control over the enterprise server [3].
Impact
Successful exploitation can lead to arbitrary command execution on the victim's machine. The attacker effectively bypasses the intended HTTP-only browsing mechanism, gaining the ability to execute arbitrary executables. This could result in full compromise of the user's system depending on the executed payload [1][3].
Mitigation
The vulnerability is fixed in go-gh version 2.12.1. In this update, Browser.Browse() now enforces strict URL validation: it supports only http://, https://, vscode://, and vscode-insiders:// protocols; explicitly rejects file:// URLs; and prevents opening paths that match local files, directories, or executables in the user's PATH. No workarounds are available other than upgrading to 2.12.1 or later [2][3]. The affected versions are all prior to 2.12.1.
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/cli/go-gh/v2Go | < 2.12.1 | 2.12.1 |
Affected products
5- osv-coords3 versions
< 0.37.2-r1+ 2 more
- (no CPE)range: < 0.37.2-r1
- (no CPE)range: < 0.37.2-r1
- (no CPE)range: < 2.12.1
- cli/go-ghv5Range: < 2.12.1
Patches
12 files changed · +241 −8
pkg/browser/browser.go+59 −0 modified@@ -2,7 +2,9 @@ package browser import ( + "fmt" "io" + "net/url" "os" "os/exec" @@ -45,9 +47,20 @@ func (b *Browser) Browse(url string) error { } func (b *Browser) browse(url string, env []string) error { + // Ensure the URL is supported including the scheme, + // overwrite `url` for use within the function. + urlParsed, err := isPossibleProtocol(url) + if err != nil { + return err + } + + url = urlParsed.String() + + // Use default `gh` browsing module for opening URL if not customized. if b.launcher == "" { return cliBrowser.OpenURL(url) } + launcherArgs, err := shlex.Split(b.launcher) if err != nil { return err @@ -78,3 +91,49 @@ func resolveLauncher() string { } return os.Getenv("BROWSER") } + +func isSupportedScheme(scheme string) bool { + switch scheme { + case "http", "https", "vscode", "vscode-insiders": + return true + default: + return false + } +} + +func isPossibleProtocol(u string) (*url.URL, error) { + // Parse URL for known supported schemes before handling unknown cases. + urlParsed, err := url.Parse(u) + if err != nil { + return nil, fmt.Errorf("opening unparsable URL is unsupported: %s", u) + } + + if isSupportedScheme(urlParsed.Scheme) { + return urlParsed, nil + } + + // Disallow any unrecognized URL schemes if explicitly present. + if urlParsed.Scheme != "" { + return nil, fmt.Errorf("opening unsupport URL scheme: %s", u) + } + + // Disallow URLs that match existing files or directories on the filesystem + // as these could be executables or executed by the launcher browser due to + // the file extension and/or associated application. + // + // Symlinks should not be resolved in order to avoid broken links or other + // vulnerabilities trying to resolve them. + if fileInfo, _ := os.Lstat(u); fileInfo != nil { + return nil, fmt.Errorf("opening files or directories is unsupported: %s", u) + } + + // Disallow URLs that match executables found in the user path. + exec, _ := safeexec.LookPath(u) + if exec != "" { + return nil, fmt.Errorf("opening executables is unsupported: %s", u) + } + + // Otherwise, assume HTTP URL using `https` to ensure secure browsing. + urlParsed.Scheme = "https" + return urlParsed, nil +}
pkg/browser/browser_test.go+182 −8 modified@@ -4,10 +4,13 @@ import ( "bytes" "fmt" "os" + "path/filepath" + "runtime" "testing" "github.com/cli/go-gh/v2/pkg/config" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHelperProcess(t *testing.T) { @@ -18,15 +21,186 @@ func TestHelperProcess(t *testing.T) { os.Exit(0) } +// TestBrowse ensures supported URLs are opened by the browser launcher. +// Running package tests in VSCode will cause this to fail due to use of +// `-coverageprofile` flag without `GOCOVERDIR` env var. func TestBrowse(t *testing.T) { - launcher := fmt.Sprintf("%q -test.run=TestHelperProcess -- chrome", os.Args[0]) - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - b := Browser{launcher: launcher, stdout: stdout, stderr: stderr} - err := b.browse("github.com", []string{"GH_WANT_HELPER_PROCESS=1"}) - assert.NoError(t, err) - assert.Equal(t, "[chrome github.com]", stdout.String()) - assert.Equal(t, "", stderr.String()) + type browseTest struct { + name string + url string + launcher string + expected string + setup func(*testing.T) error + wantErr bool + } + + tests := []browseTest{ + { + name: "Explicit `http` URL works", + url: "http://github.com", + launcher: fmt.Sprintf("%q -test.run=TestHelperProcess -- explicit http", os.Args[0]), + expected: "[explicit http http://github.com]", + }, + { + name: "Explicit `https` URL works", + url: "https://github.com", + launcher: fmt.Sprintf("%q -test.run=TestHelperProcess -- explicit https", os.Args[0]), + expected: "[explicit https https://github.com]", + }, + { + name: "Explicit `HTTPS` URL works", + url: "HTTPS://github.com", + launcher: fmt.Sprintf("%q -test.run=TestHelperProcess -- explicit HTTPS", os.Args[0]), + expected: "[explicit HTTPS https://github.com]", + }, + { + name: "Explicit `vscode` URL works", + url: "vscode:extension/GitHub.copilot", + launcher: fmt.Sprintf("%q -test.run=TestHelperProcess -- explicit vscode", os.Args[0]), + expected: "[explicit vscode vscode:extension/GitHub.copilot]", + }, + { + name: "Explicit `vscode-insiders` URL works", + url: "vscode-insiders:extension/GitHub.copilot", + launcher: fmt.Sprintf("%q -test.run=TestHelperProcess -- explicit vscode-insiders", os.Args[0]), + expected: "[explicit vscode-insiders vscode-insiders:extension/GitHub.copilot]", + }, + { + name: "Implicit `https` URL works", + url: "github.com", + launcher: fmt.Sprintf("%q -test.run=TestHelperProcess -- implicit https", os.Args[0]), + expected: "[implicit https https://github.com]", + }, + { + name: "Explicit absolute `file://` URL errors", + url: "file:///System/Applications/Calculator.app", + wantErr: true, + }, + } + + // Setup additional test scenarios covering OS-specific executables and directories + // that should be installed on maintainer workstations and GitHub hosted runners. + switch runtime.GOOS { + case "windows": + tests = append(tests, []browseTest{ + { + name: "Explicit absolute Windows file URL errors", + url: `C:\Windows\System32\cmd.exe`, + wantErr: true, + }, + { + name: "Explicit absolute Windows directory URL errors", + url: `C:\Windows\System32`, + wantErr: true, + }, + }...) + // Default should handle common Unix/Linux scenarios including Mac OS. + default: + tests = append(tests, []browseTest{ + { + name: "Implicit absolute Unix/Linux file URL errors", + url: "/bin/bash", + wantErr: true, + }, + { + name: "Implicit absolute Unix/Linux directory URL errors", + url: "/bin", + wantErr: true, + }, + { + name: "Implicit relative Unix/Linux file URL errors", + url: "poc.command", + setup: func(t *testing.T) error { + // Setup a temporary directory to stage content and execute the test within, + // ensure the test's original working directory is restored after. + cwd, err := os.Getwd() + if err != nil { + return err + } + + tempDir := t.TempDir() + err = os.Chdir(tempDir) + if err != nil { + return err + } + + t.Cleanup(func() { + _ = os.Chdir(cwd) + }) + + // Create content for local file URL testing + path := filepath.Join(tempDir, "poc.command") + err = os.WriteFile(path, []byte("#!/bin/bash\necho hello"), 0755) + if err != nil { + return err + } + + return nil + }, + wantErr: true, + }, + { + name: "Implicit relative Unix/Linux directory URL errors", + url: "Fake.app", + setup: func(t *testing.T) error { + // Setup a temporary directory to stage content and execute the test within, + // ensure the test's original working directory is restored after. + cwd, err := os.Getwd() + if err != nil { + return err + } + + tempDir := t.TempDir() + err = os.Chdir(tempDir) + if err != nil { + return err + } + + t.Cleanup(func() { + _ = os.Chdir(cwd) + }) + + // Create content for local directory URL testing + path := filepath.Join(tempDir, "Fake.app") + err = os.Mkdir(path, 0755) + if err != nil { + return err + } + + path = filepath.Join(path, "poc.command") + err = os.WriteFile(path, []byte("#!/bin/bash\necho hello"), 0755) + if err != nil { + return err + } + + return nil + }, + wantErr: true, + }, + }...) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + err := tt.setup(t) + require.NoError(t, err) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + b := Browser{launcher: tt.launcher, stdout: stdout, stderr: stderr} + err := b.browse(tt.url, []string{"GH_WANT_HELPER_PROCESS=1"}) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, stdout.String()) + assert.Equal(t, "", stderr.String()) + } + }) + } } func TestResolveLauncher(t *testing.T) {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-g9f5-x53j-h563ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-48938ghsaADVISORY
- github.com/cli/go-gh/blob/61bf393cf4aeea6d00a6251390f5f67f5b67e727/pkg/browser/browser.gomitrex_refsource_MISC
- github.com/cli/go-gh/commit/a08820a13f257d6c5b4cb86d37db559ec6d14577ghsax_refsource_MISCWEB
- github.com/cli/go-gh/security/advisories/GHSA-g9f5-x53j-h563ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.