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
124 changes: 124 additions & 0 deletions core/listener/extproc/plaintextgate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package extproc

import (
"context"
"encoding/json"
"strings"
"testing"

extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"

"github.com/rossoctl/cortex/core/pipeline"
"github.com/rossoctl/cortex/core/plugins/staticinject"
)

// plaintextGateServer builds an ext_proc server whose outbound pipeline holds
// a static-inject configured with a credential for api.example.com — the #1481
// shape, driven here through Envoy's own message wire so what is pinned is the
// LISTENER's plumbing: the :scheme pseudo-header reaching pctx.Scheme, which
// the plugin's plaintext gate reads.
//
// The plugin-level behaviour is pinned in staticinject's own tests and the
// forward proxy's leg end to end; without this file the extproc leg was
// verified only by reading the source, so a dropped :scheme read would stop
// the envoy-sidecar shape of the gate firing while every one of those stayed
// green — exactly the silent regression a listener-wiring test exists to catch.
func plaintextGateServer(t *testing.T) *Server {
t.Helper()
inject := staticinject.New()
cfg := `{
"source": "mappings",
"mappings": {"api.example.com": "REAL"},
"key_by": "host",
"placeholder": "PLACEHOLDER"
}`
if err := inject.Configure(json.RawMessage(cfg)); err != nil {
t.Fatalf("static-inject Configure: %v", err)
}
p, err := pipeline.New([]pipeline.Plugin{inject})
if err != nil {
t.Fatalf("pipeline.New: %v", err)
}
return &Server{OutboundPipeline: pipeline.NewHolder(p)}
}

// TestExtProc_Outbound_PlaintextCredentialRefusedOnSchemeHeader: a request
// whose :scheme pseudo-header says http, to a host the pipeline holds a
// credential for, is refused 403 plaintext_credential — the same conformance
// case TestForwardProxy_PlaintextCredentialRefused pins on the proxy-sidecar
// leg, asserted here on Envoy's message wire so the two deployment shapes are
// both pinned against the same regression.
func TestExtProc_Outbound_PlaintextCredentialRefusedOnSchemeHeader(t *testing.T) {
srv := plaintextGateServer(t)

stream := &mockStream{ctx: context.Background(), requests: []*extprocv3.ProcessingRequest{
{
Request: &extprocv3.ProcessingRequest_RequestHeaders{
RequestHeaders: &extprocv3.HttpHeaders{Headers: makeHeaders(
"x-authbridge-direction", "outbound",
":method", "GET",
":scheme", "http",
":path", "/x",
":authority", "api.example.com",
"authorization", "Bearer PLACEHOLDER",
)},
},
},
}}
_ = srv.Process(stream)

if len(stream.responses) != 1 {
t.Fatalf("expected 1 response, got %d", len(stream.responses))
}
ir := stream.responses[0].GetImmediateResponse()
if ir == nil {
t.Fatalf("expected ImmediateResponse (deny), got %+v", stream.responses[0])
}
if ir.Status.Code != 403 {
t.Errorf("status = %d, want 403", ir.Status.Code)
}
if !strings.Contains(string(ir.Body), "plaintext_credential") {
t.Errorf("body = %q, want the plaintext_credential error code", ir.Body)
}
}

// TestExtProc_Outbound_PlaintextGatePassesSchemeHTTPS: the same request with
// :scheme https injects exactly as before the gate existed — the SetHeaders
// mutation carries the credential — so this leg also pins that the gate does
// not over-fire on the envoy path.
func TestExtProc_Outbound_PlaintextGatePassesSchemeHTTPS(t *testing.T) {
srv := plaintextGateServer(t)

stream := &mockStream{ctx: context.Background(), requests: []*extprocv3.ProcessingRequest{
{
Request: &extprocv3.ProcessingRequest_RequestHeaders{
RequestHeaders: &extprocv3.HttpHeaders{Headers: makeHeaders(
"x-authbridge-direction", "outbound",
":method", "GET",
":scheme", "https",
":path", "/x",
":authority", "api.example.com",
"authorization", "Bearer PLACEHOLDER",
)},
},
},
}}
_ = srv.Process(stream)

if len(stream.responses) != 1 {
t.Fatalf("expected 1 response, got %d", len(stream.responses))
}
resp := stream.responses[0]
if ir := resp.GetImmediateResponse(); ir != nil {
t.Fatalf("https request was refused: status %d body %q", ir.Status.Code, ir.Body)
}
// setHeaderValue is placeholder_test.go's accessor for the allow path's
// header mutation; the injected credential must be on it.
got, ok := setHeaderValue(resp, "authorization")
if !ok {
t.Fatalf("expected SetHeaders mutation for authorization, got %+v", resp)
}
if got != "Bearer REAL" {
t.Errorf("authorization = %q, want %q (injected on https as before the gate)", got, "Bearer REAL")
}
}
64 changes: 64 additions & 0 deletions core/listener/forwardproxy/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/rossoctl/cortex/core/pipeline"
"github.com/rossoctl/cortex/core/plugins/jwtvalidation/validation"
"github.com/rossoctl/cortex/core/plugins/plugintesting"
"github.com/rossoctl/cortex/core/plugins/staticinject"
"github.com/rossoctl/cortex/core/plugins/tokenexchange/cache"
"github.com/rossoctl/cortex/core/plugins/tokenexchange/exchange"
"github.com/rossoctl/cortex/core/routing"
Expand Down Expand Up @@ -434,6 +435,69 @@ func TestForwardProxy_BodyBuffering(t *testing.T) {
}
}

// TestForwardProxy_PlaintextCredentialRefused pins #1481's conformance case
// end to end: an absolute-form http:// request through the proxy to a host
// that has a credential is refused 403 plaintext_credential, and the upstream
// is never dialed — the secret cannot cross the wire in the clear because
// nothing crosses it at all.
func TestForwardProxy_PlaintextCredentialRefused(t *testing.T) {
var upstreamHits int
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamHits++
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

// static-inject keyed by host:port — pctx.Host for a proxied request is
// the URL's host:port, so the resolver key must match it exactly.
inject := staticinject.New()
cfg := `{
"source": "mappings",
"mappings": {"` + mustParseURL(backend.URL).Host + `": "REAL"},
"key_by": "host",
"placeholder": "PLACEHOLDER"
}`
if err := inject.Configure(json.RawMessage(cfg)); err != nil {
t.Fatalf("Configure() error = %v", err)
}
p, err := pipeline.New([]pipeline.Plugin{inject})
if err != nil {
t.Fatal(err)
}

srv := &Server{OutboundPipeline: pipeline.NewHolder(p), Client: http.DefaultClient}
proxy := httptest.NewServer(srv.Handler())
defer proxy.Close()

req, _ := http.NewRequest("GET", backend.URL+"/x", nil)
req.Header.Set("Authorization", "Bearer PLACEHOLDER")

proxyClient := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(proxy.URL)),
},
}
resp, err := proxyClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want 403 (body %q)", resp.StatusCode, body)
}
if !strings.Contains(string(body), "plaintext_credential") {
t.Errorf("body = %q, want the plaintext_credential error code", body)
}
if strings.Contains(string(body), "REAL") {
t.Errorf("body = %q, must not carry the credential", body)
}
if upstreamHits != 0 {
t.Errorf("upstream hits = %d, want 0 (nothing sent)", upstreamHits)
}
}

// postThroughBodyReader sends a size-byte POST through a forward proxy whose
// pipeline buffers request bodies, and reports the status the client got and
// how many bytes the plugin and the upstream each saw — -1 for a side the
Expand Down
94 changes: 94 additions & 0 deletions core/listener/reverseproxy/staticinject_plaintext_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package reverseproxy

import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"github.com/rossoctl/cortex/core/pipeline"
"github.com/rossoctl/cortex/core/plugins/staticinject"
)

// MOCA AB1's shape, end to end: static-inject in the INBOUND pipeline, a
// PLAINTEXT front, and an HTTPS backend. The credential never crosses the
// plaintext hop — it is injected on the way out, to a TLS backend — so the
// request must be forwarded with the real key.
//
// This is a listener-level test on purpose. #1481's plaintext gate reads
// pctx.Scheme, which on the reverse proxy is requestScheme(r): the scheme the
// CLIENT used to reach Cortex, "http" for any non-TLS inbound listener. The
// backend's scheme comes from reverse_proxy_backend and is invisible from the
// plugin. So the gate, before it also checked Direction, refused every request
// through AB1 — 403 with zero upstream hits.
//
// The unit tests in core/plugins/staticinject could not have caught it: they
// build a pipeline.Context by hand, so the wrong-hop Scheme has to come from a
// real listener to show up. AB1 runs
// reverse_proxy_addr ":8080" in front of reverse_proxy_backend
// "https://api.anthropic.com" (moca deploy/knative/authbridge/ab1-deployment.yaml).
func TestStaticInject_InboundPlaintextFrontHTTPSBackend_StillInjects(t *testing.T) {
var hits int64
var sawKey string
backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&hits, 1)
sawKey = r.Header.Get("x-api-key")
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

// AB1's static-inject config: key_by static, inject_header x-api-key.
p := staticinject.New()
cfg, err := json.Marshal(map[string]any{
"source": "mappings",
"mappings": map[string]string{"anthropic": "sk-real-secret"},
"key_by": "static",
"key": "anthropic",
"inject_header": "x-api-key",
})
if err != nil {
t.Fatal(err)
}
if err := p.Configure(json.RawMessage(cfg)); err != nil {
t.Fatalf("Configure: %v", err)
}
pl, err := pipeline.New([]pipeline.Plugin{p})
if err != nil {
t.Fatalf("pipeline.New: %v", err)
}
srv, err := NewServer(pipeline.NewHolder(pl), nil, backend.URL, nil)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
srv.proxy.Transport = backend.Client().Transport // trust the test CA

// Plaintext front, as reverse_proxy_addr: ":8080".
front := httptest.NewServer(srv.Handler())
defer front.Close()

req, err := http.NewRequest(http.MethodPost, front.URL+"/v1/messages", nil)
if err != nil {
t.Fatal(err)
}
// The placeholder bearer the workload presents; static-inject swaps it.
req.Header.Set("Authorization", "Bearer placeholder-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode == http.StatusForbidden {
t.Fatalf("status = 403 — the inbound plaintext front was read as the credential's hop; AB1's every LLM call would be refused")
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if n := atomic.LoadInt64(&hits); n != 1 {
t.Errorf("backend hits = %d, want 1", n)
}
if sawKey != "sk-real-secret" {
t.Errorf("backend saw x-api-key = %q, want the injected credential", sawKey)
}
}
71 changes: 69 additions & 2 deletions core/plugins/staticinject/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,9 @@ func (p *StaticInject) Configure(raw json.RawMessage) error {

// OnRequest implements the fail-closed injection sequence: missing/mismatched
// placeholder, unresolved key, or an unsafe resolved value all deny with 401
// and leave the Authorization header unchanged. Only a fully successful
// resolution swaps the header.
// and leave the Authorization header unchanged. A resolved credential on a
// plaintext (http) request denies with 403 plaintext_credential — see the
// gate below. Only a fully successful resolution swaps the header.
func (p *StaticInject) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action {
if p.resolver == nil {
return pipeline.DenyStatus(401, "static-inject.unconfigured", "static-inject plugin not configured")
Expand Down Expand Up @@ -211,6 +212,72 @@ func (p *StaticInject) OnRequest(ctx context.Context, pctx *pipeline.Context) pi
return pipeline.DenyStatus(401, "static-inject.unresolved-key", "no credential available for the resolved key")
}

// A credential that is about to be injected must never cross the network
// in cleartext (#1481, MOCA MI1 §8.5: 403 plaintext_credential). pctx.Scheme
// is "http" exactly for a request the listener knows is plaintext — an
// absolute-form http:// URL through the forward proxy, or Envoy's :scheme

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "or Envoy's :scheme on the ext_proc path": the envoy profile does not include staticinject (scripts/profile-tags/profiles.go:79), so no shipped envoy-sidecar image runs this gate. extproc/plaintextgate_test.go pins wiring that is fine to have, but the comment reads as if the envoy shape is covered in production. Consider "would be, if the envoy profile shipped it".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a11bdf1. Confirmed first: staticinject's profile list is
{ProfileFull, ProfileFullCpex, ProfileLite} (scripts/profile-tags/profiles.go) with no
ProfileEnvoy, so you are right that no shipped envoy-sidecar image runs this gate and the
comment was describing a path that does not exist in production.

The comment now reads "would be, if the envoy profile shipped it" and names
extproc/plaintextgate_test.go as pinning the wiring against that changing — so the test
keeps its purpose without the comment implying the envoy shape is covered today.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

// on the ext_proc path (would be, if the envoy profile shipped this plugin
// — it does not, so no released envoy-sidecar image runs this gate;
// extproc/plaintextgate_test.go pins the wiring against that changing);
// a TLS-bridged request carries "https". Checked
// here, after resolution and before any header write, so the refusal
// names the credential rather than the scheme: plain HTTP to a host
// WITHOUT one keeps its normal allow-list/SSRF treatment, and an empty
// scheme (listener couldn't tell) is left to those same checks rather
// than denied on a guess.
//
// OUTBOUND ONLY, and the direction is load-bearing rather than a
// narrowing for caution's sake. On the reverse proxy pctx.Scheme is
// requestScheme(r) (reverseproxy/server.go) — the scheme the CLIENT
// used to reach Cortex, which is "http" whenever the inbound listener is
// not TLS. The credential does not travel on that hop; it leaves on the
// Cortex→backend hop, whose scheme comes from reverse_proxy_backend and
// is invisible from here. So judging an inbound request by pctx.Scheme
// asks about the wrong hop entirely.
//
// That is a real deployment, not a hypothetical: MOCA's AB1 runs
// static-inject in the INBOUND pipeline behind a plaintext
// reverse_proxy_addr: ":8080" with reverse_proxy_backend:
// "https://api.anthropic.com" (moca deploy/knative/authbridge/
// ab1-deployment.yaml). Gating on scheme alone refused every LLM call
// through it — reproduced at 403 with zero upstream hits (#1537 review).
//
// Covering inbound properly would mean teaching the reverse proxy to
// expose its backend scheme, which #1481 does not ask for; until then the
// uncovered inbound risk is a reverse_proxy_backend of "http://" — the
// real key is injected and crosses THAT hop in cleartext — and pointing
// it at https:// (or a trusted network) is the operator's own call, not
// the guest-controlled egress this gate is about. The front listener's
// transport is not what matters here; the credential never travels on it.
//
// The check lives in the injector, not in a pre-pipeline gate, because
// only the resolver knows whether this host has a credential — a
// listener-level gate would have to duplicate that knowledge or refuse
// plain HTTP to credential-less hosts too, which the contract routes to
// the allow list instead. It is also deliberately not a blanket rule for
// every credential-writing plugin: token-exchange injects short-lived
// tokens onto plaintext in-cluster calls by design.
//
// The code is the MOCA contract's identifier verbatim rather than the
// repo's usual `static-inject.<kebab>` form, so the egress conformance
// suite Cortex and moca-egress share matches it without a translation
// table — the same reasoning that freezes `x-authbridge-*` wire names.
if pctx.Direction == pipeline.Outbound && strings.EqualFold(pctx.Scheme, "http") {
pctx.Record(pipeline.Invocation{
Action: pipeline.ActionDeny,
Reason: "plaintext_credential",
Details: map[string]string{
"scheme": pctx.Scheme,
// The resolver key, not the host: under key_by: host it is
// pctx.Host (host:port), under key_by: static the configured
// key — which is not a host at all.
"key": key,
},
})
return pipeline.DenyStatus(403, "plaintext_credential",
"refusing to send a credential over plain HTTP")
}

target := p.cfg.InjectHeader
var headerVal string
if strings.EqualFold(target, "Authorization") {
Expand Down
Loading
Loading