diff --git a/README.md b/README.md index 23022ea..d5b00c1 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ It mints a JWT from `${VAULT_ADDR}/v1/identity/oidc/token/${ENCAPI_OIDC_ROLE}` (defaults `https://vault.service.consul:8200`, `encapi-testing`) using `VAULT_TOKEN` or `~/.vault-token`, trusting `VAULT_CACERT` when set, then `PUT`s `{"environment":"testing"}` to -`${ENCAPI_URL}/api/v1/nodes//environment`. +`${ENCAPI_URL}/api/v1/nodes//environment`. `ENCAPI_URL` must be +`https`; the bearer JWT is never sent over plain http. ## Behaviour (drop-in for the python ENC) diff --git a/settesting.go b/settesting.go index c5f5e90..8673526 100644 --- a/settesting.go +++ b/settesting.go @@ -20,6 +20,13 @@ const defaultVaultAddr = "https://vault.service.consul:8200" // setTesting pins certname to the testing environment via encapi, using a // Vault identity OIDC token for the encapi-testing role. func setTesting(certname string, out io.Writer) error { + baseURL := os.Getenv("ENCAPI_URL") + if baseURL == "" { + baseURL = defaultBaseURL + } + if parsed, err := url.Parse(baseURL); err != nil || parsed.Scheme != "https" { + return fmt.Errorf("set-testing requires an https ENCAPI_URL, got %q", baseURL) + } client, err := vaultClient() if err != nil { return err @@ -29,10 +36,6 @@ func setTesting(certname string, out io.Writer) error { return err } - baseURL := os.Getenv("ENCAPI_URL") - if baseURL == "" { - baseURL = defaultBaseURL - } u := strings.TrimRight(baseURL, "/") + "/api/v1/nodes/" + url.PathEscape(certname) + "/environment" req, err := http.NewRequest(http.MethodPut, u, bytes.NewBufferString(`{"environment":"testing"}`)) if err != nil { diff --git a/settesting_test.go b/settesting_test.go index 7583a84..6bd4905 100644 --- a/settesting_test.go +++ b/settesting_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/pem" "io" "net/http" "net/http/httptest" @@ -33,7 +34,7 @@ type seenPut struct{ path, auth, body string } func fakeEncapi(t *testing.T, status int, seen *seenPut) *httptest.Server { t.Helper() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) if r.Method == http.MethodPut { *seen = seenPut{r.URL.Path, r.Header.Get("Authorization"), string(b)} @@ -42,9 +43,21 @@ func fakeEncapi(t *testing.T, status int, seen *seenPut) *httptest.Server { _, _ = io.WriteString(w, `{"error":"forbidden"}`) })) t.Cleanup(srv.Close) + trustServer(t, srv) return srv } +// trustServer points VAULT_CACERT at the TLS server's certificate. +func trustServer(t *testing.T, srv *httptest.Server) { + t.Helper() + path := filepath.Join(t.TempDir(), "ca.pem") + b := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("VAULT_CACERT", path) +} + func TestSetTestingSuccess(t *testing.T) { var seen seenPut t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL) @@ -106,3 +119,98 @@ func TestSetTestingEncapiForbidden(t *testing.T) { t.Fatalf("err = %v, want HTTP 403 with body", err) } } + +func TestSetTestingRefusesPlainHTTP(t *testing.T) { + vaultHit := false + vault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { vaultHit = true })) + t.Cleanup(vault.Close) + t.Setenv("VAULT_ADDR", vault.URL) + t.Setenv("VAULT_TOKEN", "vt") + + for _, encapiURL := range []string{"http://encapi.example", "", "encapi.example", "://bad"} { + t.Setenv("ENCAPI_URL", encapiURL) + err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "requires an https ENCAPI_URL") { + t.Errorf("ENCAPI_URL=%q: err = %v, want https refusal", encapiURL, err) + } + } + if vaultHit { + t.Error("vault called despite non-https ENCAPI_URL") + } +} + +func TestSetTestingVaultNoToken(t *testing.T) { + for _, body := range []string{`{"data":{"token":""}}`, `{"data":{}}`, `{}`} { + var seen seenPut + t.Setenv("VAULT_ADDR", fakeVault(t, 200, body).URL) + t.Setenv("VAULT_TOKEN", "vt") + t.Setenv("ENCAPI_URL", fakeEncapi(t, 200, &seen).URL) + + err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "no OIDC token") { + t.Errorf("body %s: err = %v, want no OIDC token", body, err) + } + if seen.path != "" { + t.Errorf("body %s: encapi called: %+v", body, seen) + } + } +} + +func TestSetTestingNoVaultToken(t *testing.T) { + var seen seenPut + t.Setenv("HOME", t.TempDir()) + t.Setenv("VAULT_TOKEN", "") + t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL) + t.Setenv("ENCAPI_URL", fakeEncapi(t, 200, &seen).URL) + + err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "no VAULT_TOKEN and cannot read ~/.vault-token") { + t.Fatalf("err = %v, want missing vault token", err) + } + if seen.path != "" { + t.Errorf("encapi called: %+v", seen) + } +} + +func TestVaultClientCACertUnreadable(t *testing.T) { + t.Setenv("VAULT_CACERT", filepath.Join(t.TempDir(), "missing.pem")) + if _, err := vaultClient(); err == nil || !strings.Contains(err.Error(), "read VAULT_CACERT") { + t.Fatalf("err = %v, want read VAULT_CACERT", err) + } +} + +func TestVaultClientCACertNoPEM(t *testing.T) { + path := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(path, []byte("not a certificate\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("VAULT_CACERT", path) + if _, err := vaultClient(); err == nil || !strings.Contains(err.Error(), "no certificates in VAULT_CACERT") { + t.Fatalf("err = %v, want no certificates", err) + } +} + +func TestVaultClientCACertTrustsServer(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + t.Cleanup(srv.Close) + + t.Setenv("VAULT_CACERT", "") + plain, err := vaultClient() + if err != nil { + t.Fatal(err) + } + if _, err := plain.Get(srv.URL); err == nil { + t.Fatal("untrusted TLS server accepted without VAULT_CACERT") + } + + trustServer(t, srv) + client, err := vaultClient() + if err != nil { + t.Fatalf("vaultClient: %v", err) + } + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("GET with VAULT_CACERT: %v", err) + } + _ = resp.Body.Close() +}