From fe851f3537505db34b237851f7d8ddfe4746b61c Mon Sep 17 00:00:00 2001 From: Richard Senior Date: Tue, 3 Feb 2026 23:25:38 +0000 Subject: [PATCH] feat(interceptor): add customizable cold start message Add optional coldStartMessage field to HTTPScaledObject that displays a user-friendly HTML page when applications are scaled to zero. - Add ColdStartMessage field to HTTPScaledObjectSpec CRD - Implement immediate HTML response when no endpoints available - Include auto-refresh every 5 seconds via HTTP meta refresh - Add XSS protection via HTML escaping - Fully backward compatible (opt-in feature) - Add comprehensive unit tests with XSS protection validation Signed-off-by: Richard Senior --- CHANGELOG.md | 2 +- .../bases/http.keda.sh_httpscaledobjects.yaml | 6 ++ interceptor/proxy_handlers.go | 25 +++++ interceptor/warming_page.go | 96 ++++++++++++++++++ interceptor/warming_page_test.go | 99 +++++++++++++++++++ .../http/v1alpha1/httpscaledobject_types.go | 5 + 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 interceptor/warming_page.go create mode 100644 interceptor/warming_page_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef63a33d..c97e54965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ This changelog keeps track of work items that have been completed and are ready ### New -- **General**: TODO ([#TODO](https://github.com/kedacore/http-add-on/issues/TODO)) +- **Interceptor**: Add customizable cold start message for scale-to-zero applications ([#TBD](https://github.com/kedacore/http-add-on/pull/TBD)) ### Improvements diff --git a/config/crd/bases/http.keda.sh_httpscaledobjects.yaml b/config/crd/bases/http.keda.sh_httpscaledobjects.yaml index e7540b926..957322713 100644 --- a/config/crd/bases/http.keda.sh_httpscaledobjects.yaml +++ b/config/crd/bases/http.keda.sh_httpscaledobjects.yaml @@ -60,6 +60,12 @@ spec: spec: description: HTTPScaledObjectSpec defines the desired state of HTTPScaledObject properties: + coldStartMessage: + description: |- + (optional) Custom message to display when the application is scaled to zero. + If not set, a default message will be shown. + The message will be displayed in an auto-refreshing HTML page. + type: string coldStartTimeoutFailoverRef: description: (optional) The name of the failover service to route HTTP requests to when the target is not available diff --git a/interceptor/proxy_handlers.go b/interceptor/proxy_handlers.go index c9d885d58..931662613 100644 --- a/interceptor/proxy_handlers.go +++ b/interceptor/proxy_handlers.go @@ -90,6 +90,31 @@ func newForwardingHandler( conditionWaitTimeout = time.Duration(httpso.Spec.ColdStartTimeoutFailoverRef.TimeoutSeconds) * time.Second } + // NEW: Check if we should show cold start message immediately + // If ColdStartMessage is configured, check endpoints and return HTML immediately if scaled to zero + if httpso.Spec.ColdStartMessage != "" { + waitFuncCtx, done := context.WithTimeout(ctx, 100*time.Millisecond) + defer done() + isColdStart, err := waitFunc( + waitFuncCtx, + httpso.GetNamespace(), + httpso.Spec.ScaleTargetRef.Service, + ) + // If we're at zero replicas (cold start), immediately return warming page + if err != nil || isColdStart { + lggr.Info("Cold start detected, returning warming page", "namespace", httpso.GetNamespace(), "service", httpso.Spec.ScaleTargetRef.Service) + html := generateWarmingPageHTML(httpso.Spec.ColdStartMessage) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(html)); err != nil { + lggr.Error(err, "failed to write warming page response") + } + return + } + // If we have endpoints, continue with normal flow + } + waitFuncCtx, done := context.WithTimeout(ctx, conditionWaitTimeout) defer done() isColdStart, err := waitFunc( diff --git a/interceptor/warming_page.go b/interceptor/warming_page.go new file mode 100644 index 000000000..c4bda86f4 --- /dev/null +++ b/interceptor/warming_page.go @@ -0,0 +1,96 @@ +package main + +import ( + "html" +) + +const defaultColdStartMessage = "Service is starting, please wait..." + +// generateWarmingPageHTML creates an HTML page to display during cold starts. +// The page includes: +// - Custom message (or default if empty) +// - Auto-refresh every 5 seconds +// - Loading animation +// - Professional styling +func generateWarmingPageHTML(customMessage string) string { + message := customMessage + if message == "" { + message = defaultColdStartMessage + } + + // Escape HTML to prevent XSS + safeMessage := html.EscapeString(message) + + return ` + + + + + + Service Starting + + + +
+
+

Service Starting

+
` + safeMessage + `
+

This page will automatically refresh...

+

Powered by KEDA HTTP Add-on

+
+ +` +} diff --git a/interceptor/warming_page_test.go b/interceptor/warming_page_test.go new file mode 100644 index 000000000..8a2913b0c --- /dev/null +++ b/interceptor/warming_page_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "strings" + "testing" +) + +func TestGenerateWarmingPageHTML(t *testing.T) { + tests := []struct { + name string + customMessage string + expectedContent []string + }{ + { + name: "with custom message", + customMessage: "Waking up the application...", + expectedContent: []string{ + "Waking up the application...", + "Service Starting", + "meta http-equiv=\"refresh\"", + "This page will automatically refresh", + }, + }, + { + name: "with empty message uses default", + customMessage: "", + expectedContent: []string{ + defaultColdStartMessage, + "Service Starting", + "meta http-equiv=\"refresh\"", + }, + }, + { + name: "escapes HTML in message", + customMessage: "", + expectedContent: []string{ + "<script>", + "</script>", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + html := generateWarmingPageHTML(tt.customMessage) + + // Check that HTML is not empty + if html == "" { + t.Error("generated HTML is empty") + } + + // Check for expected content + for _, expected := range tt.expectedContent { + if !strings.Contains(html, expected) { + t.Errorf("HTML does not contain expected content: %q", expected) + } + } + + // Verify it's valid HTML structure + if !strings.Contains(html, "") { + t.Error("HTML missing DOCTYPE declaration") + } + if !strings.Contains(html, "") { + t.Error("HTML missing closing html tag") + } + }) + } +} + +func TestGenerateWarmingPageHTML_XSSProtection(t *testing.T) { + dangerousInputs := []struct { + input string + shouldNotContain string + }{ + {"", "