Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions pkg/audit/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
package audit

import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
"unicode/utf8"
)

// ReplayHeaderName is the header attached to replayed requests so the
Expand Down Expand Up @@ -59,15 +62,15 @@ type Payload struct {
RequestHeaders map[string][]string `json:"request_headers,omitempty"`
RequestQuery map[string][]string `json:"request_query,omitempty"`
RequestContentType string `json:"request_content_type,omitempty"`
RequestBody []byte `json:"request_body,omitempty"`
RequestBody []byte `json:"-"`
RequestSizeBytes int `json:"request_size_bytes,omitempty"`
RequestTruncated bool `json:"request_truncated,omitempty"`
RequestRemoteAddr string `json:"request_remote_addr,omitempty"`

// Response side
ResponseHeaders map[string][]string `json:"response_headers,omitempty"`
ResponseContentType string `json:"response_content_type,omitempty"`
ResponseBody []byte `json:"response_body,omitempty"`
ResponseBody []byte `json:"-"`
ResponseSizeBytes int `json:"response_size_bytes,omitempty"`
ResponseTruncated bool `json:"response_truncated,omitempty"`

Expand All @@ -76,6 +79,40 @@ type Payload struct {
ReplayedFrom string `json:"replayed_from,omitempty"`
}

// MarshalJSON renders Payload so the portal SPA and any human reader see
// request/response bodies as utf-8 strings, not the base64 dump Go's
// default []byte encoder produces. When a body isn't valid utf-8 we fall
// back to base64 and flag it via a sibling `_encoding` field so callers
// can decode unambiguously.
func (p Payload) MarshalJSON() ([]byte, error) {
type alias Payload
out := struct {
alias
RequestBody string `json:"request_body,omitempty"`
RequestBodyEncoding string `json:"request_body_encoding,omitempty"`
ResponseBody string `json:"response_body,omitempty"`
ResponseBodyEncoding string `json:"response_body_encoding,omitempty"`
}{alias: alias(p)}

if len(p.RequestBody) > 0 {
if utf8.Valid(p.RequestBody) {
out.RequestBody = string(p.RequestBody)
} else {
out.RequestBody = base64.StdEncoding.EncodeToString(p.RequestBody)
out.RequestBodyEncoding = "base64"
}
}
if len(p.ResponseBody) > 0 {
if utf8.Valid(p.ResponseBody) {
out.ResponseBody = string(p.ResponseBody)
} else {
out.ResponseBody = base64.StdEncoding.EncodeToString(p.ResponseBody)
out.ResponseBodyEncoding = "base64"
}
}
return json.Marshal(out)
}

// NewEvent constructs an Event with sensible defaults filled in.
func NewEvent(method, path string) *Event {
return &Event{
Expand Down
62 changes: 62 additions & 0 deletions pkg/audit/event_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,72 @@
package audit

import (
"encoding/json"
"net/http"
"testing"
)

func TestPayload_MarshalJSON_UTF8BodiesAsStrings(t *testing.T) {
p := Payload{
RequestBody: []byte(`{"hello":"world","n":42}`),
ResponseBody: []byte("plain text response"),
}
out, err := json.Marshal(p)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["request_body"] != `{"hello":"world","n":42}` {
t.Errorf("request_body = %v, want literal JSON string", got["request_body"])
}
if got["response_body"] != "plain text response" {
t.Errorf("response_body = %v, want plain text", got["response_body"])
}
if _, present := got["request_body_encoding"]; present {
t.Errorf("request_body_encoding should be absent for utf-8 bodies")
}
}

func TestPayload_MarshalJSON_BinaryBodyBase64Flagged(t *testing.T) {
p := Payload{
ResponseBody: []byte{0xff, 0xfe, 0x00, 0x01},
}
out, err := json.Marshal(p)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["response_body_encoding"] != "base64" {
t.Errorf("response_body_encoding = %v, want base64", got["response_body_encoding"])
}
if got["response_body"] == "" {
t.Errorf("response_body empty for binary input")
}
}

func TestPayload_MarshalJSON_EmptyBodiesOmitted(t *testing.T) {
p := Payload{RequestContentType: "application/json"}
out, err := json.Marshal(p)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, k := range []string{"request_body", "response_body", "request_body_encoding", "response_body_encoding"} {
if _, present := got[k]; present {
t.Errorf("%s should be omitted when body is empty", k)
}
}
}

func TestSanitizeHeaders_Redacts(t *testing.T) {
h := http.Header{}
h.Set("Authorization", "Bearer secret")
Expand Down
42 changes: 42 additions & 0 deletions pkg/httpmw/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,36 @@ import (
"errors"
"log/slog"
"net/http"
"strings"

"github.com/plexara/api-test/pkg/auth/inbound"
)

// hasInboundCredential reports whether the request carries a credential
// the inbound chain could resolve: an X-API-Key header, an ?api_key=
// query param, or an Authorization: Bearer token. Used to decide
// whether the pre-identity bypass should yield to a wire-level
// credential.
//
// TODO: this hardcodes the default api-key header/query names. If a
// deployment customizes APIKeysConfig.HeaderName / QueryParamName, an
// operator-typed credential using the custom name will silently slip
// past the predicate and the pre-identity bypass will win instead of
// the chain. The api-test-server.plexara.io deployment runs the
// defaults so this is not a live regression; revisit when wiring
// Identity() to know about the configured names (or when the chain
// gains a HasCredential method that authenticators implement).
func hasInboundCredential(r *http.Request) bool {
if r.Header.Get("X-API-Key") != "" {
return true
}
if r.URL != nil && r.URL.Query().Get("api_key") != "" {
return true
}
a := r.Header.Get("Authorization")
return a != "" && strings.HasPrefix(strings.ToLower(a), "bearer ")
}

// Identity returns middleware that runs the inbound auth chain and
// either:
// - attaches the resolved Identity to the context and proceeds, or
Expand All @@ -20,9 +46,25 @@ import (
//
// The "401 includes WWW-Authenticate" RFC 6750 convention is honored:
// requests that came in without a credential get a Bearer challenge.
//
// When an inbound.Identity is already present on the request context AND
// no credential is on the wire, the chain is bypassed. This is the
// Try-It / audit-replay dispatch path: the portal handler has already
// auth'd the operator via the portal session, so re-running the inbound
// chain (which only knows about API keys / bearer tokens on the wire)
// would 401 a request the portal already accepted. If the dispatched
// request DOES carry a credential header (e.g. an operator typed
// X-API-Key into the Try-It headers field to test as a different
// principal), the chain runs and wins — that's the documented way to
// "test as someone else" through Try-It.
func Identity(chain *inbound.Chain, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if pre := inbound.FromContext(r.Context()); pre != nil && !hasInboundCredential(r) {
recordIdentity(r.Context(), pre)
next.ServeHTTP(w, r)
return
}
id, err := chain.Authenticate(r.Context(), r)
if err != nil {
if errors.Is(err, inbound.ErrNoCredential) {
Expand Down
52 changes: 52 additions & 0 deletions pkg/httpmw/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,58 @@ func TestIdentity_401WhenNoCredAndNoAnonymous(t *testing.T) {
}
}

func TestIdentity_PreSetSkipsChain(t *testing.T) {
// Try-It dispatch path: the portal handler has already authed the
// operator and attached an inbound.Identity to the request context
// before re-entering the mux. The chain — which would otherwise 401
// because no credential is on the wire — must yield.
chain := inbound.NewChain(false, inbound.NewBearer([]config.FileBearerToken{{Name: "x", Token: "t"}}))
var saw *inbound.Identity
h := Identity(chain, discardLogger())(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
saw = inbound.FromContext(r.Context())
}))
pre := &inbound.Identity{Subject: "alice", AuthType: "portal", KeyName: "session"}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r = r.WithContext(inbound.WithIdentity(r.Context(), pre))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("status %d want 200", w.Code)
}
if saw == nil || saw.Subject != "alice" || saw.AuthType != "portal" {
t.Errorf("identity = %+v, want pre-set alice/portal", saw)
}
}

func TestIdentity_WireCredentialOverridesPreSet(t *testing.T) {
// Operator types X-API-Key into the Try-It headers field to test
// "does this key resolve to the right principal?". The chain must
// still run so the resolved Identity reflects the wire credential,
// not the portal session that planted the pre-identity.
store := inbound.NewFileAPIKeyStore([]config.FileAPIKey{{Name: "ci-runner", Key: "secret"}})
apikey := inbound.NewAPIKey(store, "X-API-Key", "api_key")
chain := inbound.NewChain(false, apikey)

var saw *inbound.Identity
h := Identity(chain, discardLogger())(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
saw = inbound.FromContext(r.Context())
}))

portalPre := &inbound.Identity{Subject: "alice", AuthType: "portal", KeyName: "session"}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("X-API-Key", "secret")
r = r.WithContext(inbound.WithIdentity(r.Context(), portalPre))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)

if w.Code != http.StatusOK {
t.Errorf("status %d want 200", w.Code)
}
if saw == nil || saw.KeyName != "ci-runner" || saw.AuthType != "apikey" {
t.Errorf("identity = %+v, want wire creds (ci-runner/apikey) not portal pre-set", saw)
}
}

func TestIdentity_AnonymousFallthrough(t *testing.T) {
chain := inbound.NewChain(true)
var saw *inbound.Identity
Expand Down
55 changes: 53 additions & 2 deletions pkg/httpsrv/portal_audit_replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/google/uuid"

"github.com/plexara/api-test/pkg/audit"
"github.com/plexara/api-test/pkg/auth"
"github.com/plexara/api-test/pkg/auth/inbound"
)

// replayHeaderMarker is the header attached to every replayed request
Expand All @@ -25,6 +27,35 @@ const replayHeaderMarker = audit.ReplayHeaderName
// allocate large amounts of memory at replay time.
const replayMaxBodyBytes = 1 << 20 // 1 MiB

// redactedReplayHeaders are credential-carrying header names whose
// captured values are guaranteed to be the "[redacted]" sentinel
// written by audit.SanitizeHeaders. Re-emitting them would put a
// non-empty value on the wire that httpmw.Identity would treat as a
// real credential, defeating the portal-identity bypass. The replay
// path carries identity through context, so dropping them is safe.
//
// TODO: paired with the same gap in pkg/httpmw/identity.go — if a
// deployment customizes APIKeysConfig.HeaderName, the captured
// redaction sentinel under the custom name will leak back into the
// replayed request. Fix when the chain gains a self-describing
// HasCredential surface.
var redactedReplayHeaders = map[string]struct{}{
"authorization": {},
"x-api-key": {},
"cookie": {},
}

func isRedactedCredentialHeader(name string) bool {
_, ok := redactedReplayHeaders[strings.ToLower(name)]
return ok
}

// isRedactedCredentialQuery matches the ?api_key= form the apikey
// authenticator also accepts; audit.SanitizeQuery redacts it identically.
func isRedactedCredentialQuery(name string) bool {
return strings.EqualFold(name, "api_key")
}

// auditReplay re-issues a captured request through the local mux.
// Requires the audit Logger to implement PayloadLogger (so we can
// reconstruct headers + body) and the composition layer to have wired
Expand Down Expand Up @@ -103,6 +134,9 @@ func (p *PortalAPI) auditReplay(w http.ResponseWriter, r *http.Request) {
if len(payload.RequestQuery) > 0 {
values := url.Values{}
for k, vs := range payload.RequestQuery {
if isRedactedCredentialQuery(k) {
continue
}
for _, v := range vs {
values.Add(k, v)
}
Expand All @@ -114,21 +148,38 @@ func (p *PortalAPI) auditReplay(w http.ResponseWriter, r *http.Request) {
if len(body) > replayMaxBodyBytes {
body = body[:replayMaxBodyBytes]
}
replayReq, err := http.NewRequestWithContext(r.Context(),

// The captured request's Authorization / X-API-Key are persisted as
// "[redacted]", so replaying them verbatim would 401 the call. Carry
// the operator's portal-resolved identity into the replayed request's
// context — same trust model as Try-It dispatch.
replayCtx := r.Context()
if portalID := auth.GetIdentity(replayCtx); portalID != nil {
replayCtx = inbound.WithIdentity(replayCtx, portalIdentityToInbound(portalID))
}

replayReq, err := http.NewRequestWithContext(replayCtx,
ev.Method, u.String(), bytes.NewReader(body))
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Errorf("construct replay request: %w", err))
return
}

// Copy captured headers but refuse to replay anything already
// carrying our marker (loop guard).
// carrying our marker (loop guard). Skip credential headers — the
// audit middleware persists their values as "[redacted]", so
// re-emitting them would put a redaction sentinel on the wire that
// httpmw.Identity treats as a real credential and then fails to
// validate. Identity flows through the dispatch context instead.
for k, vs := range payload.RequestHeaders {
if strings.EqualFold(k, replayHeaderMarker) {
writeError(w, http.StatusBadRequest,
errors.New("captured request already carries the replay marker — refusing to replay a replay"))
return
}
if isRedactedCredentialHeader(k) {
continue
}
for _, v := range vs {
replayReq.Header.Add(k, v)
}
Expand Down
Loading
Loading