116 lines
4.8 KiB
Go
116 lines
4.8 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRuntimeExpiredNavigationBridge(t *testing.T) {
|
|
a, req := authFixture(t)
|
|
login := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil)
|
|
jar, _ := cookiejar.New(nil)
|
|
base, _ := url.Parse(a.origin)
|
|
cookies := login.Result().Cookies()
|
|
sid := cookieValue(cookies, "dsh_session")
|
|
csrf := cookieValue(cookies, "dsh_csrf")
|
|
for _, c := range cookies {
|
|
if c.Name == "dsh_access" {
|
|
c.Value = a.accessJWT(sid, "alice", time.Now().Add(-time.Minute))
|
|
}
|
|
}
|
|
jar.SetCookies(base, cookies)
|
|
call := func(method, path, mode, dest, origin, upgrade string) *httptest.ResponseRecorder {
|
|
u, _ := url.Parse(a.origin + path)
|
|
r := httptest.NewRequest(method, u.String(), nil)
|
|
r.Header.Set("Sec-Fetch-Mode", mode)
|
|
r.Header.Set("Sec-Fetch-Dest", dest)
|
|
r.Header.Set("Origin", origin)
|
|
r.Header.Set("Upgrade", upgrade)
|
|
for _, c := range jar.Cookies(u) {
|
|
r.AddCookie(c)
|
|
}
|
|
if _, err := r.Cookie("dsh_refresh"); err == nil {
|
|
t.Fatal("refresh leaked to runtime path")
|
|
}
|
|
w := httptest.NewRecorder()
|
|
a.Handler().ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
w := call("GET", "/dsh/demo?x=1&token=secret", "navigate", "document", "", "")
|
|
if w.Code != 303 || w.Header().Get("Cache-Control") != "no-store" || len(w.Result().Cookies()) != 0 {
|
|
t.Fatal(w.Code, w.Header())
|
|
}
|
|
location, _ := url.Parse(w.Header().Get("Location"))
|
|
values, _ := url.ParseQuery(location.Fragment[len("runtime-renew?"):])
|
|
if location.Path != "/" || values.Get("return") != "/dsh/demo?x=1" {
|
|
t.Fatal(location)
|
|
}
|
|
for _, tc := range []struct{ method, mode, dest, origin, upgrade string }{
|
|
{"GET", "cors", "empty", a.origin, ""},
|
|
{"GET", "navigate", "iframe", "https://evil.test", ""},
|
|
{"GET", "", "", a.origin, "websocket"},
|
|
{"GET", "navigate", "document", a.origin, "websocket"},
|
|
{"POST", "navigate", "document", a.origin, ""},
|
|
{"GET", "navigate", "document", "https://evil.test", ""},
|
|
} {
|
|
if w := call(tc.method, "/dsh/demo/", tc.mode, tc.dest, tc.origin, tc.upgrade); w.Code != 401 || w.Header().Get("Location") != "" {
|
|
t.Fatal(tc, w.Code)
|
|
}
|
|
}
|
|
refreshURL, _ := url.Parse(a.origin + "/api/refresh")
|
|
if w := call("GET", "/dsh/demo/", "navigate", "iframe", "", ""); w.Code != 303 {
|
|
t.Fatal("expired iframe did not reach renewal bridge", w.Code)
|
|
}
|
|
jar.SetCookies(base, []*http.Cookie{{Name: "dsh_access", Path: "/", MaxAge: -1}})
|
|
if w := call("GET", "/dsh/demo/", "navigate", "document", "", ""); w.Code != 303 {
|
|
t.Fatal("browser-expired cookie did not reach bridge", w.Code)
|
|
}
|
|
if w := call("GET", "/dsh/demo/ws", "websocket", "empty", a.origin, "websocket"); w.Code != 401 {
|
|
t.Fatal("bare sid accepted", w.Code)
|
|
}
|
|
expired := jar.Cookies(refreshURL)
|
|
if w := req("POST", "/api/refresh", "", "", expired); w.Code != 403 {
|
|
t.Fatal("CSRF bypass", w.Code)
|
|
}
|
|
fresh := req("POST", "/api/refresh", "", csrf, expired)
|
|
if fresh.Code != 200 {
|
|
t.Fatal(fresh.Code, fresh.Body.String())
|
|
}
|
|
jar.SetCookies(base, fresh.Result().Cookies())
|
|
if w := call("GET", "/dsh/demo/", "navigate", "document", "", ""); w.Code != 404 {
|
|
t.Fatal("renewed navigation", w.Code)
|
|
}
|
|
if w := call("GET", "/dsh/demo/ws", "websocket", "empty", a.origin, "websocket"); w.Code != 404 {
|
|
t.Fatal("renewed WS authentication", w.Code)
|
|
}
|
|
a.s.DeleteSession(sid)
|
|
if w := call("GET", "/dsh/demo/ws", "websocket", "empty", a.origin, "websocket"); w.Code != 401 {
|
|
t.Fatal("revoked WS accepted", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeSessionBootstrap(t *testing.T) {
|
|
target, _ := url.Parse("http://runtime.test")
|
|
proxy := runtimeReverseProxy(target, "/dsh/demo", "panel.test", "runtime=secret")
|
|
response := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/html"}, "Set-Cookie": {"secret=runtime"}, "Etag": {"old"}, "Content-Length": {"1"}, "Cache-Control": {"public"}}, Body: io.NopCloser(strings.NewReader("<html><head></head><body>runtime</body></html>"))}
|
|
if err := proxy.ModifyResponse(response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, _ := io.ReadAll(response.Body)
|
|
if !strings.Contains(string(data), `<head><script src="/api/runtime-session.js"></script>`) || response.ContentLength != int64(len(data)) || response.Header.Get("Set-Cookie") != "" || response.Header.Get("ETag") != "" || response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("Content-Length") != fmt.Sprint(len(data)) {
|
|
t.Fatal(string(data), response.Header)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
runtimeSessionScript(w, httptest.NewRequest("GET", "/api/runtime-session.js", nil))
|
|
if w.Code != 200 || !strings.Contains(w.Body.String(), "240000") || !strings.Contains(w.Body.String(), `"X-CSRF-Token":csrf.slice(9)`) || strings.Contains(w.Body.String(), "dsh_refresh=") {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
}
|