Skip to content

Commit 282a0a4

Browse files
committed
fix: classification banner working for SPAs
1 parent 3d7977c commit 282a0a4

3 files changed

Lines changed: 134 additions & 30 deletions

File tree

bundles/k3d-standard/uds-bundle.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,19 @@ packages:
102102
path: backend.replicas
103103
description: "Loki backend replicas"
104104
default: "1"
105+
istio-controlplane:
106+
uds-global-istio-config:
107+
values:
108+
# Demo classification banner for the standard dev bundle. UNKNOWN renders as the
109+
# black marking and is not a real classification level, so it cannot be mistaken
110+
# for a live classification in another environment.
111+
- path: classificationBanner.text
112+
value: "UNKNOWN"
113+
- path: classificationBanner.enabledHosts
114+
value:
115+
- "sso.uds.dev"
116+
- "portal.uds.dev"
117+
- "grafana.admin.uds.dev"
105118
istio-admin-gateway:
106119
uds-istio-config:
107120
variables:

src/istio/common/chart/templates/classification-banner.yaml

Lines changed: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Copyright 2026 Defense Unicorns
3+
* SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial
4+
*/
5+
6+
import { expect, test } from "@playwright/test";
7+
import type { Page } from "@playwright/test";
8+
import { domain, flavor, fullCore } from "./uds.config";
9+
10+
// Text injected by the classification-banner EnvoyFilter, set on the enabled hosts in
11+
// bundles/k3d-standard/uds-bundle.yaml. UNKNOWN renders as the black marking.
12+
const BANNER_TEXT = "UNKNOWN";
13+
14+
// Asserts the classification-banner EnvoyFilter injected its fixed header div into the page.
15+
// The Lua filter previously called response_handle:body() on bodyless 3xx redirects, which
16+
// hung the gateway response until the client disconnected. A page that loads at all with the
17+
// banner present therefore also proves the redirect path no longer stalls.
18+
async function expectBanner(page: Page) {
19+
const banner = page.locator("#classification-banner-top");
20+
await expect(banner).toBeVisible();
21+
await expect(banner).toHaveText(BANNER_TEXT);
22+
}
23+
24+
test("sso shows the classification banner", async ({ page }) => {
25+
await page.goto(`https://sso.${domain}/realms/uds/account`);
26+
await expectBanner(page);
27+
});
28+
29+
test("grafana shows the classification banner", async ({ page }) => {
30+
// Grafana's root returns a 302 redirect (the case that previously hung the gateway), so
31+
// reaching a rendered page here exercises both the redirect and the 200 HTML inject paths.
32+
await page.goto(`https://grafana.admin.${domain}/`);
33+
await expectBanner(page);
34+
});
35+
36+
test("portal shows the classification banner", async ({ page }) => {
37+
test.skip(
38+
!fullCore || flavor === "registry1",
39+
"Portal is not present in registry1 flavor deploys",
40+
);
41+
await page.goto(`https://portal.${domain}/`);
42+
await expectBanner(page);
43+
});

0 commit comments

Comments
 (0)