vouch-proxy/handlers/login_test.go

262 lines
12 KiB
Go

/*
Copyright 2020 The Vouch Proxy Authors.
Use of this source code is governed by The MIT License (MIT) that
can be found in the LICENSE file. Software distributed under The
MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
OR CONDITIONS OF ANY KIND, either express or implied.
*/
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/vouch/vouch-proxy/pkg/cfg"
)
func Test_normalizeLoginURL(t *testing.T) {
setUp("/config/testing/handler_login_url.yml")
tests := []struct {
name string
url string
want string
wantStray []string
wantErr bool
}{
// This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway
{"extra params", "http://host/login?url=http://host/path?p2=2", "http://host/path?p2=2", []string{}, false},
{"extra params (blank)", "http://host/login?url=http://host/path?p2=", "http://host/path?p2=", []string{}, false},
// This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway
// Even though the p1 param is not a login param, we do not interpret is as part of the url param because it precedes it
{"prior params", "http://host/login?p1=1&url=http://host/path", "http://host/path", []string{"p1"}, false},
// This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway
// We assume vouch-* is a login param and do not fold it into url
{"vouch-* params after", "http://host/login?url=http://host/path&vouch-xxx=2", "http://host/path", []string{}, false},
// This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway
// We assume vouch-* is a login param and do not fold it into url
{"vouch-* params before", "http://host/login?vouch-xxx=1&url=http://host/path", "http://host/path", []string{}, false},
// This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway
// We assume x-vouch-* is a login param and do not fold it into url
{"x-vouch-* params after", "http://host/login?url=http://host/path&vouch-xxx=2", "http://host/path", []string{}, false},
// This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway
// We assume x-vouch-* is a login param and do not fold it into url
{"x-vouch-* params before", "http://host/login?x-vouch-xxx=1&url=http://host/path", "http://host/path", []string{}, false},
// This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway
// Even though p1 is not a login param, we do not interpret is as part of url because it follows a login param (vouch-*)
{"params after vouch-* params", "http://host/login?url=http://host/path&vouch-xxx=2&p3=3", "http://host/path", []string{"p3"}, false},
// This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway
// Even though p1 is not a login param, we do not interpret is as part of url because it follows a login param (x-vouch-*)
{"params after x-vouch-* params", "http://host/login?url=http://host/path&x-vouch-xxx=2&p3=3", "http://host/path", []string{"p3"}, false},
// This is not an RFC-compliant URL; it combines all the aspects above
{"all params", "http://host/login?p1=1&url=http://host/path?p2=2&p3=3&x-vouch-xxx=4&vouch=5&error=6&p7=7", "http://host/path?p2=2&p3=3", []string{"p1", "p7"}, false},
// This is an RFC-compliant URL
{"all params (encoded)", "http://host/login?p1=1&url=http%3a%2f%2fhost/path%3fp2=2%26p3=3&x-vouch-xxx=4&vouch=5&error=6&p7=7", "http://host/path?p2=2&p3=3", []string{"p1", "p7"}, false},
// This is not an RFC-compliant URL; it combines all the aspects above, and it uses semicolons as parameter separators
// Note that when we fold a stray param into the url param, we always do so with &s
{"all params (semicolons)", "http://host/login?p1=1;url=http://host/path?p2=2;p3=3;x-vouch-xxx=4;p5=5", "http://host/path?p2=2&p3=3", []string{"p1", "p5"}, false},
// This is an RFC-compliant URL that uses semicolons as parameter separators
{"all params (encoded, semicolons)", "http://host/login?p1=1;url=http%3a%2f%2fhost/path%3fp2=2%3bp3=3;x-vouch-xxx=4;p5=5", "http://host/path?p2=2;p3=3", []string{"p1", "p5"}, false},
// Real world tests
// since v0.4.0 the vouch README has specified an Nginx config including a 302 redirect in the following format...
{"Vouch Proxy README (with error)", "http://host/login?url=http://host/path?p2=2&vouch-failcount=3&X-Vouch-Token=TOKEN&error=anerror", "http://host/path?p2=2", []string{}, false},
{"Vouch Proxy README (blank error)", "http://host/login?url=http://host/path?p2=2&vouch-failcount=&X-Vouch-Token=&error=", "http://host/path?p2=2", []string{}, false},
{"Vouch Proxy README (semicolons, blank error)", "http://host/login?url=http://host/path?p2=2;p3=3&vouch-failcount=&X-Vouch-Token=&error=", "http://host/path?p2=2&p3=3", []string{}, false},
// Nginx Ingress controler for Kubernetes adds the parameter `rd` to these calls
// https://github.com/vouch/vouch-proxy/issues/289
{"rd param appended by Nginx Ingress", "http://host/login?url=http://host/path?p2=2&p3=3&vouch-failcount=&X-Vouch-Token=&error=&rd=http%3a%2f%2fhost/path%3fp2=2%3bp3=3", "http://host/path?p2=2&p3=3", []string{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, _ := url.Parse(tt.url)
got, stray, err := normalizeLoginURLParam(u)
if got.String() != tt.want {
t.Errorf("normalizeLoginURLParam() = %v, want %v", got, tt.want)
}
if !cmp.Equal(stray, tt.wantStray) {
t.Errorf("normalizeLoginURLParam() stray params incorrectly parsed, got %+q, expected %+q", stray, tt.wantStray)
}
if (err != nil) != tt.wantErr {
t.Errorf("normalizeLoginURLParam() err = %v", err)
}
})
}
}
func Test_getValidRequestedURL(t *testing.T) {
setUp("/config/testing/handler_login_url.yml")
r := &http.Request{}
tests := []struct {
name string
url string
want string
wantErr bool
}{
{"no https", "example.com/dest", "", true},
{"redirection chaining", "http://example.com/dest?url=https://", "", true},
{"redirection chaining upper case", "http://example.com/dest?url=HTTPS://someplaceelse.com", "", true},
{"redirection chaining no protocol", "http://example.com/dest?url=//someplaceelse.com", "", true},
{"redirection chaining escaped https://", "http://example.com/dest?url=https%3a%2f%2fsomeplaceelse.com", "", true},
{"data uri", "http://example.com/dest?url=data:text/plain,Example+Text", "", true},
{"javascript uri", "http://example.com/dest?url=javascript:alert(1)", "", true},
{"not in domain but contains domain", "http://example.com.somewherelse.com/", "", true},
{"not in domain", "http://somewherelse.com/", "", true},
{"should warn", "https://example.com/", "https://example.com/", false},
{"should be fine", "http://example.com/", "http://example.com/", false},
{"multiple query param", "http://example.com/?strange=but-true&also-strange=but-false", "http://example.com/?strange=but-true&also-strange=but-false", false},
{"multiple query params, one of them bad", "http://example.com/?strange=but-true&also-strange=but-false&strange-but-bad=https://badandstrange.com", "", true},
{"multiple query params, one of them bad (escaped)", "http://example.com/?strange=but-true&also-strange=but-false&strange-but-bad=https%3a%2f%2fbadandstrange.com", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r.URL, _ = url.Parse("http://vouch.example.com/login?url=" + tt.url)
got, err := getValidRequestedURL(r)
if (err != nil) != tt.wantErr {
t.Errorf("getValidRequestedURL() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("getValidRequestedURL() = %v, want %v", got, tt.want)
}
})
}
}
func TestLoginHandlerDocumentRoot(t *testing.T) {
handler := http.HandlerFunc(LoginHandler)
tests := []struct {
name string
configFile string
wantcode int
}{
{"general test", "/config/testing/handler_login_url_document_root.yml", http.StatusFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setUp(tt.configFile)
req, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/logout?url=http://myapp.example.com/login", nil)
req.Header.Set("Host", "my.example.com")
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != tt.wantcode {
t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode)
}
found := false
for _, c := range rr.Result().Cookies() {
if c.Name == cfg.Cfg.Session.Name {
if strings.HasPrefix(c.Path, cfg.Cfg.DocumentRoot+"/auth") {
found = true
}
}
}
if !found {
t.Errorf("session cookie is not set into path that begins with Cfg.DocumentRoot %s", cfg.Cfg.DocumentRoot)
}
// confirm the OAuthClient has a properly configured
redirectURL, err := url.Parse(rr.Header()["Location"][0])
if err != nil {
t.Fatal(err)
}
redirectParam := redirectURL.Query().Get("redirect_uri")
assert.NotEmpty(t, cfg.OAuthClient.RedirectURL, "cfg.OAuthClient.RedirectURL is empty")
assert.NotEmpty(t, redirectParam, "redirect_uri should not be empty when redirected to google oauth")
})
}
}
func TestLoginHandler(t *testing.T) {
handler := http.HandlerFunc(LoginHandler)
tests := []struct {
name string
configFile string
wantcode int
}{
{"general test", "/config/testing/handler_login_url.yml", http.StatusFound},
{"general test", "/config/testing/handler_login_redirecturls.yml", http.StatusFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setUp(tt.configFile)
req, err := http.NewRequest("GET", "/logout?url=http://myapp.example.com/login", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != tt.wantcode {
t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode)
}
// confirm the OAuthClient has a properly configured
redirectURL, err := url.Parse(rr.Header()["Location"][0])
if err != nil {
t.Fatal(err)
}
redirectParam := redirectURL.Query().Get("redirect_uri")
assert.NotEmpty(t, cfg.OAuthClient.RedirectURL, "cfg.OAuthClient.RedirectURL is empty")
assert.NotEmpty(t, redirectParam, "redirect_uri should not be empty when redirected to google oauth")
})
}
}
func TestLoginErrTooManyRedirects(t *testing.T) {
handler := http.HandlerFunc(LoginHandler)
setUp("/config/testing/handler_login_url.yml")
tests := []struct {
name string
wantcode int
numRequests int
}{
{"try the URL a few times", http.StatusFound, failCountLimit}, // after we make successive number of requests up to the failCountLimit ``
{"then fail ErrTooManyRedirects", http.StatusBadRequest, 1}, // then we generate the error and return `400 Bad Request`
}
var rr *httptest.ResponseRecorder
req, err := http.NewRequest("GET", "/logout?url=http://myapp.example.com/login", nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for i := 0; i < tt.numRequests; i++ {
if err != nil {
t.Fatal(err)
}
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != tt.wantcode {
t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode)
}
for _, c := range rr.Result().Cookies() {
req.AddCookie(c)
}
}
})
}
}