Skip to content

Commit b464823

Browse files
committed
fix: escape literal percent in error-page local reply body
The extension server injects the branded error page into Envoy's local_reply_config as a SubstitutionFormatString (TextFormatSource), so Envoy parses the body as a format template. Any bare '%' in the HTML is read as the start of a command operator; the embedded default page's CSS (e.g. "height: 100%") is not escaped, so Envoy rejects the whole listener ("Couldn't find valid command at position 330"). On the failOpen:false downstream hook that NACKs the xDS update fleet-wide, taking every route with a TrafficProtectionPolicy in Enforce mode offline. Escape bare '%' to '%%' at inject time while preserving already-escaped '%%' and valid '%COMMAND%' operators (such as the intended %RESPONSE_CODE%). The transform is idempotent and also protects any mounted override page. Fixes #243 Key changes: - add escapeEnvoyFormatLiterals and apply it to the local reply body - unit tests for the escaper and a regression guard asserting the embedded default page is Envoy-safe after escaping
1 parent 7419999 commit b464823

2 files changed

Lines changed: 99 additions & 1 deletion

File tree

internal/extensionserver/mutate/localreply.go

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

33
import (
44
"fmt"
5+
"regexp"
6+
"strings"
57

68
accesslogv3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3"
79
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
@@ -146,10 +148,51 @@ func buildLocalReplyConfig(cfg *LocalReplyConfig) *hcmv3.LocalReplyConfig {
146148
ContentType: cfg.ContentType,
147149
Format: &corev3.SubstitutionFormatString_TextFormatSource{
148150
TextFormatSource: &corev3.DataSource{
149-
Specifier: &corev3.DataSource_InlineString{InlineString: cfg.BodyHTML},
151+
Specifier: &corev3.DataSource_InlineString{InlineString: escapeEnvoyFormatLiterals(cfg.BodyHTML)},
150152
},
151153
},
152154
},
153155
}},
154156
}
155157
}
158+
159+
// envoyCommandToken matches a single well-formed Envoy substitution command
160+
// operator (e.g. %RESPONSE_CODE%, %REQ(x-header)%, %REQ(x-header):10%) anchored
161+
// at the start of the input. It is used to distinguish intended command
162+
// operators from literal percent signs.
163+
var envoyCommandToken = regexp.MustCompile(`^%[A-Z0-9_]+(\([^)]*\))?(:[0-9]+)?%`)
164+
165+
// escapeEnvoyFormatLiterals makes an arbitrary string safe to embed as the
166+
// text_format of an Envoy SubstitutionFormatString. Envoy parses that string as
167+
// a format template, so any bare '%' is read as the start of a command operator
168+
// and an unrecognized one is rejected — which, on the failOpen:false downstream
169+
// hook, NACKs the whole listener xDS update fleet-wide (see issue #243: the
170+
// branded error page's CSS "height: 100%" took the listener down).
171+
//
172+
// Bare '%' are escaped to '%%' (Envoy's literal-percent escape) while
173+
// already-escaped '%%' and valid '%COMMAND%' operators (such as the intended
174+
// %RESPONSE_CODE%) are preserved unchanged. The transform is idempotent.
175+
func escapeEnvoyFormatLiterals(s string) string {
176+
var b strings.Builder
177+
b.Grow(len(s))
178+
for i := 0; i < len(s); {
179+
if s[i] != '%' {
180+
b.WriteByte(s[i])
181+
i++
182+
continue
183+
}
184+
if i+1 < len(s) && s[i+1] == '%' {
185+
b.WriteString("%%")
186+
i += 2
187+
continue
188+
}
189+
if m := envoyCommandToken.FindString(s[i:]); m != "" {
190+
b.WriteString(m)
191+
i += len(m)
192+
continue
193+
}
194+
b.WriteString("%%")
195+
i++
196+
}
197+
return b.String()
198+
}

internal/extensionserver/mutate/localreply_test.go

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

33
import (
4+
"regexp"
5+
"strings"
46
"testing"
57

68
"github.com/stretchr/testify/assert"
@@ -11,6 +13,8 @@ import (
1113
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
1214
hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
1315
"google.golang.org/protobuf/types/known/anypb"
16+
17+
"go.datum.net/network-services-operator/internal/extensionserver/assets"
1418
)
1519

1620
const (
@@ -218,3 +222,54 @@ func TestInjectLocalReplyConfig_NoOp(t *testing.T) {
218222
})
219223
}
220224
}
225+
226+
func TestEscapeEnvoyFormatLiterals(t *testing.T) {
227+
tests := []struct {
228+
name string
229+
in string
230+
want string
231+
}{
232+
{"empty", "", ""},
233+
{"no percent", "<html>plain</html>", "<html>plain</html>"},
234+
{"bare percent in css", "height: 100%;", "height: 100%%;"},
235+
{"multiple bare percents", "120% 50% 0%", "120%% 50%% 0%%"},
236+
{"preserve command", "code %RESPONSE_CODE% end", "code %RESPONSE_CODE% end"},
237+
{"preserve command with args", "%REQ(x-header)%", "%REQ(x-header)%"},
238+
{"preserve command with length", "%REQ(x-header):10%", "%REQ(x-header):10%"},
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+
// No bare '%' may remain: every '%' is either part of a '%%' pair or a
268+
// valid command token. Strip the escaped pairs first, then the command
269+
// tokens (with a non-anchored copy of the operator pattern), and assert
270+
// nothing is left.
271+
commandToken := regexp.MustCompile(`%[A-Z0-9_]+(\([^)]*\))?(:[0-9]+)?%`)
272+
residual := strings.ReplaceAll(escaped, "%%", "")
273+
residual = commandToken.ReplaceAllString(residual, "")
274+
assert.NotContains(t, residual, "%", "no bare percent may remain after escaping")
275+
}

0 commit comments

Comments
 (0)