Skip to content

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

Description

@ecv

Summary

The extension-server injects the branded error page into Envoy's local_reply_config as a SubstitutionFormatString / TextFormatSource. Envoy parses that string as a format string, so every literal % in the HTML is treated as a command operator. The embedded default page contains unescaped % characters, so Envoy rejects the entire listener — and with the downstream Envoy Gateway running failOpen: false, the rejected xDS update is dropped fleet-wide, leaving no listener. Result: 100% traffic failure on any TrafficProtectionPolicy in Enforce mode that falls back to the embedded page.

Discovered while reproducing #242 on a full local kind WAF stack.

Evidence

Envoy on the merged downstream gateway:

[warning][config] delta config for type.googleapis.com/envoy.config.listener.v3.Listener
  rejected: Error adding/updating listener(s) tcp-80: Incorrect configuration: <!DOCTYPE html>
  ...
  . Couldn't find valid command at position 330

/listeners then shows only the admin/stats listeners (no tcp-80), and clients get curl: (52) Empty reply from server. The backend is healthy (direct hit returns 200). Removing the TPP restores the listener.

Byte 330 of internal/extensionserver/assets/error-5xx-default.html is the % in:

html, body { height: 100%; margin: 0; }

The embedded page has 12 single % and zero %% escapes.

Root cause

internal/extensionserver/mutate/localreply.go (buildLocalReplyConfig):

BodyFormatOverride: &corev3.SubstitutionFormatString{
    ContentType: cfg.ContentType,
    Format: &corev3.SubstitutionFormatString_TextFormatSource{
        TextFormatSource: &corev3.DataSource{
            Specifier: &corev3.DataSource_InlineString{InlineString: cfg.BodyHTML},
        },
    },
},

%RESPONSE_CODE% is the intended (valid) substitution, but any bare % in the body is not. error-5xx-default.html does not escape its literal %. The infra-mounted override page (apps/envoy-error-pages/.../error-5xx.html) does escape them (width: 100%%), which is why prod hasn't been taken down by this specific path — but any gateway without the envoy-error-pages ConfigMap falls back to the malformed embedded page.

Fix

  • Escape literal %%% in error-5xx-default.html (and any other content fed into a SubstitutionFormatString).
  • Validate/round-trip the assembled local_reply_config at extension-server startup so a malformed body is caught before it ever reaches Envoy, rather than silently NACKing xDS in the data plane.

Also worth confirming

While validating the fix locally, after mounting the escaped override page and restarting the extension-server (which logged using error-page body override ... bytes=13047), the pushed listener config still carried the embedded page and stayed rejected. Most likely Envoy Gateway caching the pre-override translation (re-applying an unchanged TPP doesn't retrigger the xDS hook), but confirm the loaded override actually reaches InjectLocalReplyConfig and isn't shadowed by the embedded default.

Scope / impact

Any TrafficProtectionPolicy in Enforce mode on a gateway using the embedded default error page → the WAF listener never programs → total outage on that gateway (not a WAF rule match). Sub-issue of #242.

Repro notes

Reproduced on Envoy contrib-v1.35.0 (the tag in config/dev/downstream_resources/downstream-gateway.yaml); edge runs contrib-v1.37.1, where the wire symptom differs (reported 500 + content-length + 0 body + transfer closed vs. the listener-reject "empty reply" here) but is consistent with the same unescaped-% root. See #242 for the full local-stack writeup.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions