Skip to content

Commit c7a9f42

Browse files
authored
Merge branch 'main' into feat/envoy-billing-pipeline
2 parents b22828e + e159acf commit c7a9f42

6 files changed

Lines changed: 407 additions & 3 deletions

File tree

internal/controller/trafficprotectionpolicy_controller.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"encoding/json"
88
"fmt"
9+
"slices"
910
"sort"
1011
"strings"
1112

@@ -1229,7 +1230,7 @@ func (r *TrafficProtectionPolicyReconciler) getCorazaDirectivesForTrafficProtect
12291230
secRuleEngine = "Off"
12301231
}
12311232

1232-
directives := r.Config.Gateway.Coraza.RouteBaseDirectives
1233+
directives := slices.Clone(r.Config.Gateway.Coraza.RouteBaseDirectives)
12331234

12341235
directives = append(directives, fmt.Sprintf("SecRuleEngine %s", secRuleEngine))
12351236

internal/controller/trafficprotectionpolicy_controller_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,36 @@ func TestGetCorazaDirectivesForTrafficProtectionPolicy(t *testing.T) {
992992
}
993993
}
994994

995+
func TestGetCorazaDirectivesDoesNotAliasRouteBaseDirectives(t *testing.T) {
996+
base := make([]string, 2, 8)
997+
base[0] = "Include @crs-setup-conf"
998+
base[1] = "Include @recommended-conf"
999+
1000+
operatorConfig := config.NetworkServicesOperator{
1001+
Gateway: config.GatewayConfig{
1002+
Coraza: config.CorazaConfig{RouteBaseDirectives: base},
1003+
},
1004+
}
1005+
reconciler := &TrafficProtectionPolicyReconciler{Config: operatorConfig}
1006+
1007+
enforce := reconciler.getCorazaDirectivesForTrafficProtectionPolicy(&policyContext{ptr.To(
1008+
newTrafficProtectionPolicy("default", "tpp-enforce", func(tpp *networkingv1alpha.TrafficProtectionPolicy) {
1009+
tpp.Spec.Mode = networkingv1alpha.TrafficProtectionPolicyEnforce
1010+
}),
1011+
)})
1012+
assert.Contains(t, enforce, "SecRuleEngine On")
1013+
1014+
reconciler.getCorazaDirectivesForTrafficProtectionPolicy(&policyContext{ptr.To(
1015+
newTrafficProtectionPolicy("default", "tpp-observe"),
1016+
)})
1017+
1018+
assert.Contains(t, enforce, "SecRuleEngine On",
1019+
"second policy leaked into the first via a shared RouteBaseDirectives backing array")
1020+
assert.EqualValues(t, []string{"Include @crs-setup-conf", "Include @recommended-conf"},
1021+
reconciler.Config.Gateway.Coraza.RouteBaseDirectives,
1022+
"shared base directives were mutated")
1023+
}
1024+
9951025
// nolint:unparam
9961026
func newTrafficProtectionPolicy(
9971027
namespace,

internal/extensionserver/cmd/run.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,33 @@ func buildLocalReplyConfig(cfg config.ErrorPageConfig, log *slog.Logger) mutate.
142142
}
143143
}
144144

145-
return mutate.LocalReplyConfig{
145+
lrConfig := mutate.LocalReplyConfig{
146146
Disabled: !cfg.Enabled,
147147
MinStatusCode: cfg.MinStatusCode,
148148
RuntimeKey: cfg.RuntimeKey,
149149
BodyHTML: body,
150150
ContentType: cfg.ContentType,
151151
}
152+
153+
// Validate before the config can reach the data plane. Escaping already makes
154+
// the body Envoy-safe, so this is defense-in-depth: if the escaper or its
155+
// allowlist ever regresses, fail loud here and fall back to a known-safe body
156+
// instead of NACKing the xDS update fleet-wide on the failOpen:false hook.
157+
if err := mutate.ValidateLocalReplyConfig(&lrConfig); err != nil {
158+
if body != extassets.DefaultError5xxHTML {
159+
log.Error("error-page body override failed validation; using embedded default",
160+
"path", cfg.BodyPath, "err", err)
161+
lrConfig.BodyHTML = extassets.DefaultError5xxHTML
162+
err = mutate.ValidateLocalReplyConfig(&lrConfig)
163+
}
164+
if err != nil {
165+
log.Error("embedded default error page failed validation; disabling branded error page",
166+
"err", err)
167+
lrConfig.Disabled = true
168+
}
169+
}
170+
171+
return lrConfig
152172
}
153173

154174
// run starts the extension server with the given options. It owns cache startup,

internal/extensionserver/mutate/localreply.go

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mutate
22

33
import (
44
"fmt"
5+
"strings"
56

67
accesslogv3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3"
78
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
@@ -146,10 +147,114 @@ func buildLocalReplyConfig(cfg *LocalReplyConfig) *hcmv3.LocalReplyConfig {
146147
ContentType: cfg.ContentType,
147148
Format: &corev3.SubstitutionFormatString_TextFormatSource{
148149
TextFormatSource: &corev3.DataSource{
149-
Specifier: &corev3.DataSource_InlineString{InlineString: cfg.BodyHTML},
150+
Specifier: &corev3.DataSource_InlineString{InlineString: escapeEnvoyFormatLiterals(cfg.BodyHTML)},
150151
},
151152
},
152153
},
153154
}},
154155
}
155156
}
157+
158+
// envoyBodyAllowedCommands is the allowlist of Envoy substitution command
159+
// operators permitted to pass through unescaped in an error-page body. It is an
160+
// allowlist, not a grammar match: only operators we actually template are
161+
// preserved, because any *other* command-shaped token (e.g. a literal
162+
// "%COMPLETE%" in an override page) is not a valid Envoy operator and would
163+
// re-trigger the exact listener rejection this escaper exists to prevent
164+
// (issue #243). Keep entries longest-first so matching is unambiguous.
165+
var envoyBodyAllowedCommands = []string{
166+
"%RESPONSE_CODE%",
167+
}
168+
169+
// matchAllowedCommand returns the allowlisted command operator that prefixes s,
170+
// or "" if none does.
171+
func matchAllowedCommand(s string) string {
172+
for _, cmd := range envoyBodyAllowedCommands {
173+
if strings.HasPrefix(s, cmd) {
174+
return cmd
175+
}
176+
}
177+
return ""
178+
}
179+
180+
// escapeEnvoyFormatLiterals makes an arbitrary string safe to embed as the
181+
// text_format of an Envoy SubstitutionFormatString. Envoy parses that string as
182+
// a format template, so any bare '%' is read as the start of a command operator
183+
// and an unrecognized one is rejected — which, on the failOpen:false downstream
184+
// hook, NACKs the whole listener xDS update fleet-wide (see issue #243: the
185+
// branded error page's CSS "height: 100%" took the listener down).
186+
//
187+
// Bare '%' are escaped to '%%' (Envoy's literal-percent escape) while
188+
// already-escaped '%%' and allowlisted operators (envoyBodyAllowedCommands,
189+
// e.g. the intended %RESPONSE_CODE%) are preserved unchanged. Every other
190+
// command-shaped token is treated as a literal and escaped, so an unrecognized
191+
// operator can never survive into the pushed config. The transform is
192+
// idempotent.
193+
func escapeEnvoyFormatLiterals(s string) string {
194+
var b strings.Builder
195+
b.Grow(len(s))
196+
for i := 0; i < len(s); {
197+
if s[i] != '%' {
198+
b.WriteByte(s[i])
199+
i++
200+
continue
201+
}
202+
if i+1 < len(s) && s[i+1] == '%' {
203+
b.WriteString("%%")
204+
i += 2
205+
continue
206+
}
207+
if cmd := matchAllowedCommand(s[i:]); cmd != "" {
208+
b.WriteString(cmd)
209+
i += len(cmd)
210+
continue
211+
}
212+
b.WriteString("%%")
213+
i++
214+
}
215+
return b.String()
216+
}
217+
218+
// assertEnvoyFormatSafe returns an error if s still contains a bare '%' that
219+
// Envoy would misparse as an incomplete or unrecognized command operator. It is
220+
// the inverse invariant of escapeEnvoyFormatLiterals and backs the startup
221+
// validation in ValidateLocalReplyConfig.
222+
func assertEnvoyFormatSafe(s string) error {
223+
for i := 0; i < len(s); {
224+
if s[i] != '%' {
225+
i++
226+
continue
227+
}
228+
if i+1 < len(s) && s[i+1] == '%' {
229+
i += 2
230+
continue
231+
}
232+
if cmd := matchAllowedCommand(s[i:]); cmd != "" {
233+
i += len(cmd)
234+
continue
235+
}
236+
return fmt.Errorf("bare %% at byte offset %d", i)
237+
}
238+
return nil
239+
}
240+
241+
// ValidateLocalReplyConfig assembles the local_reply_config and asserts it is
242+
// safe to push, so a malformed body is caught in the operator process at startup
243+
// instead of NACKing the xDS update fleet-wide on the failOpen:false downstream
244+
// hook (issue #243). It round-trips the config through proto marshalling and
245+
// verifies the injected body carries no bare '%'. A disabled or empty config is
246+
// vacuously valid (injection is a no-op).
247+
func ValidateLocalReplyConfig(cfg *LocalReplyConfig) error {
248+
if cfg.Disabled || cfg.BodyHTML == "" {
249+
return nil
250+
}
251+
lrc := buildLocalReplyConfig(cfg)
252+
if _, err := anypb.New(lrc); err != nil {
253+
return fmt.Errorf("marshal local_reply_config: %w", err)
254+
}
255+
body := lrc.GetMappers()[0].GetBodyFormatOverride().GetTextFormatSource().GetInlineString()
256+
if err := assertEnvoyFormatSafe(body); err != nil {
257+
return fmt.Errorf("assembled error-page body is not Envoy-safe: %w", err)
258+
}
259+
return nil
260+
}

internal/extensionserver/mutate/localreply_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package mutate
22

33
import (
4+
"strings"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
@@ -11,6 +12,8 @@ import (
1112
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
1213
hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
1314
"google.golang.org/protobuf/types/known/anypb"
15+
16+
"go.datum.net/network-services-operator/internal/extensionserver/assets"
1417
)
1518

1619
const (
@@ -218,3 +221,84 @@ func TestInjectLocalReplyConfig_NoOp(t *testing.T) {
218221
})
219222
}
220223
}
224+
225+
func TestEscapeEnvoyFormatLiterals(t *testing.T) {
226+
tests := []struct {
227+
name string
228+
in string
229+
want string
230+
}{
231+
{"empty", "", ""},
232+
{"no percent", "<html>plain</html>", "<html>plain</html>"},
233+
{"bare percent in css", "height: 100%;", "height: 100%%;"},
234+
{"multiple bare percents", "120% 50% 0%", "120%% 50%% 0%%"},
235+
{"preserve allowlisted command", "code %RESPONSE_CODE% end", "code %RESPONSE_CODE% end"},
236+
{"escape non-allowlisted command", "%REQ(x-header)%", "%%REQ(x-header)%%"},
237+
{"escape non-allowlisted command with length", "%REQ(x-header):10%", "%%REQ(x-header):10%%"},
238+
{"escape command-shaped literal", "progress %COMPLETE% now", "progress %%COMPLETE%% now"},
239+
{"already escaped stays escaped", "width: 100%%;", "width: 100%%;"},
240+
{"mixed command and literal", "%RESPONSE_CODE% at 100%", "%RESPONSE_CODE% at 100%%"},
241+
{"trailing bare percent", "50%", "50%%"},
242+
}
243+
for _, tc := range tests {
244+
t.Run(tc.name, func(t *testing.T) {
245+
got := escapeEnvoyFormatLiterals(tc.in)
246+
assert.Equal(t, tc.want, got)
247+
assert.Equal(t, got, escapeEnvoyFormatLiterals(got), "must be idempotent")
248+
})
249+
}
250+
}
251+
252+
// TestEmbeddedDefaultPageIsEnvoySafe is the regression guard for issue #243:
253+
// the embedded default error page contains literal CSS percent signs
254+
// (e.g. "height: 100%") that Envoy would misparse as command operators and
255+
// reject the listener. After escaping, no bare '%' may remain and the intended
256+
// %RESPONSE_CODE% operator must survive.
257+
func TestEmbeddedDefaultPageIsEnvoySafe(t *testing.T) {
258+
raw := assets.DefaultError5xxHTML
259+
require.Contains(t, raw, "height: 100%;", "test premise: raw page has an unescaped percent")
260+
261+
escaped := escapeEnvoyFormatLiterals(raw)
262+
263+
assert.Contains(t, escaped, "height: 100%%;", "literal percent must be escaped")
264+
assert.Equal(t, 1, strings.Count(escaped, "%RESPONSE_CODE%"), "command operator must be preserved exactly once")
265+
assert.Equal(t, escaped, escapeEnvoyFormatLiterals(escaped), "escaping must be idempotent")
266+
267+
// The escaped body must satisfy the same invariant the startup validator
268+
// asserts: no bare '%' Envoy could misparse as a command operator.
269+
assert.NoError(t, assertEnvoyFormatSafe(escaped), "no bare percent may remain after escaping")
270+
}
271+
272+
// TestValidateLocalReplyConfig covers the startup guard (issue #243): the
273+
// assembled config must validate for the embedded default, reject a body whose
274+
// bare '%' would reach Envoy unescaped, and no-op when injection is disabled.
275+
func TestValidateLocalReplyConfig(t *testing.T) {
276+
base := testLocalReplyConfig()
277+
278+
t.Run("embedded default is valid", func(t *testing.T) {
279+
cfg := *base
280+
cfg.BodyHTML = assets.DefaultError5xxHTML
281+
assert.NoError(t, ValidateLocalReplyConfig(&cfg))
282+
})
283+
284+
t.Run("escaped body is valid", func(t *testing.T) {
285+
cfg := *base
286+
cfg.BodyHTML = "height: 100%;"
287+
assert.NoError(t, ValidateLocalReplyConfig(&cfg))
288+
})
289+
290+
t.Run("disabled is a no-op", func(t *testing.T) {
291+
cfg := *base
292+
cfg.Disabled = true
293+
cfg.BodyHTML = "height: 100%;"
294+
assert.NoError(t, ValidateLocalReplyConfig(&cfg))
295+
})
296+
297+
t.Run("bare percent surviving into the body is rejected", func(t *testing.T) {
298+
// buildLocalReplyConfig escapes, so this asserts the raw invariant the
299+
// validator enforces on the injected body.
300+
assert.Error(t, assertEnvoyFormatSafe("height: 100%;"))
301+
assert.Error(t, assertEnvoyFormatSafe("%COMPLETE%"))
302+
assert.NoError(t, assertEnvoyFormatSafe("code %RESPONSE_CODE% ok"))
303+
})
304+
}

0 commit comments

Comments
 (0)