- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
420 lines
11 KiB
Go
420 lines
11 KiB
Go
package tide
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestProxyNamedSessionsOrderedAndIsolated(t *testing.T) {
|
|
var seenHost []string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seenHost = append(seenHost, r.Host)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"path":"` + r.URL.Path + `"}`))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
fixtures := t.TempDir()
|
|
proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML())
|
|
srv := httptest.NewServer(proxy.Handler())
|
|
t.Cleanup(srv.Close)
|
|
|
|
doProxy(t, srv.URL, "alpha", "/one", "")
|
|
doProxy(t, srv.URL, "alpha", "/two", "")
|
|
doProxy(t, srv.URL, "beta", "/other", "")
|
|
|
|
if err := proxy.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
alpha, err := LoadFlow(filepath.Join(fixtures, "nuxt", "alpha.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(alpha.Steps) != 2 {
|
|
t.Fatalf("alpha steps: %d", len(alpha.Steps))
|
|
}
|
|
if alpha.Steps[0].Request.Path != "/one" || alpha.Steps[1].Request.Path != "/two" {
|
|
t.Fatalf("alpha order: %+v", alpha.Steps)
|
|
}
|
|
beta, err := LoadFlow(filepath.Join(fixtures, "nuxt", "beta.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(beta.Steps) != 1 || beta.Steps[0].Request.Path != "/other" {
|
|
t.Fatalf("beta interleaved: %+v", beta.Steps)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodGet, srv.URL+"/evil", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Host = "evil.example"
|
|
req.Header.Set(SessionHeader, "gamma")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if len(seenHost) < 4 {
|
|
t.Fatalf("upstream hits: %d", len(seenHost))
|
|
}
|
|
wantHost := strings.TrimPrefix(upstream.URL, "http://")
|
|
if seenHost[len(seenHost)-1] != wantHost {
|
|
t.Fatalf("client Host leaked: last=%s want=%s all=%v", seenHost[len(seenHost)-1], wantHost, seenHost)
|
|
}
|
|
}
|
|
|
|
func TestProxyForwardsCookieRedirectAndBody(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/go" {
|
|
w.Header().Set("Set-Cookie", "sid=abc; Path=/")
|
|
w.Header().Set("Location", "/landed")
|
|
w.WriteHeader(http.StatusFound)
|
|
_, _ = w.Write([]byte("redirect-body"))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("cookie=" + r.Header.Get("Cookie")))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
fixtures := t.TempDir()
|
|
proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML())
|
|
srv := httptest.NewServer(proxy.Handler())
|
|
t.Cleanup(srv.Close)
|
|
|
|
client := &http.Client{
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
req, err := http.NewRequest(http.MethodGet, srv.URL+"/go", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set(SessionHeader, "redir")
|
|
req.Header.Set("Cookie", "keep=1")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusFound {
|
|
t.Fatalf("status %d", resp.StatusCode)
|
|
}
|
|
if loc := resp.Header.Get("Location"); loc != "/landed" {
|
|
t.Fatalf("location %q", loc)
|
|
}
|
|
if !strings.Contains(resp.Header.Get("Set-Cookie"), "sid=abc") {
|
|
t.Fatalf("set-cookie %q", resp.Header.Get("Set-Cookie"))
|
|
}
|
|
if string(body) != "redirect-body" {
|
|
t.Fatalf("body %q", body)
|
|
}
|
|
|
|
req2, err := http.NewRequest(http.MethodGet, srv.URL+"/echo", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req2.Header.Set(SessionHeader, "redir")
|
|
req2.Header.Set("Cookie", "keep=1")
|
|
resp2, err := client.Do(req2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := io.ReadAll(resp2.Body)
|
|
resp2.Body.Close()
|
|
if string(got) != "cookie=keep=1" {
|
|
t.Fatalf("cookie not forwarded: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestProxyRejectsNonLoopbackOverflowAndCredentials(t *testing.T) {
|
|
if _, err := NewProxy(ProxyConfig{
|
|
Listen: "0.0.0.0:8422",
|
|
Upstream: "http://127.0.0.1:8423",
|
|
Fixtures: t.TempDir(),
|
|
Rules: mustParseRules(t, testRulesYAML()),
|
|
}); err == nil || !strings.Contains(err.Error(), "loopback") {
|
|
t.Fatalf("non-loopback listen: %v", err)
|
|
}
|
|
if _, err := NewProxy(ProxyConfig{
|
|
Listen: "127.0.0.1:8422",
|
|
Upstream: "http://example.com",
|
|
Fixtures: t.TempDir(),
|
|
Rules: mustParseRules(t, testRulesYAML()),
|
|
}); err == nil || !strings.Contains(err.Error(), "loopback") {
|
|
t.Fatalf("non-loopback upstream: %v", err)
|
|
}
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("hello-world"))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
fixtures := t.TempDir()
|
|
cfg := ProxyConfig{
|
|
Listen: "127.0.0.1:0",
|
|
Upstream: upstream.URL,
|
|
Fixtures: fixtures,
|
|
Rules: mustParseRules(t, testRulesYAML()),
|
|
MaxBody: 4,
|
|
}
|
|
proxy, err := NewProxy(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := httptest.NewServer(proxy.Handler())
|
|
t.Cleanup(srv.Close)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set(SessionHeader, "overflow")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge && resp.StatusCode != http.StatusBadGateway && resp.StatusCode != http.StatusInternalServerError {
|
|
if resp.StatusCode == http.StatusOK {
|
|
t.Fatal("overflow recorded as success")
|
|
}
|
|
}
|
|
overflowPath := filepath.Join(fixtures, "nuxt", "overflow.yaml")
|
|
if _, statErr := os.Stat(overflowPath); !os.IsNotExist(statErr) {
|
|
t.Fatalf("partial fixture committed: %v", statErr)
|
|
}
|
|
|
|
bigUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.abcabcabcabcabcabcab"}`))
|
|
}))
|
|
t.Cleanup(bigUp.Close)
|
|
secretFix := t.TempDir()
|
|
secretProxy, err := NewProxy(ProxyConfig{
|
|
Listen: "127.0.0.1:0",
|
|
Upstream: bigUp.URL,
|
|
Fixtures: secretFix,
|
|
Rules: mustParseRules(t, testRulesYAML()),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secretSrv := httptest.NewServer(secretProxy.Handler())
|
|
t.Cleanup(secretSrv.Close)
|
|
sreq, err := http.NewRequest(http.MethodGet, secretSrv.URL+"/sample", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sreq.Header.Set(SessionHeader, "secret")
|
|
sresp, err := http.DefaultClient.Do(sreq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sresp.Body.Close()
|
|
if _, statErr := os.Stat(filepath.Join(secretFix, "nuxt", "secret.yaml")); !os.IsNotExist(statErr) {
|
|
t.Fatalf("credential fixture was committed: %v", statErr)
|
|
}
|
|
|
|
reqBad, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reqBad.Header.Set(SessionHeader, "alice/../bob")
|
|
bresp, err := http.DefaultClient.Do(reqBad)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bresp.Body.Close()
|
|
if bresp.StatusCode == http.StatusOK {
|
|
t.Fatal("path-separator session must fail")
|
|
}
|
|
|
|
inside := filepath.Join(fixtures, "vars.yaml")
|
|
if _, err := NewProxy(ProxyConfig{
|
|
Listen: "127.0.0.1:0",
|
|
Upstream: upstream.URL,
|
|
Fixtures: fixtures,
|
|
VarsPath: inside,
|
|
Rules: mustParseRules(t, testRulesYAML()),
|
|
}); err == nil || !strings.Contains(err.Error(), "outside") {
|
|
t.Fatalf("vars inside fixtures: %v", err)
|
|
}
|
|
if err := os.WriteFile(inside, []byte("{}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linkDir := t.TempDir()
|
|
link := filepath.Join(linkDir, "vars-link.yaml")
|
|
if err := os.Symlink(inside, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := NewProxy(ProxyConfig{
|
|
Listen: "127.0.0.1:0",
|
|
Upstream: upstream.URL,
|
|
Fixtures: fixtures,
|
|
VarsPath: link,
|
|
Rules: mustParseRules(t, testRulesYAML()),
|
|
}); err == nil || !strings.Contains(err.Error(), "outside") {
|
|
t.Fatalf("symlink vars into fixtures: %v", err)
|
|
}
|
|
outside := filepath.Join(t.TempDir(), "vars.yaml")
|
|
p, err := NewProxy(ProxyConfig{
|
|
Listen: "127.0.0.1:0",
|
|
Upstream: upstream.URL,
|
|
Fixtures: fixtures,
|
|
VarsPath: outside,
|
|
Rules: mustParseRules(t, testRulesYAML()),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = p
|
|
st, err := os.Stat(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.Mode().Perm() != 0o600 {
|
|
t.Fatalf("vars mode %o", st.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestProxyDuplicateSessionName(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
fixtures := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(fixtures, "nuxt"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(fixtures, "nuxt", "taken.yaml"), []byte("version: 1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML())
|
|
srv := httptest.NewServer(proxy.Handler())
|
|
t.Cleanup(srv.Close)
|
|
req, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set(SessionHeader, "taken")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
t.Fatal("duplicate session must fail")
|
|
}
|
|
}
|
|
|
|
func TestProxyFailedSessionLeavesNoFixture(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/ok" {
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"token":"` + testJWT + `"}`))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
fixtures := t.TempDir()
|
|
proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML())
|
|
srv := httptest.NewServer(proxy.Handler())
|
|
t.Cleanup(srv.Close)
|
|
|
|
doProxy(t, srv.URL, "partial", "/ok", "")
|
|
req, err := http.NewRequest(http.MethodGet, srv.URL+"/leak", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set(SessionHeader, "partial")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if err := proxy.Flush(); err == nil {
|
|
t.Fatal("failed session flush must surface the capture error")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(fixtures, "nuxt", "partial.yaml")); !os.IsNotExist(err) {
|
|
t.Fatalf("partial session fixture committed: %v", err)
|
|
}
|
|
}
|
|
|
|
func newTestProxy(t *testing.T, upstream, fixtures, rulesYAML string) *Proxy {
|
|
t.Helper()
|
|
p, err := NewProxy(ProxyConfig{
|
|
Listen: "127.0.0.1:0",
|
|
Upstream: upstream,
|
|
Fixtures: fixtures,
|
|
Rules: mustParseRules(t, rulesYAML),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func mustParseRules(t *testing.T, raw string) Rules {
|
|
t.Helper()
|
|
rules, err := ParseRules([]byte(raw))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rules
|
|
}
|
|
|
|
func testRulesYAML() string {
|
|
return "" +
|
|
"client: nuxt\n" +
|
|
"keep_request_headers:\n" +
|
|
" - Cookie\n" +
|
|
" - Content-Type\n" +
|
|
"keep_response_headers:\n" +
|
|
" - Content-Type\n" +
|
|
" - Location\n" +
|
|
" - Set-Cookie\n" +
|
|
"routes:\n" +
|
|
" - method: GET\n" +
|
|
" path: /sample\n" +
|
|
" - method: GET\n" +
|
|
" path: /*\n"
|
|
}
|
|
|
|
func doProxy(t *testing.T, base, session, path, body string) {
|
|
t.Helper()
|
|
var rdr io.Reader
|
|
if body != "" {
|
|
rdr = strings.NewReader(body)
|
|
}
|
|
req, err := http.NewRequest(http.MethodGet, base+path, rdr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set(SessionHeader, session)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("%s %s: %d %s", session, path, resp.StatusCode, b)
|
|
}
|
|
}
|