Add localhost resolve HTTP hop with bearer auth and redaction
Implement `keyproxy serve`: POST /v1/resolve (constant-time bearer-token
auth, 4 KiB body cap, mpk- ref validation before any echo) and
GET /healthz. The token is resolved once at startup from the file
backend. Panics are recovered with the panic value discarded and detail
suppressed; auth failures log the remote address only; every log line
naming a ref masks it as <ref>=***. Failures respond with fixed reason
enums (404/400/405/413/500/501/502) that cannot embed material.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,231 @@
|
|||||||
|
// Package server implements `keyproxy serve`: a localhost HTTP hop that
|
||||||
|
// resolves mpk- refs to material. Consumers authenticate with a bearer
|
||||||
|
// token bootstrapped from the file backend; material crosses the wire at
|
||||||
|
// resolve time only, in memory, never persisted, never logged — every
|
||||||
|
// log line that names a ref masks any value as <ref>=***.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto/subtle"
|
||||||
|
|
||||||
|
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/backend"
|
||||||
|
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxBodyBytes bounds resolve requests; bigger bodies are rejected
|
||||||
|
// before parsing (413).
|
||||||
|
const maxBodyBytes = 4 << 10
|
||||||
|
|
||||||
|
// Server is the `keyproxy serve` HTTP hop.
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
reg *backend.Registry
|
||||||
|
token string // resolved at startup from the file backend; never logged
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New resolves the bearer token through the registry (fail-fast: the
|
||||||
|
// auth ref must resolve at startup) and returns the server. The token
|
||||||
|
// value never reaches the log writer; a backend panic during the
|
||||||
|
// startup resolve is converted to a generic error (a panic value may
|
||||||
|
// embed material).
|
||||||
|
func New(cfg *config.Config, reg *backend.Registry, out io.Writer) (*Server, error) {
|
||||||
|
ref := cfg.Refs[cfg.AuthTokenRef]
|
||||||
|
b, ok := reg.Get(ref.Backend)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("auth ref %s: backend %s not registered", cfg.AuthTokenRef, ref.Backend)
|
||||||
|
}
|
||||||
|
token, err := resolveAuthToken(b, ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
reg: reg,
|
||||||
|
token: token,
|
||||||
|
logger: log.New(out, "keyproxy: ", log.LstdFlags|log.Lmsgprefix),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAuthToken resolves the bearer token, converting a backend
|
||||||
|
// panic into a generic error whose text carries no material.
|
||||||
|
func resolveAuthToken(b backend.Backend, ref backend.Ref) (token string, err error) {
|
||||||
|
defer func() {
|
||||||
|
if p := recover(); p != nil {
|
||||||
|
_ = p // never stringified: never logged
|
||||||
|
err = fmt.Errorf("auth ref %s: backend %s: resolve panicked (detail suppressed)", ref.Name, ref.Backend)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
token, err = b.Resolve(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("auth ref %s: %w", ref.Name, err)
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler builds the HTTP routes: POST /v1/resolve (bearer auth) and
|
||||||
|
// GET /healthz (unauthenticated liveness).
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"status":"ok"}`)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/v1/resolve", s.resolve)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// masked returns the log-safe form of a ref: <ref>=***. Refs are
|
||||||
|
// validated mpk- placeholders before they ever reach a log line, so the
|
||||||
|
// name itself is safe; the =*** form asserts the value stays masked.
|
||||||
|
func masked(ref string) string { return ref + "=***" }
|
||||||
|
|
||||||
|
func (s *Server) resolve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Crash-path redaction: if anything below panics, log the tracked
|
||||||
|
// ref/backend (masked, format-validated) and NEVER the panic value
|
||||||
|
// (a panic value may embed material). Stack suppressed for the same
|
||||||
|
// reason.
|
||||||
|
logRef, logBackend := "-", "-"
|
||||||
|
defer func() {
|
||||||
|
if p := recover(); p != nil {
|
||||||
|
_ = p // never stringified: never logged
|
||||||
|
s.logger.Printf("panic recovered ref=%s backend=%s status=500 detail=suppressed", logRef, logBackend)
|
||||||
|
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", http.MethodPost)
|
||||||
|
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth first, before the body is read: failures log the remote only,
|
||||||
|
// never the presented token.
|
||||||
|
if !s.authorized(r) {
|
||||||
|
s.logger.Printf("auth failure remote=%s status=401", r.RemoteAddr)
|
||||||
|
s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, ok := s.readBody(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil || strings.TrimSpace(req.Ref) == "" {
|
||||||
|
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed body (expected {\"ref\":\"mpk-...\"})"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Ref)
|
||||||
|
if !config.ValidRef(name) {
|
||||||
|
// Not echoed: an unparsable "ref" field could hold pasted
|
||||||
|
// material. Only format-validated mpk- refs are ever echoed.
|
||||||
|
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid ref (expected mpk-<name>)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logRef = masked(name)
|
||||||
|
|
||||||
|
ref, ok := s.cfg.Refs[name]
|
||||||
|
if !ok {
|
||||||
|
s.logger.Printf("resolve ref=%s backend=%s status=404 reason=%s", logRef, logBackend, backend.ReasonUnknownRef)
|
||||||
|
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown ref", "ref": name})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logBackend = ref.Backend
|
||||||
|
|
||||||
|
b, ok := s.reg.Get(ref.Backend)
|
||||||
|
if !ok {
|
||||||
|
s.logger.Printf("resolve ref=%s backend=%s status=500 reason=unknown_backend", logRef, logBackend)
|
||||||
|
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backend not registered", "backend": ref.Backend})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := b.Resolve(r.Context(), ref)
|
||||||
|
if err != nil {
|
||||||
|
rerr := backend.AsResolveError(err, ref, ref.Backend)
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if rerr.Reason == backend.ReasonNotImplemented {
|
||||||
|
status = http.StatusNotImplemented
|
||||||
|
}
|
||||||
|
// The reason is a fixed enum: it can name the ref and the
|
||||||
|
// backend, never material.
|
||||||
|
s.logger.Printf("resolve ref=%s backend=%s status=%d reason=%s", logRef, logBackend, status, rerr.Reason)
|
||||||
|
s.writeJSON(w, status, map[string]string{
|
||||||
|
"error": "resolution failed",
|
||||||
|
"ref": name,
|
||||||
|
"backend": ref.Backend,
|
||||||
|
"reason": string(rerr.Reason),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Printf("resolve ref=%s backend=%s status=200", logRef, logBackend)
|
||||||
|
s.writeJSON(w, http.StatusOK, map[string]string{"value": value})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorized checks the bearer token in constant time; the presented
|
||||||
|
// value is never logged and never compared with early exit.
|
||||||
|
func (s *Server) authorized(r *http.Request) bool {
|
||||||
|
h := r.Header.Get("Authorization")
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
presented := h[len(prefix):]
|
||||||
|
return subtle.ConstantTimeCompare([]byte(presented), []byte(s.token)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) readBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unreadable body"})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if len(body) > maxBodyBytes {
|
||||||
|
s.writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return body, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe runs the HTTP hop until ctx is cancelled, then shuts
|
||||||
|
// down gracefully (material is memory-only; shutdown drops it).
|
||||||
|
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||||
|
httpSrv := &http.Server{
|
||||||
|
Addr: s.cfg.Listen,
|
||||||
|
Handler: s.Handler(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- httpSrv.ListenAndServe() }()
|
||||||
|
s.logger.Printf("listening addr=%s refs=%d backends=%s auth=%s", s.cfg.Listen, len(s.cfg.Refs), strings.Join(s.reg.Names(), ","), masked(s.cfg.AuthTokenRef))
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return httpSrv.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/backend"
|
||||||
|
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
authToken = "smoke-bearer-token-do-not-log"
|
||||||
|
fileSecret = "sk-file-supersecret-material-aaa111"
|
||||||
|
envSecret = "sk-env-supersecret-material-bbb222"
|
||||||
|
panicSecret = "sk-panic-supersecret-material-ccc333"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fixture struct {
|
||||||
|
cfg *config.Config
|
||||||
|
log *bytes.Buffer
|
||||||
|
ts *httptest.Server
|
||||||
|
backend *backend.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFixture builds a full stack: real config file, real 0600 env files,
|
||||||
|
// real registry, real HTTP listener on 127.0.0.1 (httptest), shared log
|
||||||
|
// buffer for redaction sweeps.
|
||||||
|
func newFixture(t *testing.T) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
write := func(name, content string, mode os.FileMode) string {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, mode); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
tokenFile := write("keyproxy.env", "KEYPROXY_TOKEN="+authToken+"\n", 0o600)
|
||||||
|
goodFile := write("good.env", "API_KEY="+fileSecret+"\nOTHER=x\n", 0o600)
|
||||||
|
looseFile := write("loose.env", "API_KEY="+fileSecret+"\n", 0o644)
|
||||||
|
emptyFile := write("empty.env", "API_KEY=\n", 0o600)
|
||||||
|
|
||||||
|
t.Setenv("KEYPROXY_TEST_ENV_SECRET", envSecret)
|
||||||
|
|
||||||
|
cfgPath := write("keyproxy.toml", fmt.Sprintf(`
|
||||||
|
listen = "127.0.0.1:0"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
token_ref = "mpk-keyproxy-self"
|
||||||
|
|
||||||
|
[refs."mpk-keyproxy-self"]
|
||||||
|
backend = "file"
|
||||||
|
source = %q
|
||||||
|
key = "KEYPROXY_TOKEN"
|
||||||
|
|
||||||
|
[refs."mpk-file"]
|
||||||
|
backend = "file"
|
||||||
|
source = %q
|
||||||
|
key = "API_KEY"
|
||||||
|
|
||||||
|
[refs."mpk-env"]
|
||||||
|
backend = "env"
|
||||||
|
source = "KEYPROXY_TEST_ENV_SECRET"
|
||||||
|
|
||||||
|
[refs."mpk-loose"]
|
||||||
|
backend = "file"
|
||||||
|
source = %q
|
||||||
|
key = "API_KEY"
|
||||||
|
|
||||||
|
[refs."mpk-empty"]
|
||||||
|
backend = "file"
|
||||||
|
source = %q
|
||||||
|
key = "API_KEY"
|
||||||
|
|
||||||
|
[refs."mpk-bitwarden"]
|
||||||
|
backend = "bitwarden"
|
||||||
|
source = "sm://p/x"
|
||||||
|
key = "API_KEY"
|
||||||
|
|
||||||
|
[refs."mpk-vault"]
|
||||||
|
backend = "vault"
|
||||||
|
source = "secret/data/x"
|
||||||
|
key = "API_KEY"
|
||||||
|
`, tokenFile, goodFile, looseFile, emptyFile), 0o600)
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fixture config: %v", err)
|
||||||
|
}
|
||||||
|
reg := backend.NewRegistry(
|
||||||
|
backend.NewFile(),
|
||||||
|
backend.NewEnv(),
|
||||||
|
backend.NewBitwarden(),
|
||||||
|
backend.NewVault(),
|
||||||
|
)
|
||||||
|
log := &bytes.Buffer{}
|
||||||
|
srv, err := New(cfg, reg, log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fixture server: %v", err)
|
||||||
|
}
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return &fixture{cfg: cfg, log: log, ts: ts, backend: reg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// post drives /v1/resolve with an optional bearer token.
|
||||||
|
func (f *fixture) post(t *testing.T, token, body string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, f.ts.URL+"/v1/resolve", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
return resp.StatusCode, buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTable(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
body string
|
||||||
|
wantStatus int
|
||||||
|
wantBody string // substring expected in the body
|
||||||
|
wantBody2 string // second substring expected in the body
|
||||||
|
noBody string // substring that must NOT appear in the body
|
||||||
|
wantLog string // substring expected in the log
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "file ref resolves",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":"mpk-file"}`,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: `"value":"` + fileSecret + `"`,
|
||||||
|
wantLog: "ref=mpk-file=*** backend=file status=200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env ref resolves",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":"mpk-env"}`,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: `"value":"` + envSecret + `"`,
|
||||||
|
wantLog: "ref=mpk-env=*** backend=env status=200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no token 401",
|
||||||
|
body: `{"ref":"mpk-file"}`,
|
||||||
|
wantStatus: http.StatusUnauthorized,
|
||||||
|
wantBody: "unauthorized",
|
||||||
|
noBody: fileSecret,
|
||||||
|
wantLog: "auth failure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong token 401",
|
||||||
|
token: "wrong-token",
|
||||||
|
body: `{"ref":"mpk-file"}`,
|
||||||
|
wantStatus: http.StatusUnauthorized,
|
||||||
|
wantBody: "unauthorized",
|
||||||
|
noBody: "wrong-token", // presented token never logged/echoed
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown ref 404",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":"mpk-ghost"}`,
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
wantBody: `"ref":"mpk-ghost"`,
|
||||||
|
wantLog: "ref=mpk-ghost=*** backend=- status=404 reason=unknown_ref",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid ref format 400 without echo",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":"sk-live-pasted-secret-xyz"}`,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "invalid ref",
|
||||||
|
noBody: "sk-live-pasted-secret-xyz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed body 400",
|
||||||
|
token: authToken,
|
||||||
|
body: `not json`,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "malformed body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty ref 400",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":""}`,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "malformed body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bitwarden stub 501",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":"mpk-bitwarden"}`,
|
||||||
|
wantStatus: http.StatusNotImplemented,
|
||||||
|
wantBody: `"reason":"not_implemented"`,
|
||||||
|
wantBody2: `"backend":"bitwarden"`,
|
||||||
|
wantLog: "ref=mpk-bitwarden=*** backend=bitwarden status=501 reason=not_implemented",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vault stub 501",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":"mpk-vault"}`,
|
||||||
|
wantStatus: http.StatusNotImplemented,
|
||||||
|
wantBody: `"reason":"not_implemented"`,
|
||||||
|
wantBody2: `"backend":"vault"`,
|
||||||
|
wantLog: "ref=mpk-vault=*** backend=vault status=501 reason=not_implemented",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty value 502",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":"mpk-empty"}`,
|
||||||
|
wantStatus: http.StatusBadGateway,
|
||||||
|
wantBody: `"reason":"empty_value"`,
|
||||||
|
wantLog: "ref=mpk-empty=*** backend=file status=502 reason=empty_value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "loose file mode 502",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":"mpk-loose"}`,
|
||||||
|
wantStatus: http.StatusBadGateway,
|
||||||
|
wantBody: `"reason":"insecure_source_mode"`,
|
||||||
|
wantLog: "ref=mpk-loose=*** backend=file status=502 reason=insecure_source_mode",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "oversized body 413",
|
||||||
|
token: authToken,
|
||||||
|
body: `{"ref":"` + strings.Repeat("a", 8192) + `"}`,
|
||||||
|
wantStatus: http.StatusRequestEntityTooLarge,
|
||||||
|
wantBody: "payload too large",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
status, body := f.post(t, tt.token, tt.body)
|
||||||
|
if status != tt.wantStatus {
|
||||||
|
t.Fatalf("status = %d (%s), want %d", status, body, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if tt.wantBody != "" && !strings.Contains(body, tt.wantBody) {
|
||||||
|
t.Fatalf("body %q missing %q", body, tt.wantBody)
|
||||||
|
}
|
||||||
|
if tt.wantBody2 != "" && !strings.Contains(body, tt.wantBody2) {
|
||||||
|
t.Fatalf("body %q missing %q", body, tt.wantBody2)
|
||||||
|
}
|
||||||
|
if tt.noBody != "" && strings.Contains(body, tt.noBody) {
|
||||||
|
t.Fatalf("BODY LEAKS %q: %q", tt.noBody, body)
|
||||||
|
}
|
||||||
|
if tt.wantLog != "" && !strings.Contains(f.log.String(), tt.wantLog) {
|
||||||
|
t.Fatalf("log %q missing %q", f.log.String(), tt.wantLog)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method checks.
|
||||||
|
resp, err := http.Get(f.ts.URL + "/v1/resolve")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("GET /v1/resolve = %d, want 405", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp, err = http.Get(f.ts.URL + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("GET /healthz = %d, want 200 (unauthenticated)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole-run redaction sweep: no material, no presented token, no
|
||||||
|
// panic values anywhere in the log buffer; every ref named in the
|
||||||
|
// log appears masked as <ref>=***.
|
||||||
|
logAll := f.log.String()
|
||||||
|
for _, secret := range []string{authToken, "wrong-token", fileSecret, envSecret, "sk-live-pasted-secret"} {
|
||||||
|
if strings.Contains(logAll, secret) {
|
||||||
|
t.Fatalf("LOG LEAKS MATERIAL %q:\n%s", secret, logAll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ref := range []string{"mpk-file", "mpk-env", "mpk-ghost", "mpk-empty", "mpk-loose"} {
|
||||||
|
if strings.Contains(logAll, ref) && !strings.Contains(logAll, ref+"=***") {
|
||||||
|
// ref may appear only in masked form; check per line
|
||||||
|
for _, line := range strings.Split(logAll, "\n") {
|
||||||
|
if strings.Contains(line, ref) && !strings.Contains(line, ref+"=***") {
|
||||||
|
t.Fatalf("LOG LINE NAMES REF UNMASKED: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPanicRedaction drives a backend panic whose VALUE is the material
|
||||||
|
// and asserts the crash path stays redacted.
|
||||||
|
func TestPanicRedaction(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tokenPath := filepath.Join(dir, "keyproxy.env")
|
||||||
|
if err := os.WriteFile(tokenPath, []byte("KEYPROXY_TOKEN="+authToken+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfgPath := filepath.Join(dir, "keyproxy.toml")
|
||||||
|
content := fmt.Sprintf(`
|
||||||
|
[auth]
|
||||||
|
token_ref = "mpk-keyproxy-self"
|
||||||
|
|
||||||
|
[refs."mpk-keyproxy-self"]
|
||||||
|
backend = "file"
|
||||||
|
source = %q
|
||||||
|
key = "KEYPROXY_TOKEN"
|
||||||
|
|
||||||
|
[refs."mpk-file"]
|
||||||
|
backend = "file"
|
||||||
|
source = %q
|
||||||
|
key = "API_KEY"
|
||||||
|
`, tokenPath, filepath.Join(dir, "absent.env"))
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file-backend implementation that serves the auth token normally
|
||||||
|
// but panics with the material as the panic value on data refs: the
|
||||||
|
// worst-case crash path.
|
||||||
|
reg := backend.NewRegistry(&panicBackend{token: authToken, material: panicSecret}, backend.NewEnv(), backend.NewBitwarden(), backend.NewVault())
|
||||||
|
log := &bytes.Buffer{}
|
||||||
|
srv, err := New(cfg, reg, log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/resolve", strings.NewReader(`{"ref":"mpk-file"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if strings.Contains(buf.String(), panicSecret) {
|
||||||
|
t.Fatalf("RESPONSE LEAKS MATERIAL: %q", buf.String())
|
||||||
|
}
|
||||||
|
got := log.String()
|
||||||
|
if !strings.Contains(got, "panic recovered") || !strings.Contains(got, "ref=mpk-file=***") {
|
||||||
|
t.Fatalf("panic log line missing or unmasked:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, panicSecret) {
|
||||||
|
t.Fatalf("LOG LEAKS MATERIAL ON CRASH PATH:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewFailFastAuth covers startup refusing when the auth token cannot
|
||||||
|
// be resolved (fail fast, error names the ref, never the token).
|
||||||
|
func TestNewFailFastAuth(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "keyproxy.toml")
|
||||||
|
content := fmt.Sprintf(`
|
||||||
|
[auth]
|
||||||
|
token_ref = "mpk-keyproxy-self"
|
||||||
|
|
||||||
|
[refs."mpk-keyproxy-self"]
|
||||||
|
backend = "file"
|
||||||
|
source = %q
|
||||||
|
key = "KEYPROXY_TOKEN"
|
||||||
|
`, filepath.Join(dir, "absent.env"))
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := backend.NewRegistry(backend.NewFile(), backend.NewEnv(), backend.NewBitwarden(), backend.NewVault())
|
||||||
|
_, err = New(cfg, reg, &bytes.Buffer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("New must fail when the auth token cannot be resolved")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "mpk-keyproxy-self") || !strings.Contains(err.Error(), "unreadable_source") {
|
||||||
|
t.Fatalf("error %q must name the auth ref and reason", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// panicBackend implements Backend (registered under the file name):
|
||||||
|
// with all set it panics on every resolve; otherwise it serves the auth
|
||||||
|
// token normally and panics with material in the panic value on every
|
||||||
|
// other ref.
|
||||||
|
type panicBackend struct {
|
||||||
|
token string
|
||||||
|
material string
|
||||||
|
all bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panicBackend) Name() string { return "file" }
|
||||||
|
|
||||||
|
func (p *panicBackend) Resolve(ctx context.Context, ref backend.Ref) (string, error) {
|
||||||
|
if !p.all && ref.Key == "KEYPROXY_TOKEN" {
|
||||||
|
return p.token, nil
|
||||||
|
}
|
||||||
|
panic(p.material)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewPanicSuppressed: a backend that panics even on the auth-token
|
||||||
|
// resolve makes startup fail fast with a generic, material-free error.
|
||||||
|
func TestNewPanicSuppressed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "keyproxy.toml")
|
||||||
|
content := fmt.Sprintf(`
|
||||||
|
[auth]
|
||||||
|
token_ref = "mpk-keyproxy-self"
|
||||||
|
|
||||||
|
[refs."mpk-keyproxy-self"]
|
||||||
|
backend = "file"
|
||||||
|
source = %q
|
||||||
|
key = "KEYPROXY_TOKEN"
|
||||||
|
`, filepath.Join(dir, "absent.env"))
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := backend.NewRegistry(
|
||||||
|
&panicBackend{token: "never-served", material: panicSecret, all: true},
|
||||||
|
backend.NewEnv(), backend.NewBitwarden(), backend.NewVault(),
|
||||||
|
)
|
||||||
|
logBuf := &bytes.Buffer{}
|
||||||
|
_, err = New(cfg, reg, logBuf)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("New must fail when the auth-token resolve panics")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), panicSecret) {
|
||||||
|
t.Fatalf("STARTUP ERROR LEAKS MATERIAL: %q", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "panicked (detail suppressed)") {
|
||||||
|
t.Fatalf("error %q must say the panic detail is suppressed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user