Skip to content

Commit fbf5831

Browse files
CopilotBreee
andauthored
security: safe defaults and timeline hardening
Co-authored-by: Breee <11966385+Breee@users.noreply.github.com>
1 parent 0e46781 commit fbf5831

7 files changed

Lines changed: 113 additions & 16 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,14 @@ Timeline and tracing:
174174
```text
175175
--timeline-bind-address=:8090
176176
--otel-otlp-endpoint=<collector-host:4317>
177-
--otel-otlp-insecure=true
177+
--otel-otlp-insecure=false
178178
```
179179

180-
Timeline UI path: `/timeline/ui` — opens a compact cross-namespace overview (state-over-time swimlanes, color-coded event log, and a drag-to-zoom time ruler with a from/to picker).
181-
182-
> **⚠ Experimental:** the timeline UI/API is unauthenticated and read-only. Use it only via localhost or `kubectl port-forward`; never expose it through an Ingress or untrusted network.
180+
The timeline server is **disabled by default**. Enable it only when you can
181+
restrict access to trusted networks, for example via `kubectl port-forward`.
182+
When enabled, the UI/API is unauthenticated and read-only at `/timeline/ui`; it
183+
exposes namespace names, workload names, and restart state, so never expose it
184+
through an Ingress or untrusted network.
183185

184186
## Security note
185187

charts/kick/values.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ containerSecurityContext:
5858
capabilities:
5959
drop:
6060
- ALL
61+
readOnlyRootFilesystem: true
62+
seccompProfile:
63+
type: RuntimeDefault
6164

6265
probes:
6366
liveness:

cmd/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,10 @@ func parseFlags() options {
6969
var argocdApplicationNamespaces string
7070
flag.StringVar(&opts.metricsAddr, "metrics-bind-address", ":8080", "Metrics endpoint address.")
7171
flag.StringVar(&opts.probeAddr, "health-probe-bind-address", ":8081", "Health probe address.")
72-
flag.StringVar(&opts.timelineAddr, "timeline-bind-address", ":8090", "Timeline API/UI bind address. Empty disables the timeline server.")
72+
flag.StringVar(&opts.timelineAddr, "timeline-bind-address", "", "Timeline API/UI bind address. Empty disables the timeline server.")
7373
flag.BoolVar(&opts.leaderElection, "leader-elect", false, "Enable leader election.")
7474
flag.StringVar(&opts.otlpEndpoint, "otel-otlp-endpoint", "", "OTLP endpoint (host:port) for exporting traces to Tempo/Jaeger or another collector.")
75-
flag.BoolVar(&opts.otlpInsecure, "otel-otlp-insecure", true, "Use insecure OTLP transport (no TLS).")
75+
flag.BoolVar(&opts.otlpInsecure, "otel-otlp-insecure", false, "Use insecure OTLP transport (no TLS).")
7676
flag.DurationVar(&opts.requestRetention, "request-retention", 24*time.Hour, "Retention duration for terminal KickRequests before deletion.")
7777
flag.DurationVar(&opts.rolloutTimeout, "rollout-timeout", 15*time.Minute, "How long a restart may take before the KickRequest fails with RolloutTimeout.")
7878
flag.BoolVar(&opts.enableCSIIntegration, "enable-csi-integration", false, "Watch SecretProviderClassPodStatus to restart workloads when Secrets Store CSI secrets rotate. Ignored when the CRD is absent.")

config/manager/manager.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ spec:
2020
allowPrivilegeEscalation: false
2121
capabilities: {drop: ["ALL"]}
2222
runAsNonRoot: true
23+
readOnlyRootFilesystem: true
24+
seccompProfile:
25+
type: RuntimeDefault
2326
resources:
2427
requests: {cpu: 10m, memory: 64Mi}
2528
limits: {cpu: 500m, memory: 256Mi}

docs/content/docs/operations/security.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,13 @@ KICK safety constraints:
1212

1313
- no privileged containers;
1414
- no CRI socket access;
15-
- no Secret value logging.
15+
- no Secret value logging;
16+
- container runs with a read-only root filesystem and the runtime default seccomp profile.
17+
18+
## Timeline UI
19+
20+
The timeline server is disabled by default. Enable it with `--timeline-bind-address` only on trusted networks, for example via `kubectl port-forward`. It is unauthenticated and exposes namespace names, workload names, and restart state.
21+
22+
## Tracing
23+
24+
OTLP trace export defaults to TLS. Use `--otel-otlp-insecure` only when the collector is reached through a trusted, encrypted path such as a service mesh.

internal/timeline/service.go

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"k8s.io/apimachinery/pkg/labels"
2020
"k8s.io/apimachinery/pkg/types"
2121
"sigs.k8s.io/controller-runtime/pkg/client"
22+
"sigs.k8s.io/controller-runtime/pkg/log"
2223
)
2324

2425
type Entry struct {
@@ -122,6 +123,11 @@ type Service struct {
122123
ObservationStore observation.Store
123124
}
124125

126+
const (
127+
msgInternalServerError = "internal server error"
128+
msgBadRequest = "bad request"
129+
)
130+
125131
func RegisterHandlers(mux *http.ServeMux, svc *Service) {
126132
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
127133
if r.URL.Path != "/" {
@@ -139,11 +145,23 @@ func RegisterHandlers(mux *http.ServeMux, svc *Service) {
139145
mux.HandleFunc("/timeline/ui", serveUI)
140146
}
141147

148+
// writeError logs the full error and returns a generic message to the client.
149+
// Timeline endpoints are exposed without authentication, so internal details
150+
// must not leak through HTTP responses.
151+
func (s *Service) writeError(ctx context.Context, w http.ResponseWriter, err error, code int) {
152+
log.FromContext(ctx).Error(err, "timeline request failed")
153+
msg := msgInternalServerError
154+
if code == http.StatusBadRequest {
155+
msg = msgBadRequest
156+
}
157+
http.Error(w, msg, code)
158+
}
159+
142160
func (s *Service) handleNamespaces(w http.ResponseWriter, r *http.Request) {
143161
ctx := r.Context()
144162
var namespaces corev1.NamespaceList
145163
if err := s.Client.List(ctx, &namespaces); err != nil {
146-
http.Error(w, err.Error(), http.StatusBadRequest)
164+
s.writeError(ctx, w, err, http.StatusInternalServerError)
147165
return
148166
}
149167

@@ -168,7 +186,7 @@ func (s *Service) handleResources(w http.ResponseWriter, r *http.Request) {
168186

169187
var policyList kickv1alpha1.KickPolicyList
170188
if err := s.Client.List(ctx, &policyList, listOptions...); err != nil {
171-
http.Error(w, err.Error(), http.StatusBadRequest)
189+
s.writeError(ctx, w, err, http.StatusInternalServerError)
172190
return
173191
}
174192
policies := make([]KickPolicySummary, 0, len(policyList.Items))
@@ -184,7 +202,7 @@ func (s *Service) handleResources(w http.ResponseWriter, r *http.Request) {
184202

185203
var requestList kickv1alpha1.KickRequestList
186204
if err := s.Client.List(ctx, &requestList, listOptions...); err != nil {
187-
http.Error(w, err.Error(), http.StatusBadRequest)
205+
s.writeError(ctx, w, err, http.StatusInternalServerError)
188206
return
189207
}
190208
requests := make([]KickRequestSummary, 0, len(requestList.Items))
@@ -230,7 +248,7 @@ func (s *Service) handleTimeline(w http.ResponseWriter, r *http.Request) {
230248

231249
items, err := s.buildTimeline(ctx, namespace, kind, name)
232250
if err != nil {
233-
http.Error(w, err.Error(), http.StatusBadRequest)
251+
s.writeError(ctx, w, err, http.StatusInternalServerError)
234252
return
235253
}
236254

@@ -239,15 +257,16 @@ func (s *Service) handleTimeline(w http.ResponseWriter, r *http.Request) {
239257
}
240258

241259
func (s *Service) handleDiscovery(w http.ResponseWriter, r *http.Request) {
260+
ctx := r.Context()
242261
namespace := r.URL.Query().Get("namespace")
243262
if namespace == "" {
244263
http.Error(w, "namespace is required", http.StatusBadRequest)
245264
return
246265
}
247266

248-
items, policies, err := s.discoverManagedWorkloads(r.Context(), namespace)
267+
items, policies, err := s.discoverManagedWorkloads(ctx, namespace)
249268
if err != nil {
250-
http.Error(w, err.Error(), http.StatusBadRequest)
269+
s.writeError(ctx, w, err, http.StatusInternalServerError)
251270
return
252271
}
253272

@@ -274,15 +293,16 @@ func (s *Service) handleDiscovery(w http.ResponseWriter, r *http.Request) {
274293
}
275294

276295
func (s *Service) handleDAG(w http.ResponseWriter, r *http.Request) {
296+
ctx := r.Context()
277297
namespace := r.URL.Query().Get("namespace")
278298
if namespace == "" {
279299
http.Error(w, "namespace is required", http.StatusBadRequest)
280300
return
281301
}
282302

283-
dag, err := s.buildDAG(r.Context(), namespace)
303+
dag, err := s.buildDAG(ctx, namespace)
284304
if err != nil {
285-
http.Error(w, err.Error(), http.StatusBadRequest)
305+
s.writeError(ctx, w, err, http.StatusInternalServerError)
286306
return
287307
}
288308

@@ -298,7 +318,7 @@ func (s *Service) handleOverview(w http.ResponseWriter, r *http.Request) {
298318

299319
var namespaces corev1.NamespaceList
300320
if err := s.Client.List(ctx, &namespaces); err != nil {
301-
http.Error(w, err.Error(), http.StatusBadRequest)
321+
s.writeError(ctx, w, err, http.StatusInternalServerError)
302322
return
303323
}
304324

internal/timeline/service_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package timeline
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"net/http"
78
"net/http/httptest"
9+
"strings"
810
"testing"
911
"time"
1012

@@ -15,6 +17,7 @@ import (
1517
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1618
"k8s.io/apimachinery/pkg/runtime"
1719
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
20+
"sigs.k8s.io/controller-runtime/pkg/client"
1821
"sigs.k8s.io/controller-runtime/pkg/client/fake"
1922
)
2023

@@ -418,3 +421,60 @@ func TestHandleOverviewAggregatesAcrossNamespaces(t *testing.T) {
418421
}
419422
}
420423
}
424+
425+
// errorClient is a fake client that fails every List with a fixed error.
426+
type errorClient struct {
427+
client.Client
428+
err error
429+
}
430+
431+
func (c *errorClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
432+
return c.err
433+
}
434+
435+
// TestTimelineHandlersDoNotLeakInternalErrors verifies that the read-only
436+
// timeline API returns generic error messages instead of raw API-server errors.
437+
func TestTimelineHandlersDoNotLeakInternalErrors(t *testing.T) {
438+
scheme := runtime.NewScheme()
439+
if err := clientgoscheme.AddToScheme(scheme); err != nil {
440+
t.Fatalf("add kube scheme: %v", err)
441+
}
442+
if err := kickv1alpha1.AddToScheme(scheme); err != nil {
443+
t.Fatalf("add kick scheme: %v", err)
444+
}
445+
446+
inner := fake.NewClientBuilder().WithScheme(scheme).Build()
447+
svc := &Service{Client: &errorClient{Client: inner, err: errors.New("etcd is unavailable: internal cluster detail")}, ObservationStore: observation.NewMemoryStore()}
448+
mux := http.NewServeMux()
449+
RegisterHandlers(mux, svc)
450+
451+
cases := []struct {
452+
path string
453+
}{
454+
{"/timeline/namespaces"},
455+
{"/timeline/resources?namespace=team-a"},
456+
{"/timeline/discovery?namespace=team-a"},
457+
{"/timeline/dag?namespace=team-a"},
458+
{"/timeline/overview"},
459+
{"/timeline?namespace=team-a&name=web"},
460+
}
461+
462+
for _, tc := range cases {
463+
t.Run(tc.path, func(t *testing.T) {
464+
req := httptest.NewRequest(http.MethodGet, "http://localhost"+tc.path, nil)
465+
rr := httptest.NewRecorder()
466+
mux.ServeHTTP(rr, req)
467+
468+
if rr.Code != http.StatusInternalServerError {
469+
t.Fatalf("expected 500, got %d", rr.Code)
470+
}
471+
body := strings.TrimSpace(rr.Body.String())
472+
if strings.Contains(body, "etcd") || strings.Contains(body, "internal cluster detail") {
473+
t.Fatalf("response leaks internal error: %q", body)
474+
}
475+
if !strings.Contains(body, "internal server error") {
476+
t.Fatalf("response missing generic message: %q", body)
477+
}
478+
})
479+
}
480+
}

0 commit comments

Comments
 (0)