mirror of https://github.com/vouch/vouch-proxy
262 lines
12 KiB
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)
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
}
|
|
}
|