Repository navigation
Fix: Refuse static-inject credentials over plain HTTP (403 plaintext_credential) #1537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
8ee39a3
fix: Refuse static-inject credentials over plain HTTP (403 plaintext_…
mrsabath 725f5ab
test: pin the extproc :scheme wiring for the plaintext gate
mrsabath 41f7c7f
Merge branch 'main' into fix/sbx-plaintext-credential
mrsabath a11bdf1
fix: gate the plaintext refusal on outbound only (#1481)
mrsabath 805529b
docs: name the backend hop as the inbound operator's job (#1481)
mrsabath File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
envoyprofile does not includestaticinject(scripts/profile-tags/profiles.go:79), so no shipped envoy-sidecar image runs this gate.extproc/plaintextgate_test.gopins 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".There was a problem hiding this comment.
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 noProfileEnvoy, so you are right that no shipped envoy-sidecar image runs this gate and thecomment 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.goas pinning the wiring against that changing — so the testkeeps its purpose without the comment implying the envoy shape is covered today.
Assisted-By: Claude (Anthropic AI) noreply@anthropic.com