Skip to content

fix: make error-page body Envoy-safe via operator allowlist + startup validation (#243)#244

Open
ecv wants to merge 2 commits into
mainfrom
fix/issue-243-escape-error-page-percent
Open

fix: make error-page body Envoy-safe via operator allowlist + startup validation (#243)#244
ecv wants to merge 2 commits into
mainfrom
fix/issue-243-escape-error-page-percent

Conversation

@ecv

@ecv ecv commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What

Fixes #243. A TrafficProtectionPolicy in Enforce mode takes the whole gateway offline (100% of traffic fails) whenever the branded error page reaches Envoy with a % it can't parse.

Root cause

The extension server injects the branded page into Envoy's local_reply_config as a SubstitutionFormatString (TextFormatSource), so Envoy parses the body as a format template. Any bare % is read as the start of a command operator. The embedded default page's CSS (height: 100%, gradient stops, border-radius: 50%) is unescaped, so Envoy rejects the listener:

Error adding/updating listener(s) tcp-80: Incorrect configuration: <!DOCTYPE html>
  ... Couldn't find valid command at position 330

With the downstream EG hook on failOpen: false, the rejected update is NACKed fleet-wide — Envoy ends up with no tcp-80 listener and every request gets an empty reply.

Fix

Two layers, matching both remediations called out in #243:

  1. Escape at inject time, against an allowlist. Bare %%% while preserving already-escaped %% and the operators we actually template. The allowlist (envoyBodyAllowedCommands, currently just %RESPONSE_CODE%) is deliberately not a grammar match: any other command-shaped token (e.g. a literal %COMPLETE% in a mounted override page) is not a valid Envoy operator, so it is treated as a literal and escaped rather than passed through to re-trigger the same listener rejection. Idempotent; protects the embedded default and any override page.

  2. Validate at startup. ValidateLocalReplyConfig round-trips the assembled local_reply_config and asserts the injected body carries no bare %, so a malformed body is caught in the operator process instead of NACKing the xDS update fleet-wide in the data plane. It is fail-safe: a failing override falls back to the embedded default, and a failing default disables branding rather than stalling the data plane — the extension server never fails startup on a content problem.

Adds unit tests for the escaper (allowlist preserve/escape, idempotency), a regression guard asserting the embedded default page is Envoy-safe after escaping, and tests for the startup validator.

Proof (Chainsaw e2e on a local WAF-enabled kind harness)

The e2e test is in #245. Same fresh-fleet harness, only the operator image differs:

Manual confirmation on the same stack: benign GET / → 200 with backend body; an XSS request → 403 (WAF still enforces).

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
The percent escaper preserved any command-shaped token (%NAME%, %REQ(...)%),
but only %RESPONSE_CODE% is ever templated into the body. A command-shaped
literal in an override page (e.g. "%COMPLETE%") is not a valid Envoy operator
and, passed through unescaped, re-triggers the exact listener rejection this
escaper exists to prevent (issue #243).

Replace the grammar match with an allowlist of operators we actually emit, so
every other %...% token is treated as a literal and escaped. Add a startup
validation that round-trips the assembled local_reply_config and asserts the
injected body carries no bare '%', catching a malformed body in the operator
process instead of NACKing the xDS update fleet-wide on the failOpen:false
downstream hook. A failed override falls back to the embedded default; a failed
default disables branding rather than stalling the data plane.

Key changes:
- replace envoyCommandToken regex with envoyBodyAllowedCommands allowlist
- add assertEnvoyFormatSafe and ValidateLocalReplyConfig
- validate at extension-server startup with fail-safe fallback
- update escaper tests for allowlist behavior; add validation tests
@ecv ecv changed the title fix: escape literal percent in error-page local reply body (#243) fix: make error-page body Envoy-safe via operator allowlist + startup validation (#243) Jul 10, 2026
@ecv ecv requested a review from scotwells July 10, 2026 02:21
@ecv ecv marked this pull request as ready for review July 10, 2026 02:21
@ecv ecv enabled auto-merge July 10, 2026 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

extension-server: branded error page injected as Envoy SubstitutionFormatString → unescaped % rejects the listener (breaks TPP Enforce)

1 participant