ipn/ipnlocal: validate domain of PopBrowserURL on default control URL (#11394)
If the client uses the default Tailscale control URL, validate that all PopBrowserURLs are under tailscale.com or *.tailscale.com. This reduces the risk of a compromised control plane opening phishing pages for example. The client trusts control for many other things, but this is one easy way to reduce that trust a bit. Fixes #11393 Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
This commit is contained in:
parent
48eef9e6eb
commit
decd9893e4
|
@ -2503,11 +2503,21 @@ func (b *LocalBackend) validPopBrowserURL(urlStr string) bool {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
serverURL := b.Prefs().ControlURLOrDefault()
|
||||||
|
if ipn.IsLoginServerSynonym(serverURL) {
|
||||||
|
// When connected to the official Tailscale control plane, only allow
|
||||||
|
// URLs from tailscale.com or its subdomains.
|
||||||
|
if h := u.Hostname(); h != "tailscale.com" && !strings.HasSuffix(u.Hostname(), ".tailscale.com") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// When using a different ControlURL, we cannot be sure what legitimate
|
||||||
|
// PopBrowserURLs they will send. Allow any domain there to avoid
|
||||||
|
// breaking existing user setups.
|
||||||
|
}
|
||||||
switch u.Scheme {
|
switch u.Scheme {
|
||||||
case "https":
|
case "https":
|
||||||
return true
|
return true
|
||||||
case "http":
|
case "http":
|
||||||
serverURL := b.Prefs().ControlURLOrDefault()
|
|
||||||
// If the control server is using plain HTTP (likely a dev server),
|
// If the control server is using plain HTTP (likely a dev server),
|
||||||
// then permit http://.
|
// then permit http://.
|
||||||
return strings.HasPrefix(serverURL, "http://")
|
return strings.HasPrefix(serverURL, "http://")
|
||||||
|
|
|
@ -2469,3 +2469,41 @@ func TestTailFSManageShares(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidPopBrowserURL(t *testing.T) {
|
||||||
|
b := newTestBackend(t)
|
||||||
|
tests := []struct {
|
||||||
|
desc string
|
||||||
|
controlURL string
|
||||||
|
popBrowserURL string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"saas_login", "https://login.tailscale.com", "https://login.tailscale.com/a/foo", true},
|
||||||
|
{"saas_controlplane", "https://controlplane.tailscale.com", "https://controlplane.tailscale.com/a/foo", true},
|
||||||
|
{"saas_root", "https://login.tailscale.com", "https://tailscale.com/", true},
|
||||||
|
{"saas_bad_hostname", "https://login.tailscale.com", "https://example.com/a/foo", false},
|
||||||
|
{"localhost", "http://localhost", "http://localhost/a/foo", true},
|
||||||
|
{"custom_control_url_https", "https://example.com", "https://example.com/a/foo", true},
|
||||||
|
{"custom_control_url_https_diff_domain", "https://example.com", "https://other.com/a/foo", true},
|
||||||
|
{"custom_control_url_http", "http://example.com", "http://example.com/a/foo", true},
|
||||||
|
{"custom_control_url_http_diff_domain", "http://example.com", "http://other.com/a/foo", true},
|
||||||
|
{"bad_scheme", "https://example.com", "http://example.com/a/foo", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.desc, func(t *testing.T) {
|
||||||
|
if _, err := b.EditPrefs(&ipn.MaskedPrefs{
|
||||||
|
ControlURLSet: true,
|
||||||
|
Prefs: ipn.Prefs{
|
||||||
|
ControlURL: tt.controlURL,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := b.validPopBrowserURL(tt.popBrowserURL)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -400,7 +400,14 @@ func TestStateMachine(t *testing.T) {
|
||||||
// Attempted non-interactive login with no key; indicate that
|
// Attempted non-interactive login with no key; indicate that
|
||||||
// the user needs to visit a login URL.
|
// the user needs to visit a login URL.
|
||||||
t.Logf("\n\nLogin (url response)")
|
t.Logf("\n\nLogin (url response)")
|
||||||
notifies.expect(1)
|
|
||||||
|
notifies.expect(2)
|
||||||
|
b.EditPrefs(&ipn.MaskedPrefs{
|
||||||
|
ControlURLSet: true,
|
||||||
|
Prefs: ipn.Prefs{
|
||||||
|
ControlURL: "https://localhost:1/",
|
||||||
|
},
|
||||||
|
})
|
||||||
url1 := "https://localhost:1/1"
|
url1 := "https://localhost:1/1"
|
||||||
cc.send(nil, url1, false, nil)
|
cc.send(nil, url1, false, nil)
|
||||||
{
|
{
|
||||||
|
@ -409,11 +416,11 @@ func TestStateMachine(t *testing.T) {
|
||||||
// ...but backend eats that notification, because the user
|
// ...but backend eats that notification, because the user
|
||||||
// didn't explicitly request interactive login yet, and
|
// didn't explicitly request interactive login yet, and
|
||||||
// we're already in NeedsLogin state.
|
// we're already in NeedsLogin state.
|
||||||
nn := notifies.drain(1)
|
nn := notifies.drain(2)
|
||||||
|
|
||||||
c.Assert(nn[0].Prefs, qt.IsNotNil)
|
c.Assert(nn[1].Prefs, qt.IsNotNil)
|
||||||
c.Assert(nn[0].Prefs.LoggedOut(), qt.IsTrue)
|
c.Assert(nn[1].Prefs.LoggedOut(), qt.IsTrue)
|
||||||
c.Assert(nn[0].Prefs.WantRunning(), qt.IsFalse)
|
c.Assert(nn[1].Prefs.WantRunning(), qt.IsFalse)
|
||||||
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
|
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -775,12 +782,18 @@ func TestStateMachine(t *testing.T) {
|
||||||
// We want to try logging in as a different user, while Stopped.
|
// We want to try logging in as a different user, while Stopped.
|
||||||
// First, start the login process (without logging out first).
|
// First, start the login process (without logging out first).
|
||||||
t.Logf("\n\nLoginDifferent")
|
t.Logf("\n\nLoginDifferent")
|
||||||
notifies.expect(1)
|
notifies.expect(2)
|
||||||
|
b.EditPrefs(&ipn.MaskedPrefs{
|
||||||
|
ControlURLSet: true,
|
||||||
|
Prefs: ipn.Prefs{
|
||||||
|
ControlURL: "https://localhost:1/",
|
||||||
|
},
|
||||||
|
})
|
||||||
b.StartLoginInteractive()
|
b.StartLoginInteractive()
|
||||||
url3 := "https://localhost:1/3"
|
url3 := "https://localhost:1/3"
|
||||||
cc.send(nil, url3, false, nil)
|
cc.send(nil, url3, false, nil)
|
||||||
{
|
{
|
||||||
nn := notifies.drain(1)
|
nn := notifies.drain(2)
|
||||||
// It might seem like WantRunning should switch to true here,
|
// It might seem like WantRunning should switch to true here,
|
||||||
// but that would be risky since we already have a valid
|
// but that would be risky since we already have a valid
|
||||||
// user account. It might try to reconnect to the old account
|
// user account. It might try to reconnect to the old account
|
||||||
|
@ -789,8 +802,8 @@ func TestStateMachine(t *testing.T) {
|
||||||
// Because the login hasn't yet completed, the old login
|
// Because the login hasn't yet completed, the old login
|
||||||
// is still valid, so it's correct that we stay paused.
|
// is still valid, so it's correct that we stay paused.
|
||||||
cc.assertCalls("Login")
|
cc.assertCalls("Login")
|
||||||
c.Assert(nn[0].BrowseToURL, qt.IsNotNil)
|
c.Assert(nn[1].BrowseToURL, qt.IsNotNil)
|
||||||
c.Assert(*nn[0].BrowseToURL, qt.Equals, url3)
|
c.Assert(*nn[1].BrowseToURL, qt.Equals, url3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now, let's complete the interactive login, using a different
|
// Now, let's complete the interactive login, using a different
|
||||||
|
|
Loading…
Reference in New Issue