- WriteUpstream masks vars, hashes long base64 JSON strings and refuses unmasked Authorization/X-Api-Key - multipart requests recorded as ordered parts; the fake compares parts and hashed payloads - loopback CONNECT recording proxy with a local ECDSA parity CA, script and forward modes - parity:upstream command, README and parity docs
360 lines
12 KiB
Go
360 lines
12 KiB
Go
package tide
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type upstreamProxyEnv struct {
|
|
cfg UpstreamProxyConfig
|
|
caPEM []byte
|
|
proxy *UpstreamProxy
|
|
server *httptest.Server
|
|
client *http.Client
|
|
}
|
|
|
|
func newUpstreamProxyEnv(t *testing.T, mode, script string) *upstreamProxyEnv {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
cfg := UpstreamProxyConfig{
|
|
Listen: DefaultUpstreamProxyListen,
|
|
CADir: filepath.Join(root, "ca"),
|
|
Out: filepath.Join(root, "fixtures", "routes", "POST_things__ok.upstream.yaml"),
|
|
Mode: mode,
|
|
VarsPath: filepath.Join(root, "private", "vars.yaml"),
|
|
}
|
|
if script != "" {
|
|
cfg.Script = filepath.Join(root, "script.yaml")
|
|
if err := os.WriteFile(cfg.Script, []byte(script), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
store, err := OpenStore(cfg.VarsPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.Set("secret:example-token", "example-token-value")
|
|
if err := store.Save(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
proxy, err := NewUpstreamProxy(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewUpstreamProxy: %v", err)
|
|
}
|
|
srv := httptest.NewServer(proxy)
|
|
t.Cleanup(srv.Close)
|
|
caPEM, err := os.ReadFile(filepath.Join(cfg.CADir, parityCAFile))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caPEM) {
|
|
t.Fatal("parity CA did not parse")
|
|
}
|
|
proxyURL, _ := url.Parse(srv.URL)
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{
|
|
Proxy: http.ProxyURL(proxyURL),
|
|
TLSClientConfig: tlsConfigWithRoots(pool),
|
|
},
|
|
}
|
|
return &upstreamProxyEnv{cfg: cfg, caPEM: caPEM, proxy: proxy, server: srv, client: client}
|
|
}
|
|
|
|
const exampleScript = `responses:
|
|
- method: POST
|
|
host: api.example.test
|
|
path: /v1/things
|
|
response:
|
|
status: 201
|
|
headers:
|
|
Content-Type: application/json
|
|
X-Ratelimit-Remaining: "59"
|
|
body: '{"id":7,"owner":"example-token-value"}'
|
|
`
|
|
|
|
func TestUpstreamProxyScriptMode(t *testing.T) {
|
|
env := newUpstreamProxyEnv(t, "script", exampleScript)
|
|
req, _ := http.NewRequest(http.MethodPost, "https://api.example.test/v1/things?lang=en", strings.NewReader(`{"name":"widget"}`))
|
|
req.Header.Set("Authorization", "Bearer example-token-value")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "example-client/1.0")
|
|
resp, err := env.client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request through proxy: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated || string(body) != `{"id":7,"owner":"example-token-value"}` {
|
|
t.Fatalf("scripted response = %d %s", resp.StatusCode, body)
|
|
}
|
|
if resp.Header.Get("X-Ratelimit-Remaining") != "59" {
|
|
t.Fatalf("scripted header missing: %v", resp.Header)
|
|
}
|
|
|
|
if err := env.proxy.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(env.cfg.Out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bytes.Contains(raw, []byte("example-token-value")) {
|
|
t.Fatalf("sidecar leaks the credential:\n%s", raw)
|
|
}
|
|
st, _ := os.Stat(env.cfg.Out)
|
|
if st.Mode().Perm() != 0o644 {
|
|
t.Fatalf("sidecar mode = %v", st.Mode().Perm())
|
|
}
|
|
s, err := LoadUpstream(env.cfg.Out)
|
|
if err != nil {
|
|
t.Fatalf("LoadUpstream: %v\n%s", err, raw)
|
|
}
|
|
if len(s.Exchanges) != 1 {
|
|
t.Fatalf("exchanges = %d", len(s.Exchanges))
|
|
}
|
|
ex := s.Exchanges[0]
|
|
if got := ex.Request.Headers["Authorization"]; got != "Bearer {{secret:example-token}}" {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
if ex.Request.URL != "https://api.example.test/v1/things?lang=en" || ex.Request.Method != "POST" {
|
|
t.Fatalf("request = %+v", ex.Request)
|
|
}
|
|
if ex.Request.Body != `{"name":"widget"}` || ex.Request.Headers["User-Agent"] != "example-client/1.0" {
|
|
t.Fatalf("request body/headers = %+v", ex.Request)
|
|
}
|
|
if ex.Response.Body != `{"id":7,"owner":"{{secret:example-token}}"}` {
|
|
t.Fatalf("response body = %q", ex.Response.Body)
|
|
}
|
|
|
|
// The recorded sidecar replays offline.
|
|
store, _ := OpenStore(env.cfg.VarsPath)
|
|
fake := NewUpstreamFake(s, store)
|
|
again, _ := http.NewRequest(http.MethodPost, "https://api.example.test/v1/things?lang=en", strings.NewReader(`{"name":"widget"}`))
|
|
again.Header = req.Header.Clone()
|
|
got, err := fake.RoundTrip(again)
|
|
if err != nil {
|
|
t.Fatalf("replay: %v", err)
|
|
}
|
|
replayed, _ := io.ReadAll(got.Body)
|
|
if string(replayed) != `{"id":7,"owner":"example-token-value"}` {
|
|
t.Fatalf("replayed body = %s", replayed)
|
|
}
|
|
if err := fake.Verify(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestUpstreamProxyUnscriptedRequestFailsFlush(t *testing.T) {
|
|
env := newUpstreamProxyEnv(t, "script", exampleScript)
|
|
resp, err := env.client.Get("https://api.example.test/v1/unknown")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != upstreamProxyNoMatch {
|
|
t.Fatalf("status = %d, want %d", resp.StatusCode, upstreamProxyNoMatch)
|
|
}
|
|
if err := env.proxy.Flush(); err == nil || !strings.Contains(err.Error(), "no scripted response") {
|
|
t.Fatalf("Flush = %v, want the unscripted request reported", err)
|
|
}
|
|
if _, err := os.Stat(env.cfg.Out); !os.IsNotExist(err) {
|
|
t.Fatalf("sidecar written despite failures: %v", err)
|
|
}
|
|
// Plain requests are refused: the proxy only tunnels.
|
|
plain, err := http.Get(env.server.URL + "/x")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = plain.Body.Close()
|
|
if plain.StatusCode != http.StatusMethodNotAllowed {
|
|
t.Fatalf("plain request status = %d", plain.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestUpstreamProxyMultipartAndForwardGuard(t *testing.T) {
|
|
script := `responses:
|
|
- method: POST
|
|
host: files.example.test
|
|
path: /upload
|
|
response:
|
|
status: 202
|
|
`
|
|
env := newUpstreamProxyEnv(t, "script", script)
|
|
var buf bytes.Buffer
|
|
body := "--b1\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nHello\r\n" +
|
|
"--b1\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.png\"\r\nContent-Type: image/png\r\n\r\nPNGDATA\r\n--b1--\r\n"
|
|
buf.WriteString(body)
|
|
req, _ := http.NewRequest(http.MethodPost, "https://files.example.test/upload", &buf)
|
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=b1")
|
|
resp, err := env.client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if err := env.proxy.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := LoadUpstream(env.cfg.Out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parts := s.Exchanges[0].Request.Parts
|
|
if len(parts) != 2 || parts[0].Value != "Hello" || parts[1].Filename != "a.png" || parts[1].ContentType != "image/png" || len(parts[1].SHA256) != 64 {
|
|
t.Fatalf("parts = %+v", parts)
|
|
}
|
|
|
|
// The fake accepts the same parts under another boundary.
|
|
fake := NewUpstreamFake(s, nil)
|
|
other := strings.ReplaceAll(body, "b1", "zz9")
|
|
again, _ := http.NewRequest(http.MethodPost, "https://files.example.test/upload", strings.NewReader(other))
|
|
again.Header.Set("Content-Type", "multipart/form-data; boundary=zz9")
|
|
again.Header.Set("User-Agent", "Go-http-client/1.1")
|
|
if _, err := fake.RoundTrip(again); err != nil {
|
|
t.Fatalf("replay multipart: %v", err)
|
|
}
|
|
if err := fake.Verify(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake = NewUpstreamFake(s, nil)
|
|
changed, _ := http.NewRequest(http.MethodPost, "https://files.example.test/upload", strings.NewReader(strings.Replace(other, "PNGDATA", "PNGDATX", 1)))
|
|
changed.Header.Set("Content-Type", "multipart/form-data; boundary=zz9")
|
|
changed.Header.Set("User-Agent", "Go-http-client/1.1")
|
|
if _, err := fake.RoundTrip(changed); err == nil || !strings.Contains(err.Error(), "sha256") {
|
|
t.Fatalf("changed file bytes: %v", err)
|
|
}
|
|
|
|
// Forward mode sends through a PublicOnlyMode client: a loopback target
|
|
// is refused at dial and the recording fails.
|
|
target := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Error("forward mode must not reach a loopback target")
|
|
}))
|
|
t.Cleanup(target.Close)
|
|
fwd := newUpstreamProxyEnv(t, "forward", "")
|
|
resp, err = fwd.client.Get(target.URL + "/secret")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadGateway {
|
|
t.Fatalf("forward to loopback = %d, want 502", resp.StatusCode)
|
|
}
|
|
if err := fwd.proxy.Flush(); err == nil || !strings.Contains(err.Error(), "private_ip") {
|
|
t.Fatalf("Flush = %v, want private_ip", err)
|
|
}
|
|
}
|
|
|
|
func TestUpstreamProxyRefusesNonLoopback(t *testing.T) {
|
|
root := t.TempDir()
|
|
base := UpstreamProxyConfig{
|
|
CADir: filepath.Join(root, "ca"),
|
|
Out: filepath.Join(root, "fixtures", "x.upstream.yaml"),
|
|
Script: filepath.Join(root, "script.yaml"),
|
|
VarsPath: filepath.Join(root, "vars.yaml"),
|
|
}
|
|
if err := os.WriteFile(base.Script, []byte("responses: []\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, listen := range []string{"0.0.0.0:8425", "192.0.2.10:8425", ":8425"} {
|
|
cfg := base
|
|
cfg.Listen = listen
|
|
if _, err := NewUpstreamProxy(cfg); err == nil || !strings.Contains(err.Error(), "loopback") {
|
|
t.Fatalf("listen %s: err = %v, want loopback refusal", listen, err)
|
|
}
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
edit func(*UpstreamProxyConfig)
|
|
want string
|
|
}{
|
|
{"bad mode", func(c *UpstreamProxyConfig) { c.Mode = "replay" }, "script or forward"},
|
|
{"no out", func(c *UpstreamProxyConfig) { c.Out = "" }, "requires out"},
|
|
{"no ca", func(c *UpstreamProxyConfig) { c.CADir = "" }, "requires ca-dir"},
|
|
{"no vars", func(c *UpstreamProxyConfig) { c.VarsPath = "" }, "requires vars"},
|
|
{"no script", func(c *UpstreamProxyConfig) { c.Script = "" }, "requires script"},
|
|
{"ca inside out dir", func(c *UpstreamProxyConfig) { c.CADir = filepath.Join(root, "fixtures", "ca") }, "ca dir"},
|
|
{"vars inside out dir", func(c *UpstreamProxyConfig) { c.VarsPath = filepath.Join(root, "fixtures", "vars.yaml") }, "vars file"},
|
|
{"missing script", func(c *UpstreamProxyConfig) { c.Script = filepath.Join(root, "nope.yaml") }, "read upstream script"},
|
|
}
|
|
for _, tc := range cases {
|
|
cfg := base
|
|
tc.edit(&cfg)
|
|
if _, err := NewUpstreamProxy(cfg); err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("%s: err = %v, want %q", tc.name, err, tc.want)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "fixtures", "ca", parityCAKeyFile)); err == nil {
|
|
t.Fatal("a refused config must not create a CA key inside the fixtures tree")
|
|
}
|
|
bad := filepath.Join(root, "bad-script.yaml")
|
|
_ = os.WriteFile(bad, []byte("responses:\n - method: GET\n host: a.test\n path: /x\n response: {}\n"), 0o644)
|
|
cfg := base
|
|
cfg.Script = bad
|
|
if _, err := NewUpstreamProxy(cfg); err == nil || !strings.Contains(err.Error(), "response.status") {
|
|
t.Fatalf("incomplete script entry: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureParityCA(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "ca")
|
|
certPath, err := EnsureParityCA(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keyPath := filepath.Join(dir, parityCAKeyFile)
|
|
kst, err := os.Stat(keyPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if kst.Mode().Perm() != 0o600 {
|
|
t.Fatalf("key mode = %v, want 0600", kst.Mode().Perm())
|
|
}
|
|
cst, _ := os.Stat(certPath)
|
|
if cst.Mode().Perm() != 0o644 {
|
|
t.Fatalf("cert mode = %v, want 0644", cst.Mode().Perm())
|
|
}
|
|
first, _ := os.ReadFile(certPath)
|
|
firstKey, _ := os.ReadFile(keyPath)
|
|
_ = os.Chmod(keyPath, 0o644)
|
|
again, err := EnsureParityCA(dir)
|
|
if err != nil || again != certPath {
|
|
t.Fatalf("second call = %q, %v", again, err)
|
|
}
|
|
second, _ := os.ReadFile(certPath)
|
|
secondKey, _ := os.ReadFile(keyPath)
|
|
if !bytes.Equal(first, second) || !bytes.Equal(firstKey, secondKey) {
|
|
t.Fatal("second call must reuse the CA files")
|
|
}
|
|
if st, _ := os.Stat(keyPath); st.Mode().Perm() != 0o600 {
|
|
t.Fatalf("reused key mode = %v, want 0600", st.Mode().Perm())
|
|
}
|
|
cert, _, err := readParityCA(certPath, keyPath)
|
|
if err != nil || !cert.IsCA {
|
|
t.Fatalf("CA = %v, %v", cert, err)
|
|
}
|
|
if err := os.WriteFile(certPath, []byte("not pem"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := EnsureParityCA(dir); err == nil {
|
|
t.Fatal("corrupt CA must be reported, not silently replaced")
|
|
}
|
|
if _, err := EnsureParityCA(""); err == nil {
|
|
t.Fatal("empty dir must fail")
|
|
}
|
|
}
|
|
|
|
func tlsConfigWithRoots(pool *x509.CertPool) *tls.Config {
|
|
return &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
|
}
|