Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions config/crd/bases/http.keda.sh_httpscaledobjects.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions interceptor/proxy_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
96 changes: 96 additions & 0 deletions interceptor/warming_page.go
Original file line number Diff line number Diff line change
@@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="5">
<title>Service Starting</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #fff;
}
.container {
text-align: center;
padding: 2rem;
max-width: 600px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
.message {
font-size: 1.2rem;
margin-bottom: 2rem;
opacity: 0.9;
white-space: pre-wrap;
}
.spinner {
width: 60px;
height: 60px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 2rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.info {
font-size: 0.9rem;
opacity: 0.7;
margin-top: 2rem;
}
.powered-by {
font-size: 0.8rem;
opacity: 0.5;
margin-top: 3rem;
}
</style>
</head>
<body>
<div class="container">
<div class="spinner"></div>
<h1>Service Starting</h1>
<div class="message">` + safeMessage + `</div>
<p class="info">This page will automatically refresh...</p>
<p class="powered-by">Powered by KEDA HTTP Add-on</p>
</div>
</body>
</html>`
}
99 changes: 99 additions & 0 deletions interceptor/warming_page_test.go
Original file line number Diff line number Diff line change
@@ -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: "<script>alert('xss')</script>",
expectedContent: []string{
"&lt;script&gt;",
"&lt;/script&gt;",
},
},
}

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, "<!DOCTYPE html>") {
t.Error("HTML missing DOCTYPE declaration")
}
if !strings.Contains(html, "<html") {
t.Error("HTML missing html tag")
}
if !strings.Contains(html, "</html>") {
t.Error("HTML missing closing html tag")
}
})
}
}

func TestGenerateWarmingPageHTML_XSSProtection(t *testing.T) {
dangerousInputs := []struct {
input string
shouldNotContain string
}{
{"<script>alert('xss')</script>", "<script>"},
{"<img src=x onerror=alert('xss')>", "<img"},
{"<iframe src='javascript:alert(1)'>", "<iframe"},
{"<svg onload=alert('xss')>", "<svg"},
}

for _, tc := range dangerousInputs {
t.Run("XSS_protection_"+tc.input, func(t *testing.T) {
html := generateWarmingPageHTML(tc.input)

// Verify dangerous tags are escaped (converted to &lt; &gt;)
if strings.Contains(html, tc.shouldNotContain) {
t.Errorf("HTML contains unescaped dangerous tag: %s", tc.shouldNotContain)
}

// Verify the escaped version is present
if !strings.Contains(html, "&lt;") {
t.Error("HTML should contain escaped angle brackets")
}
})
}
}
5 changes: 5 additions & 0 deletions operator/apis/http/v1alpha1/httpscaledobject_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ type HTTPScaledObjectSpec struct {
// (optional) Timeouts that override the global ones
// +optional
Timeouts *HTTPScaledObjectTimeoutsSpec `json:"timeouts,omitempty" description:"Timeouts that override the global ones"`
// (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.
// +optional
ColdStartMessage string `json:"coldStartMessage,omitempty" description:"Custom message to display when scaled to zero"`
}

// HTTPScaledObjectStatus defines the observed state of HTTPScaledObject
Expand Down