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
2 changes: 1 addition & 1 deletion Taskfile.test-infra.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1100,7 +1100,7 @@ tasks:
vars:
# Scenarios authored against this env's cluster names and downstream path.
# Older fixtures targeting the previous cluster names are excluded here.
DEFAULT_SCENARIOS: extension-server-smoke waf-enforcement waf-section-scoping branded-error-page connector-offline-503 atomic-reject-isolation oidc-missing-secret-isolation networkservice-endpoints
DEFAULT_SCENARIOS: extension-server-smoke waf-enforcement waf-section-scoping branded-error-page connector-offline-503 atomic-reject-isolation oidc-missing-secret-isolation networkservice-endpoints misdirected-request
# CLI_ARGS (after --) wins; else SCENARIOS env; else the default set.
SELECTED: '{{.CLI_ARGS | default .SCENARIOS | default .DEFAULT_SCENARIOS}}'
deps:
Expand Down
9 changes: 9 additions & 0 deletions internal/extensionserver/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ var (
},
)

// MisdirectedGuardsTotal counts route configurations given the 421
// Misdirected Request catch-all, across all hook invocations.
MisdirectedGuardsTotal = promauto.NewCounter(
prometheus.CounterOpts{
Name: "nso_extension_misdirected_guards_total",
Help: "Total route configurations given the 421 Misdirected Request catch-all across all hook invocations.",
},
)

// WAFRouteMutationsTotal counts total per-route TrafficProtectionPolicy (WAF)
// config applications across all hook invocations.
WAFRouteMutationsTotal = promauto.NewCounter(
Expand Down
90 changes: 90 additions & 0 deletions internal/extensionserver/mutate/misdirected.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package mutate

import (
"slices"

listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
"k8s.io/apimachinery/pkg/util/sets"
)

const (
misdirectedVirtualHostName = "datum-misdirected-request"
misdirectedStatus = 421
)

// GuardMisdirectedRequests answers 421 instead of 404 when a browser reuses one
// site's connection for another, so it retries on a connection of its own.
func GuardMisdirectedRequests(listeners []*listenerv3.Listener, routes []*routev3.RouteConfiguration) int {
secure, plaintext := sets.New[string](), sets.New[string]()
for _, l := range listeners {
chains := append([]*listenerv3.FilterChain{}, l.GetFilterChains()...)
if dfc := l.GetDefaultFilterChain(); dfc != nil {
chains = append(chains, dfc)
}
for _, fc := range chains {
for _, name := range rdsRouteConfigNames(fc) {
if fc.GetTransportSocket() != nil {
secure.Insert(name)
} else {
plaintext.Insert(name)
}
}
}
}

guarded := 0
for _, rc := range routes {
name := rc.GetName()
// A second "*" domain would make Envoy reject the whole update.
if !secure.Has(name) || plaintext.Has(name) || hasCatchAllDomain(rc) {
continue
}
rc.VirtualHosts = append(rc.VirtualHosts, misdirectedVirtualHost())
guarded++
}
return guarded
}

func rdsRouteConfigNames(fc *listenerv3.FilterChain) []string {
var names []string
for _, f := range fc.GetFilters() {
if f.GetName() != hcmFilterName || f.GetTypedConfig() == nil {
continue
}
hcm := &hcmv3.HttpConnectionManager{}
if err := f.GetTypedConfig().UnmarshalTo(hcm); err != nil {
continue
}
if name := hcm.GetRds().GetRouteConfigName(); name != "" {
names = append(names, name)
}
}
return names
}

func hasCatchAllDomain(rc *routev3.RouteConfiguration) bool {
for _, vh := range rc.GetVirtualHosts() {
if slices.Contains(vh.GetDomains(), "*") {
return true
}
}
return false
}

func misdirectedVirtualHost() *routev3.VirtualHost {
return &routev3.VirtualHost{
Name: misdirectedVirtualHostName,
Domains: []string{"*"},
Routes: []*routev3.Route{{
Name: misdirectedVirtualHostName,
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_Prefix{Prefix: "/"},
},
Action: &routev3.Route_DirectResponse{
DirectResponse: &routev3.DirectResponseAction{Status: misdirectedStatus},
},
}},
}
}
114 changes: 114 additions & 0 deletions internal/extensionserver/mutate/misdirected_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package mutate

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/wrapperspb"
)

// withRDS gives the chain an HCM that takes its routes from the named route
// configuration.
func withRDS(t *testing.T, fc *listenerv3.FilterChain, routeConfig string) *listenerv3.FilterChain {
t.Helper()
hcmAny, err := anypb.New(&hcmv3.HttpConnectionManager{
StatPrefix: routeConfig,
RouteSpecifier: &hcmv3.HttpConnectionManager_Rds{Rds: &hcmv3.Rds{RouteConfigName: routeConfig}},
})
require.NoError(t, err)
fc.Filters = append(fc.Filters, &listenerv3.Filter{
Name: hcmFilterName,
ConfigType: &listenerv3.Filter_TypedConfig{TypedConfig: hcmAny},
})
return fc
}

func siteRouteConfig(name, host string) *routev3.RouteConfiguration {
return &routev3.RouteConfiguration{
Name: name,
VirtualHosts: []*routev3.VirtualHost{{Name: host, Domains: []string{host}}},
}
}

func findVirtualHost(rc *routev3.RouteConfiguration, name string) *routev3.VirtualHost {
for _, vh := range rc.GetVirtualHosts() {
if vh.GetName() == name {
return vh
}
}
return nil
}

func TestGuardMisdirectedRequests(t *testing.T) {
quicChain := withRDS(t, &listenerv3.FilterChain{
Name: "quic-b",
TransportSocket: &corev3.TransportSocket{Name: "envoy.transport_sockets.quic"},
}, "https-b")

listeners := []*listenerv3.Listener{
{
Name: "tcp-443",
FilterChains: []*listenerv3.FilterChain{
withRDS(t, tlsChain(t, "a.example.net", "secret-a"), "https-a"),
withRDS(t, tlsChain(t, "shared.example.net", "secret-shared"), "shared"),
withRDS(t, tlsChain(t, "any.example.net", "secret-any"), "has-catch-all"),
},
},
{Name: "udp-443", FilterChains: []*listenerv3.FilterChain{quicChain}},
{
Name: "tcp-80",
FilterChains: []*listenerv3.FilterChain{withRDS(t, plainChain("shared-plain"), "shared")},
DefaultFilterChain: withRDS(t, plainChain("http-80"), "http-80"),
},
}

catchAll := siteRouteConfig("has-catch-all", "any.example.net")
catchAll.VirtualHosts[0].Domains = []string{"*"}
routes := []*routev3.RouteConfiguration{
siteRouteConfig("https-a", "a.example.net"),
siteRouteConfig("https-b", "b.example.net"),
siteRouteConfig("shared", "shared.example.net"),
catchAll,
siteRouteConfig("http-80", "plain.example.net"),
}

require.Equal(t, 2, GuardMisdirectedRequests(listeners, routes))

for _, rc := range routes[:2] {
vh := findVirtualHost(rc, misdirectedVirtualHostName)
require.NotNil(t, vh, "%s is only reached over TLS or QUIC and must answer 421", rc.GetName())
assert.Equal(t, []string{"*"}, vh.GetDomains())
assert.EqualValues(t, misdirectedStatus, vh.GetRoutes()[0].GetDirectResponse().GetStatus())
assert.Equal(t, rc.GetVirtualHosts()[0].GetName(), rc.GetVirtualHosts()[0].GetDomains()[0],
"the site's own virtual host stays first and unchanged")
}
for _, rc := range routes[2:] {
assert.Nil(t, findVirtualHost(rc, misdirectedVirtualHostName),
"%s is reached in plaintext or already has a catch-all and must be left alone", rc.GetName())
}

assert.Zero(t, GuardMisdirectedRequests(listeners, routes),
"a second pass must not add a duplicate \"*\" domain")
}

func TestGuardMisdirectedRequestsSkipsUnreadableHCM(t *testing.T) {
notAnHCM, err := anypb.New(wrapperspb.String("not an HCM"))
require.NoError(t, err)
fc := tlsChain(t, "a.example.net", "secret-a")
fc.Filters = []*listenerv3.Filter{{
Name: hcmFilterName,
ConfigType: &listenerv3.Filter_TypedConfig{TypedConfig: notAnHCM},
}}
listeners := []*listenerv3.Listener{{Name: "tcp-443", FilterChains: []*listenerv3.FilterChain{fc}}}
routes := []*routev3.RouteConfiguration{siteRouteConfig("https-a", "a.example.net")}

assert.Zero(t, GuardMisdirectedRequests(listeners, routes))
assert.Len(t, routes[0].GetVirtualHosts(), 1)
}
7 changes: 7 additions & 0 deletions internal/extensionserver/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const ProgrammedSetEndpointPath = programmedSetEndpointPath
// 3. ReplaceConnectorClusters — replace online-connector clusters with
// STATIC internal-upstream clusters.
// 4. ApplyConnectorRoutes — prepend CONNECT routes, append target domains.
// 5. GuardMisdirectedRequests — answer 421 for requests on another site's connection.
func (s *Server) PostTranslateModify(
ctx context.Context,
req *pb.PostTranslateModifyRequest,
Expand Down Expand Up @@ -299,6 +300,11 @@ func (s *Server) PostTranslateModify(
)
connRoutesSpan.End()

_, misdirectedSpan := tr.Start(mctx, "misdirected.routes")
misdirectedCount := mutate.GuardMisdirectedRequests(listeners, routes)
misdirectedSpan.SetAttributes(attribute.Int("route_configs.misdirected_guarded", misdirectedCount))
misdirectedSpan.End()

// --- VPC pod family (#856) ---
// Binds a vpcPod backend's cluster to its tenant's VRF device
// (SO_BINDTODEVICE) so the shared multi-tenant Envoy fleet resolves the
Expand Down Expand Up @@ -374,6 +380,7 @@ func (s *Server) PostTranslateModify(
// rate() in Prometheus / PromQL to derive per-build averages.
extmetrics.WAFHCMMutationsTotal.Add(float64(hcmCount))
extmetrics.LocalReplyMutationsTotal.Add(float64(localReplyCount))
extmetrics.MisdirectedGuardsTotal.Add(float64(misdirectedCount))
extmetrics.WAFRouteMutationsTotal.Add(float64(tppCount))
extmetrics.ConnectorClustersTotal.Add(float64(len(replaced)))
extmetrics.ConnectorRoutesTotal.Add(float64(vhCount))
Expand Down
10 changes: 9 additions & 1 deletion test/e2e-edge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ upgrade gate: it is green on a fail-safe version (v1.7.x, v1.9.2) and flips red
EG runs v1.8.x while the upstream bug is unfixed, so "green" never gets misread as
"the upstream bug is fixed".

### A site never answers for another site's connection
Hostnames on the edge share an IP and a certificate, so a browser reuses one
site's connection for another. A request that arrives on the wrong site's
connection must be answered 421, which tells the browser to retry on a
connection of its own, not 404. The test negotiates TLS for one site, asks for
another, and expects 421, while each site still serves 200 on its own and an
unknown hostname over plain HTTP still gets 404.

## Running them

The scenarios run against the production-fidelity environment:
Expand All @@ -83,7 +91,7 @@ environment these assume.

- Scenario folders (`waf-enforcement/`, `connector-offline-503/`,
`branded-error-page/`, `atomic-reject-isolation/`,
`oidc-missing-secret-isolation/`) — one guarantee each.
`oidc-missing-secret-isolation/`, `misdirected-request/`) — one guarantee each.
- `_steps/` — shared, reusable checks (send-a-request, confirm-configuration,
capture-the-build-marker) so every scenario asserts behavior the same way.
- `_fixtures/` — the supporting pieces a scenario needs (a sample backend, an
Expand Down
Loading
Loading