1- # Copyright 2024 Defense Unicorns
1+ # Copyright 2024-2026 Defense Unicorns
22# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial
33
44{{- if .Values.classificationBanner.enabledHosts }}
@@ -115,28 +115,51 @@ spec:
115115 local colors = classColorMap[classificationKey(classification)] or classColorMap["UNKNOWN"]
116116 local backgroundColor = colors.backgroundColor
117117 local textColor = colors.textColor
118- local style = "background-color: " .. backgroundColor .. "; color: " .. textColor .. "; height: 24px; line-height: 24px; border: 1px solid transparent; border-radius: 0; position: fixed; left: 0; width: 100vw; text-align: center; margin: 0; z-index: 10000;"
118+ local style = "background-color: " .. backgroundColor .. "; color: " .. textColor .. "; font-family: Arial, Helvetica, sans-serif; font-size: 16px; font-weight: 400; height: 24px; line-height: 24px; border: 1px solid transparent; border-radius: 0; position: fixed; left: 0; width: 100vw; text-align: center; margin: 0; z-index: 10000;"
119119 local header = "<div id=\"classification-banner-top\" style=\"" .. style .. " top: 0;\">" .. classification .. "</div>"
120120 local footer = "<div id=\"classification-banner-bottom\" style=\"" .. style .. " bottom: 0;\">" .. classification .. "</div>"
121121
122- -- Add script to manage padding around the body of the response
122+ -- Reserve space for the banner(s) so they don't overlap page content, including apps
123+ -- whose nav/toolbar is position:fixed (e.g. SPAs).
123124 local bodyPaddingScript = [[
124125 <script>
125- window.addEventListener('DOMContentLoaded', function () {
126- var headerBanner = document.getElementById('classification-banner-top');
127- var footerBanner = document.getElementById('classification-banner-bottom');
128- if (headerBanner) {
129- document.body.style.paddingTop = '24px';
126+ (function () {
127+ var H = '24px';
128+ function place() {
129+ var b = document.body;
130+ if (!b) { return; }
131+ var top = document.getElementById('classification-banner-top');
132+ var bottom = document.getElementById('classification-banner-bottom');
133+ if (!top && !bottom) { return; }
134+ // Keep the banners on <html> (not <body>) so they stay anchored to the viewport
135+ // after <body> is transformed below. The banner's font is pinned explicitly in its
136+ // style, so it stays consistent regardless of which element it is parented to.
137+ if (top && top.parentElement !== document.documentElement) {
138+ document.documentElement.appendChild(top);
130139 }
131- if (footerBanner) {
132- var footerHeight = '24px';
133- document.body.style.paddingBottom = footerHeight;
134- var existingFooter = document.querySelector('footer');
135- if (existingFooter) {
136- existingFooter.style.marginBottom = footerHeight;
137- }
140+ if (bottom && bottom.parentElement !== document.documentElement) {
141+ document.documentElement.appendChild(bottom);
138142 }
139- });
143+ b.style.boxSizing = 'border-box';
144+ b.style.minHeight = '100vh';
145+ // A transform makes <body> the containing block for its position:fixed descendants
146+ // (e.g. an app's fixed nav). Their containing block is body's PADDING box, so a
147+ // transparent BORDER -- not padding -- is what pushes those fixed elements clear of
148+ // the banner while also reserving the strip for normal-flow content.
149+ if (top) { b.style.borderTop = H + ' solid transparent'; }
150+ if (bottom) { b.style.borderBottom = H + ' solid transparent'; }
151+ if (!b.style.transform || b.style.transform === 'none') {
152+ b.style.transform = 'translateZ(0)';
153+ }
154+ }
155+ if (document.readyState === 'loading') {
156+ document.addEventListener('DOMContentLoaded', place);
157+ } else {
158+ place();
159+ }
160+ // SPAs mount their chrome after initial load; re-assert once more afterwards.
161+ window.addEventListener('load', function () { setTimeout(place, 0); });
162+ })();
140163 </script>
141164 ]]
142165
@@ -153,26 +176,51 @@ spec:
153176 request_handle:streamInfo():dynamicMetadata():set("envoy.lua", "host", tostring(host))
154177 end
155178
179+ -- Insert the banner div(s) after <body> and the padding script after <head>. Both
180+ -- :gsub calls are bounded to a single replacement so this is safe to apply to a whole
181+ -- buffered body or to a single streamed chunk.
182+ local function inject_banner(html)
183+ {{- if .Values.classificationBanner.addFooter }}
184+ html = html:gsub("<body([^>]*)>", "<body%1>" .. header .. footer, 1)
185+ {{- else }}
186+ html = html:gsub("<body([^>]*)>", "<body%1>" .. header, 1)
187+ {{- end }}
188+ return (html:gsub("<head>", "<head>" .. bodyPaddingScript, 1))
189+ end
190+
156191 -- Inject the banner for any hosts where it is enabled
157192 function envoy_on_response(response_handle)
158193 local content_type = response_handle:headers():get("Content-Type") or ""
159194 local host = response_handle:streamInfo():dynamicMetadata():get("envoy.lua")["host"]
160195
161- if string.find(content_type, "text/html") and enabled_hosts[host] then
162- local body = response_handle:body():getBytes(0, response_handle:body():length())
163- local body_text = tostring(body)
164-
165- -- Insert banners into <body>
166- {{- if .Values.classificationBanner.addFooter }}
167- body_text = body_text:gsub("<body([^>]*)>", "<body%1>" .. header .. footer)
168- {{- else }}
169- body_text = body_text:gsub("<body([^>]*)>", "<body%1>" .. header)
170- {{- end }}
171-
172- -- Insert script into <head>
173- body_text = body_text:gsub("<head>", "<head>" .. bodyPaddingScript)
196+ -- Skip anything that is not a successful HTML page on an enabled host (e.g. bodyless
197+ -- 3xx redirects, which still carry Content-Type: text/html).
198+ if response_handle:headers():get(":status") ~= "200"
199+ or not string.find(content_type, "text/html")
200+ or not enabled_hosts[host] then
201+ return
202+ end
174203
175- response_handle:body():setBytes(body_text)
204+ -- response_handle:body() buffers the whole body and blocks until end-of-stream.
205+ -- That is fine for a bounded response (Content-Length present), but it hangs the
206+ -- gateway on a streamed/chunked response with no Content-Length (e.g. an SPA that
207+ -- renders its HTML directly to the response). Pick the strategy accordingly.
208+ if response_handle:headers():get("content-length") ~= nil then
209+ -- Bounded response: buffer and rewrite in one shot.
210+ local body = response_handle:body()
211+ response_handle:body():setBytes(inject_banner(tostring(body:getBytes(0, body:length()))))
212+ else
213+ -- Streamed response: rewrite chunks as they pass through (bodyChunks does not
214+ -- buffer), so we never wait for end-of-stream. Stop once the banner is in; if a
215+ -- tag straddles a chunk boundary the chunk is left untouched rather than stalling.
216+ local injected = false
217+ for chunk in response_handle:bodyChunks() do
218+ if chunk:length() > 0 and not injected then
219+ local data = inject_banner(chunk:getBytes(0, chunk:length()))
220+ chunk:setBytes(data)
221+ injected = string.find(data, "classification-banner-top", 1, true) ~= nil
222+ end
223+ end
176224 end
177225 end
178226 - applyTo : HTTP_FILTER
0 commit comments