Skip to content

Commit 22191a7

Browse files
authored
fix/security: prevent command injection on windows (#1272)
1 parent 4c3f356 commit 22191a7

File tree

2 files changed

+93
-6
lines changed

2 files changed

+93
-6
lines changed

cmd/src/login_oauth.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"context"
55
"fmt"
66
"io"
7+
"net/url"
78
"os/exec"
89
"runtime"
910
"time"
1011

12+
"github.com/sourcegraph/sourcegraph/lib/errors"
1113
"github.com/sourcegraph/src-cli/internal/api"
1214
"github.com/sourcegraph/src-cli/internal/cmderrors"
1315
"github.com/sourcegraph/src-cli/internal/oauth"
@@ -103,20 +105,39 @@ func runOAuthDeviceFlow(ctx context.Context, endpoint string, out io.Writer, cli
103105
return token, nil
104106
}
105107

106-
func openInBrowser(url string) error {
107-
if url == "" {
108+
// validateBrowserURL checks that rawURL is a valid HTTP(S) URL to prevent
109+
// command injection via malicious URLs returned by an OAuth server.
110+
func validateBrowserURL(rawURL string) error {
111+
u, err := url.Parse(rawURL)
112+
if err != nil {
113+
return errors.Wrapf(err, "invalid URL: %s", rawURL)
114+
}
115+
if u.Scheme != "http" && u.Scheme != "https" {
116+
return errors.Newf("unsupported URL scheme %q: only http and https are allowed", u.Scheme)
117+
}
118+
if u.Host == "" {
119+
return errors.New("URL has no host")
120+
}
121+
return nil
122+
}
123+
124+
func openInBrowser(rawURL string) error {
125+
if rawURL == "" {
108126
return nil
109127
}
110128

129+
if err := validateBrowserURL(rawURL); err != nil {
130+
return err
131+
}
132+
111133
var cmd *exec.Cmd
112134
switch runtime.GOOS {
113135
case "darwin":
114-
cmd = exec.Command("open", url)
136+
cmd = exec.Command("open", rawURL)
115137
case "windows":
116-
// "start" is a cmd.exe built-in; the empty string is the window title.
117-
cmd = exec.Command("cmd", "/c", "start", "", url)
138+
cmd = exec.Command("rundll32", "url.dll,OpenURL", rawURL)
118139
default:
119-
cmd = exec.Command("xdg-open", url)
140+
cmd = exec.Command("xdg-open", rawURL)
120141
}
121142
return cmd.Run()
122143
}

cmd/src/login_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,72 @@ func TestSelectLoginFlow(t *testing.T) {
204204
})
205205
}
206206

207+
func TestValidateBrowserURL(t *testing.T) {
208+
tests := []struct {
209+
name string
210+
url string
211+
wantErr bool
212+
}{
213+
{name: "valid https", url: "https://example.com/device?code=ABC", wantErr: false},
214+
{name: "valid http", url: "http://localhost:3080/auth", wantErr: false},
215+
{name: "command injection ampersand", url: "https://example.com & calc.exe", wantErr: true},
216+
{name: "command injection pipe", url: "https://x | powershell -enc ZABp", wantErr: true},
217+
{name: "file scheme", url: "file:///etc/passwd", wantErr: true},
218+
{name: "javascript scheme", url: "javascript:alert(1)", wantErr: true},
219+
{name: "empty scheme", url: "://no-scheme", wantErr: true},
220+
{name: "no host", url: "https://", wantErr: true},
221+
{name: "relative path", url: "/just/a/path", wantErr: true},
222+
}
223+
for _, tt := range tests {
224+
t.Run(tt.name, func(t *testing.T) {
225+
err := validateBrowserURL(tt.url)
226+
if (err != nil) != tt.wantErr {
227+
t.Errorf("validateBrowserURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
228+
}
229+
})
230+
}
231+
}
232+
233+
// TestValidateBrowserURL_WindowsRundll32Escape tests that validateBrowserURL blocks
234+
// payloads that could abuse the Windows "rundll32 url.dll,OpenURL" browser opener
235+
// (LOLBAS T1218.011). If any of these cases pass validation, an attacker-controlled
236+
// URL could execute arbitrary files via rundll32.
237+
// Reference: https://lolbas-project.github.io/lolbas/Libraries/Url/
238+
func TestValidateBrowserURL_WindowsRundll32Escape(t *testing.T) {
239+
tests := []struct {
240+
name string
241+
url string
242+
}{
243+
// url.dll OpenURL can launch .hta payloads via mshta.exe
244+
{name: "hta via file protocol", url: "file:///C:/Temp/payload.hta"},
245+
// url.dll OpenURL can launch executables from .url shortcut files
246+
{name: "url shortcut file", url: "file:///C:/Temp/launcher.url"},
247+
// url.dll OpenURL / FileProtocolHandler can run executables directly
248+
{name: "exe via file protocol", url: "file:///C:/Windows/System32/calc.exe"},
249+
// Obfuscated file protocol handler variant
250+
{name: "obfuscated file handler", url: "file:///C:/Temp/payload.exe"},
251+
// UNC path via file scheme to remote payload
252+
{name: "unc path file scheme", url: "file://attacker.com/share/payload.exe"},
253+
// data: URI could be passed through to a handler
254+
{name: "data uri", url: "data:text/html,<script>alert(1)</script>"},
255+
// vbscript scheme
256+
{name: "vbscript scheme", url: "vbscript:Execute(\"MsgBox(1)\")"},
257+
// about scheme
258+
{name: "about scheme", url: "about:blank"},
259+
// ms-msdt protocol handler (Follina-style)
260+
{name: "ms-msdt handler", url: "ms-msdt:/id PCWDiagnostic /skip force /param"},
261+
// search-ms protocol handler
262+
{name: "search-ms handler", url: "search-ms:query=calc&crumb=location:\\\\attacker.com\\share"},
263+
}
264+
for _, tt := range tests {
265+
t.Run(tt.name, func(t *testing.T) {
266+
if err := validateBrowserURL(tt.url); err == nil {
267+
t.Errorf("validateBrowserURL(%q) = nil; want error (payload must be blocked to prevent rundll32 url.dll,OpenURL abuse)", tt.url)
268+
}
269+
})
270+
}
271+
}
272+
207273
func restoreStoredOAuthLoader(t *testing.T, loader func(context.Context, string) (*oauth.Token, error)) {
208274
t.Helper()
209275

0 commit comments

Comments
 (0)