-
Notifications
You must be signed in to change notification settings - Fork 40
Fix: Strip query string from pctx.Path in ext_proc and ext_authz #882
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| package extauthz | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" | ||
|
|
||
| "github.com/rossoctl/cortex/authbridge/authlib/pipeline" | ||
| "github.com/rossoctl/cortex/authbridge/authlib/plugins/plugintesting" | ||
| ) | ||
|
|
||
| // pathCapture records the pctx.Path each pipeline run sees, so tests can | ||
| // assert on what the listener actually constructed rather than on side | ||
| // effects of a real plugin. | ||
| type pathCapture struct { | ||
| paths []string | ||
| } | ||
|
|
||
| func (p *pathCapture) Name() string { return "path-capture" } | ||
| func (p *pathCapture) Capabilities() pipeline.PluginCapabilities { | ||
| return pipeline.PluginCapabilities{} | ||
| } | ||
| func (p *pathCapture) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { | ||
| p.paths = append(p.paths, pctx.Path) | ||
| return pipeline.Action{Type: pipeline.Continue} | ||
| } | ||
| func (p *pathCapture) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { | ||
| return pipeline.Action{Type: pipeline.Continue} | ||
| } | ||
|
|
||
| // Envoy's AttributeContext.HttpRequest.path is "the request target, as it | ||
| // appears in the first line of the HTTP request" — query string included. | ||
| // pctx.Path must contain only the URL path, exactly as the proxy listeners | ||
| // produce it from r.URL.Path (query dropped, percent-decoding applied), so | ||
| // plugin behavior keyed on Path cannot differ by listener mode. An | ||
| // unparseable target keeps the plain query-strip fallback. | ||
| func TestCheck_PathMatchesProxyListeners(t *testing.T) { | ||
| cases := []struct { | ||
| name string | ||
| target string | ||
| want string | ||
| }{ | ||
| {"query stripped", "/api/x?secret=1", "/api/x"}, | ||
| {"percent-decoded", "/api/hello%20world?secret=1&b=2", "/api/hello world"}, | ||
| {"unparseable falls back to query strip", "/a%zz?secret=1", "/a%zz"}, | ||
| } | ||
| for _, tc := range cases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| inCap, outCap := &pathCapture{}, &pathCapture{} | ||
| inbound, err := plugintesting.BuildPipeline([]pipeline.Plugin{inCap}) | ||
| if err != nil { | ||
| t.Fatalf("building inbound pipeline: %v", err) | ||
| } | ||
| outbound, err := plugintesting.BuildPipeline([]pipeline.Plugin{outCap}) | ||
| if err != nil { | ||
| t.Fatalf("building outbound pipeline: %v", err) | ||
| } | ||
| srv := &Server{ | ||
| InboundPipeline: pipeline.NewHolder(inbound), | ||
| OutboundPipeline: pipeline.NewHolder(outbound), | ||
| } | ||
|
|
||
| req := &authv3.CheckRequest{ | ||
| Attributes: &authv3.AttributeContext{ | ||
| Request: &authv3.AttributeContext_Request{ | ||
| Http: &authv3.AttributeContext_HttpRequest{ | ||
| Headers: map[string]string{":authority": "target-svc"}, | ||
| Path: tc.target, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| if _, err := srv.Check(context.Background(), req); err != nil { | ||
| t.Fatalf("Check: %v", err) | ||
| } | ||
|
|
||
| if len(inCap.paths) != 1 || inCap.paths[0] != tc.want { | ||
| t.Errorf("inbound pctx.Path = %q, want [%q]", inCap.paths, tc.want) | ||
| } | ||
| if len(outCap.paths) != 1 || outCap.paths[0] != tc.want { | ||
| t.Errorf("outbound pctx.Path = %q, want [%q]", outCap.paths, tc.want) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| package extproc | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" | ||
|
|
||
| "github.com/rossoctl/cortex/authbridge/authlib/pipeline" | ||
| "github.com/rossoctl/cortex/authbridge/authlib/plugins/plugintesting" | ||
| ) | ||
|
|
||
| // pathCapture records the pctx.Path each pipeline run sees, so tests can | ||
| // assert on what the listener actually constructed rather than on side | ||
| // effects of a real plugin. | ||
| type pathCapture struct { | ||
| paths []string | ||
| } | ||
|
|
||
| func (p *pathCapture) Name() string { return "path-capture" } | ||
| func (p *pathCapture) Capabilities() pipeline.PluginCapabilities { | ||
| return pipeline.PluginCapabilities{} | ||
| } | ||
| func (p *pathCapture) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { | ||
| p.paths = append(p.paths, pctx.Path) | ||
| return pipeline.Action{Type: pipeline.Continue} | ||
| } | ||
| func (p *pathCapture) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { | ||
| return pipeline.Action{Type: pipeline.Continue} | ||
| } | ||
|
|
||
| func captureServer(t *testing.T) (*Server, *pathCapture, *pathCapture) { | ||
| t.Helper() | ||
| inCap, outCap := &pathCapture{}, &pathCapture{} | ||
| inbound, err := plugintesting.BuildPipeline([]pipeline.Plugin{inCap}) | ||
| if err != nil { | ||
| t.Fatalf("building inbound pipeline: %v", err) | ||
| } | ||
| outbound, err := plugintesting.BuildPipeline([]pipeline.Plugin{outCap}) | ||
| if err != nil { | ||
| t.Fatalf("building outbound pipeline: %v", err) | ||
| } | ||
| return &Server{ | ||
| InboundPipeline: pipeline.NewHolder(inbound), | ||
| OutboundPipeline: pipeline.NewHolder(outbound), | ||
| }, inCap, outCap | ||
| } | ||
|
|
||
| // The :path pseudo-header carries the full request target, query string | ||
| // included. pctx.Path must contain only the URL path, exactly as the | ||
| // forward and reverse proxy listeners produce it from r.URL.Path (query | ||
| // dropped, percent-decoding applied) — so plugin behavior keyed on Path | ||
| // cannot differ by listener mode. An unparseable target (which net/http | ||
| // would reject with 400 before any pipeline runs) keeps the plain | ||
| // query-strip fallback. | ||
| func TestExtProc_PathMatchesProxyListeners(t *testing.T) { | ||
| cases := []struct { | ||
| name string | ||
| target string | ||
| want string | ||
| }{ | ||
| {"query stripped", "/api/x?secret=1", "/api/x"}, | ||
| {"percent-decoded", "/api/hello%20world?secret=1&b=2", "/api/hello world"}, | ||
| {"unparseable falls back to query strip", "/a%zz?secret=1", "/a%zz"}, | ||
| } | ||
| for _, tc := range cases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| // One mock stream per request — each ext_proc stream carries a | ||
| // single request in production. | ||
| srv, inCap, outCap := captureServer(t) | ||
| inStream := &mockStream{ | ||
| ctx: context.Background(), | ||
| requests: []*extprocv3.ProcessingRequest{ | ||
| inboundRequest(makeHeaders( | ||
| "x-authbridge-direction", "inbound", | ||
| ":path", tc.target, | ||
| )), | ||
| }, | ||
| } | ||
| _ = srv.Process(inStream) | ||
| outStream := &mockStream{ | ||
| ctx: context.Background(), | ||
| requests: []*extprocv3.ProcessingRequest{ | ||
| outboundRequest(makeHeaders( | ||
| ":authority", "target-svc", | ||
| ":path", tc.target, | ||
| )), | ||
| }, | ||
| } | ||
| _ = srv.Process(outStream) | ||
|
|
||
| if len(inCap.paths) != 1 || inCap.paths[0] != tc.want { | ||
| t.Errorf("inbound pctx.Path = %q, want [%q]", inCap.paths, tc.want) | ||
| } | ||
| if len(outCap.paths) != 1 || outCap.paths[0] != tc.want { | ||
| t.Errorf("outbound pctx.Path = %q, want [%q]", outCap.paths, tc.want) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package httpx | ||
|
|
||
| import ( | ||
| "net/url" | ||
| "strings" | ||
| ) | ||
|
|
||
| // PathOnly extracts the URL path from a raw request target. The Envoy-fed | ||
| // listeners (ext_proc's :path pseudo-header, ext_authz's | ||
| // AttributeContext.HttpRequest.path) receive the full request target, query | ||
| // string included, but pctx.Path must hold only the path — see | ||
| // pipeline.Context.Path. It runs the same parser net/http runs for the | ||
| // proxy listeners, so pctx.Path is identical across listener modes | ||
| // (percent-decoding included), modulo targets that parser rejects: net/http | ||
| // answers those with 400 before any pipeline runs, while the Envoy-fed | ||
| // listeners fall back to a plain query strip. | ||
| func PathOnly(target string) string { | ||
| u, err := url.ParseRequestURI(target) | ||
| if err != nil { | ||
| if i := strings.IndexByte(target, '?'); i >= 0 { | ||
| return target[:i] | ||
| } | ||
| return target | ||
| } | ||
| return u.Path | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -104,9 +104,20 @@ type Context struct { | |
| // fixtures, an unrecognized transport, etc.). Plugins that need a | ||
| // concrete scheme should pick a default explicitly — treating "" | ||
| // as "assume http" would silently mask missing listener plumbing. | ||
| Scheme string | ||
| Host string | ||
| Path string | ||
| Scheme string | ||
| Host string | ||
|
|
||
| // Path is the URL path of the request, never including a query | ||
| // string: the proxy listeners populate it from r.URL.Path, and | ||
| // ext_proc / ext_authz run the raw request target through the same | ||
| // URL parser (httpx.PathOnly → url.ParseRequestURI), so the value | ||
| // is identical across listener modes — modulo unparseable targets, | ||
| // which net/http rejects with 400 before any pipeline runs and the | ||
| // Envoy-fed listeners keep query-stripped but otherwise raw. | ||
| // Plugins may match, log, or feed Path into policy without | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit — this doc is the right place for the invariant, and it is the one place that does not say the value is percent-decoded. The mechanism is stated ( One clause carries it: // Path is the URL path of the request, percent-decoded and never
// including a query string: the proxy listeners populate it from
// r.URL.Path, and ext_proc / ext_authz run the raw request target
// through the same URL parser …Worth a second clause on the consequence, since this is an auth-relevant surface: something like "so a pattern is matched against the decoded path — While here: the fallback description — "the Envoy-fed listeners keep query-stripped but otherwise raw" — is accurate and worth keeping, but it is the one branch where |
||
| // stripping a query themselves. | ||
| Path string | ||
|
|
||
| Headers http.Header | ||
| Body []byte // nil unless at least one plugin declares BodyAccess: true | ||
|
|
||
|
|
||
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.
suggestion — this is the shared helper both fixed listeners now depend on, and it is the one new thing here without a direct test. Its sibling in this same package has one (
render.go→render_test.go), so the convention is already established.The two listener tests are good, but they are integration tests that happen to exercise
PathOnlythrough a pipeline, and they pin the same three cases in both files. That leaves behaviors this function actually implements untested anywhere:PathOnlyhttp://host/path?q=1/pathpctx.Path. Nothing pins the fix/foo%2Fbar/foo/barbypass_pathsnow matches on**OPTIONS *. Worth pinning that it is passed through rather than mangled""""/a%zz/x?/xIndexBytefallbackI verified every row against
http.ReadRequest(…).URL.Pathon the same wire bytes: all five match exactly, so the table is ready to assert as-is and doubles as executable documentation of the parity claim your doc comment makes. That claim is currently asserted only in prose, and prose does not fail CI when someone "simplifies"url.ParseRequestURIinto astrings.Cut.Second, smaller point:
pathCaptureis byte-identical inextproc/server_path_test.goandextauthz/server_path_test.go— same five methods, same comment. Both files already importplugins/plugintesting, so it could live there as one exported helper and serve the next listener test too. Not worth a round trip on its own, but if you are touching these files for the table above it is free.