Skip to content

Commit fdecaad

Browse files
authored
Merge pull request #96 from mikeappsec/feat/analytics-policy-inspector
Feat/analytics policy inspector
2 parents 620786f + dc400a2 commit fdecaad

43 files changed

Lines changed: 2238 additions & 1441 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/lwauth-controlplane/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ func main() {
9090
streamHub := streaming.NewHub(registry, aggregator)
9191
streamHub.AllowedOrigin = cfg.ConsoleOrigin
9292

93+
// SECURITY: When auth is enabled, require a console origin to be
94+
// set so WebSocket upgraders fail-closed on cross-origin connections.
95+
// If the operator forgot to set CP_CONSOLE_ORIGIN, warn loudly and
96+
// use the request's Host header as the default (same-origin only).
97+
effectiveOrigin := cfg.ConsoleOrigin
98+
if cfg.AuthEnabled && effectiveOrigin == "" {
99+
logger.Error(nil, "CP_AUTH_ENABLED=true but CP_CONSOLE_ORIGIN is empty — WebSocket connections will accept any origin. Set CP_CONSOLE_ORIGIN to the console URL (e.g. https://lwauth.example.com).")
100+
}
101+
93102
// Alerting engine (Phase 2). Pulls rule evaluations from
94103
// Prometheus and Loki when configured; degrades to a readonly
95104
// catalog with running rule definitions but no firings when the

deploy/helm/lightweightauth-controlplane/templates/deployment.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,27 @@ spec:
3737
value: {{ .Values.defaultImage | quote }}
3838
- name: CP_LEADER_ELECT
3939
value: {{ .Values.leaderElection.enabled | quote }}
40+
# Console authentication — enabled by default in the chart.
41+
# Operators must supply a bcrypt password hash.
42+
- name: CP_AUTH_ENABLED
43+
value: {{ .Values.consoleAuth.enabled | quote }}
44+
- name: CP_AUTH_USERNAME
45+
value: {{ .Values.consoleAuth.username | quote }}
46+
- name: CP_AUTH_COOKIE_SECURE
47+
value: {{ .Values.consoleAuth.cookieSecure | quote }}
48+
- name: CP_AUTH_SESSION_TTL
49+
value: {{ .Values.consoleAuth.sessionTTL | quote }}
50+
{{- if .Values.consoleAuth.passwordHash }}
51+
- name: CP_AUTH_PASSWORD_HASH
52+
value: {{ .Values.consoleAuth.passwordHash | quote }}
53+
{{- end }}
54+
{{- if .Values.consoleAuth.passwordHashFile }}
55+
- name: CP_AUTH_PASSWORD_HASH_FILE
56+
value: {{ .Values.consoleAuth.passwordHashFile | quote }}
57+
{{- end }}
58+
# RBAC enforcement — enabled by default in the chart.
59+
- name: CP_RBAC_ENABLED
60+
value: {{ .Values.consoleRBAC.enabled | quote }}
4061
{{- if .Values.alerting.enabled }}
4162
- name: PROMETHEUS_URL
4263
value: {{ .Values.alerting.prometheusURL | quote }}

deploy/helm/lightweightauth-controlplane/values.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,29 @@ leaderElection:
5151
clusterName: "local"
5252
defaultImage: "ghcr.io/mikeappsec/lightweightauth:latest"
5353

54+
# Console authentication — enabled by default in the Helm chart.
55+
# Operators MUST set CP_AUTH_PASSWORD_HASH (or CP_AUTH_PASSWORD_HASH_FILE)
56+
# to a bcrypt hash of the admin password. The deployment refuses to
57+
# serve logins without it (fail-closed). Set to false only for local
58+
# development with port-forwarding.
59+
consoleAuth:
60+
enabled: true
61+
username: "admin"
62+
# passwordHash must be a bcrypt hash. Set via a Kubernetes secret and
63+
# reference with passwordHashFile, or inline via passwordHash.
64+
# Example: htpasswd -nbBC 10 "" 'mypassword' | cut -d: -f2
65+
passwordHash: ""
66+
passwordHashFile: ""
67+
cookieSecure: true
68+
sessionTTL: "12h"
69+
70+
# RBAC enforcement — enabled by default in the Helm chart. When true,
71+
# API callers must present a bearer token, X-Forwarded-User header, or
72+
# mTLS client cert. Browser console users authenticate via the session
73+
# cookie (consoleAuth above).
74+
consoleRBAC:
75+
enabled: true
76+
5477
# consoleOrigin restricts WebSocket upgrades to a single trusted origin.
5578
# Set to the full scheme+host of the UI (e.g. https://lwauth.example.com).
5679
# Empty string disables the check (dev mode — any origin accepted).

docs/cookbook/dpop-sender-binding.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -198,15 +198,13 @@ No extra configuration needed — as long as your proxy sets
198198
## 6. Replay prevention
199199

200200
The `jti` claim prevents token replay. lwauth maintains a cache of
201-
recently seen jti values — but there's no `replayCache:` config block
202-
on the `dpop` identifier (`pkg/identity/dpop/dpop.go`'s `knownKeys`
203-
only has `replayCacheSize`, and that field is parsed but never
204-
actually read anywhere in the package — it's presently dead). The
205-
cache handle instead comes from the shared, named cache-pool
206-
infrastructure: `deps.CacheProvider().Cache("replay")` requests a
207-
pool named `"replay"` from the top-level `caches:` block. If you
208-
don't declare one, lwauth synthesizes an implicit in-memory default
209-
pool so existing configs keep working:
201+
recently seen jti values — but there's no per-identifier `replayCache:`
202+
or `replayCacheSize:` config block on the `dpop` identifier. The cache
203+
handle instead comes from the shared, named cache-pool infrastructure:
204+
`deps.CacheProvider().Cache("replay")` requests a pool named `"replay"`
205+
from the top-level `caches:` block. If you don't declare one, lwauth
206+
synthesizes an implicit in-memory default pool so existing configs
207+
keep working:
210208

211209
```yaml
212210
identifiers:

docs/modules/dpop.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ identifiers:
2424
proofHeader: DPoP # default
2525
bearerHeader: Authorization # default
2626
skew: 30s # iat tolerance; jti replay TTL = 2·skew
27-
replayCacheSize: 10000 # in-process; switches to valkey if AuthConfig.cache.backend = valkey
27+
# replay cache is sized via the top-level `caches:` block
28+
# (pool name "replay"), not a per-identifier field — see
29+
# docs/cookbook/dpop-sender-binding.md #6.
2830

2931
inner:
3032
type: jwt # any registered Identifier

docs/operations/dpop-key-rotation-runbook.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ config:
5858
config:
5959
required: true
6060
skew: 30s
61-
replayCacheSize: 10000
6261
6362
# Pinned proof keys with rotation metadata
6463
pinnedKeys:

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/alicebob/miniredis/v2 v2.38.0
77
github.com/authzed/authzed-go v1.10.0
88
github.com/authzed/grpcutil v0.0.0-20260105210157-e237581949c2
9+
github.com/beevik/etree v1.6.0
910
github.com/coder/websocket v1.8.15
1011
github.com/envoyproxy/go-control-plane/envoy v1.37.0
1112
github.com/fsnotify/fsnotify v1.10.1
@@ -16,6 +17,7 @@ require (
1617
github.com/opencontainers/go-digest v1.0.0
1718
github.com/opencontainers/image-spec v1.1.1
1819
github.com/prometheus/client_golang v1.23.2
20+
github.com/russellhaering/goxmldsig v1.6.0
1921
github.com/tetratelabs/wazero v1.11.0
2022
github.com/valkey-io/valkey-go v1.0.74
2123
go.opentelemetry.io/otel v1.43.0
@@ -75,6 +77,7 @@ require (
7577
github.com/google/gnostic-models v0.7.1 // indirect
7678
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
7779
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
80+
github.com/jonboulle/clockwork v0.5.0 // indirect
7881
github.com/json-iterator/go v1.1.12 // indirect
7982
github.com/jzelinskie/stringz v0.0.3 // indirect
8083
github.com/lestrrat-go/blackmagic v1.0.4 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ github.com/authzed/authzed-go v1.10.0 h1:GUPzYFnStk1PIBZOkQzqcYA3iHaOlVYVl1UnHIR
2222
github.com/authzed/authzed-go v1.10.0/go.mod h1:2DL7pg4iqMltwWOSw+wvbEzAK7uRt3545+bkcGYD8D8=
2323
github.com/authzed/grpcutil v0.0.0-20260105210157-e237581949c2 h1:ymPD1ugBsXVUpLIG/lnRn1ndgOrsrki/0ZX7uP/S1GI=
2424
github.com/authzed/grpcutil v0.0.0-20260105210157-e237581949c2/go.mod h1:FLssYBs1DrwuItfI411kzqcV8QSqGb/B7PC6snNhjvU=
25+
github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
26+
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
2527
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
2628
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2729
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@@ -154,6 +156,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDa
154156
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
155157
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
156158
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
159+
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
160+
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
157161
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
158162
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
159163
github.com/jzelinskie/stringz v0.0.3 h1:0GhG3lVMYrYtIvRbxvQI6zqRTT1P1xyQlpa0FhfUXas=
@@ -236,6 +240,8 @@ github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8A
236240
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
237241
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
238242
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
243+
github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks=
244+
github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM=
239245
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
240246
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
241247
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=

internal/controlplane/api/endpoints_alerts.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,18 @@ func (s *Server) handlePutRules(w http.ResponseWriter, r *http.Request) {
160160
return
161161
}
162162
// Validate the override set before persisting: each rule must have
163-
// a unique name and a sane (positive) For/Window when Enabled.
163+
// a unique name. The Query and Source fields are rejected —
164+
// operators can only override Threshold, For, Window, Enabled,
165+
// and Severity on existing built-in rules. This prevents
166+
// PromQL/LogQL injection via the API: the built-in catalog's
167+
// queries are operator-reviewed at compile time and cannot be
168+
// replaced at runtime.
169+
knownRules := map[string]bool{}
170+
if s.AlertEngine != nil {
171+
for _, r := range s.AlertEngine.Rules() {
172+
knownRules[r.Name] = true
173+
}
174+
}
164175
seen := map[string]bool{}
165176
for i, r := range req.Overrides {
166177
if r.Name == "" {
@@ -172,6 +183,25 @@ func (s *Server) handlePutRules(w http.ResponseWriter, r *http.Request) {
172183
return
173184
}
174185
seen[r.Name] = true
186+
// Reject custom rules (name not in the built-in catalog) —
187+
// only overrides of existing defaults are allowed via the
188+
// API. New custom rules must be added to the ConfigMap
189+
// directly by the operator.
190+
if !knownRules[r.Name] {
191+
writeError(w, http.StatusBadRequest, "override["+strconv.Itoa(i)+"]: unknown rule name "+strconv.Quote(r.Name)+" — only overrides of built-in rules are allowed via the API")
192+
return
193+
}
194+
// Reject Query and Source overrides — these are the
195+
// PromQL/LogQL injection vectors. Operators who need to
196+
// change a query must edit the ConfigMap directly.
197+
if r.Query != "" {
198+
writeError(w, http.StatusBadRequest, "override["+strconv.Itoa(i)+"]: query field cannot be set via the API — edit the ConfigMap directly")
199+
return
200+
}
201+
if r.Source != "" {
202+
writeError(w, http.StatusBadRequest, "override["+strconv.Itoa(i)+"]: source field cannot be set via the API — edit the ConfigMap directly")
203+
return
204+
}
175205
}
176206
if err := s.RulesLoader.PutRules(r.Context(), req.Overrides); err != nil {
177207
writeError(w, http.StatusInternalServerError, err.Error())

internal/controlplane/api/probe.go

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,18 @@ import (
2020
// - Only HTTPS URLs are accepted (no http://, no file://, etc.).
2121
// - Timeout: 5 s.
2222
// - Maximum 2 redirects followed.
23-
// - Private / link-local / loopback IP ranges are not blocked here because
24-
// IdPs may legitimately be in-cluster (they are reachable from the CP pod
25-
// via ClusterIP); the operator is responsible for network policy.
23+
// - The target host is validated via validateHost (ssrf.go), which
24+
// resolves the hostname and checks every returned IP against
25+
// loopback, link-local, and cloud-metadata ranges — not just a
26+
// static hostname list — on the initial URL AND on every redirect
27+
// target (SSRF redirect bypass defence). A hostname that isn't an
28+
// IP literal and isn't a known metadata host would otherwise pass
29+
// a naive string-only check even when it resolves to an internal
30+
// or metadata address.
31+
// - In-cluster service hostnames (.svc.cluster.local) are allowed
32+
// because operators may run their IdP inside the cluster, so
33+
// private RFC1918 ranges are permitted here (allowPrivate=true) —
34+
// unlike handleRegisterInstance, which blocks them.
2635
//
2736
// Route: GET /v1/controlplane/probe/url?url=<encoded>
2837
func (s *Server) handleProbeURL(w http.ResponseWriter, r *http.Request) {
@@ -37,9 +46,11 @@ func (s *Server) handleProbeURL(w http.ResponseWriter, r *http.Request) {
3746
writeError(w, http.StatusBadRequest, "only https:// URLs are allowed")
3847
return
3948
}
40-
// Reject bare hostnames that look like private/metadata endpoints typically
41-
// exploited in SSRF, while still allowing in-cluster HTTPS IdPs.
42-
if isBlockedHost(parsed.Hostname()) {
49+
// Reject SSRF pivot targets (loopback, link-local, cloud metadata).
50+
// allowPrivate=true: in-cluster IdP hostnames commonly resolve to
51+
// RFC1918 addresses, and that's an intentional, documented use case
52+
// for this endpoint (unlike instance registration).
53+
if err := validateHost(parsed.Hostname(), true); err != nil {
4354
writeError(w, http.StatusBadRequest, "target host is not allowed")
4455
return
4556
}
@@ -57,6 +68,14 @@ func (s *Server) handleProbeURL(w http.ResponseWriter, r *http.Request) {
5768
if !strings.EqualFold(req.URL.Scheme, "https") {
5869
return http.ErrUseLastResponse
5970
}
71+
// SECURITY: Re-validate the redirect target host with the
72+
// same check applied to the initial URL. Without this an
73+
// attacker can redirect from a safe host to a cloud
74+
// metadata or internal endpoint, bypassing the initial
75+
// check.
76+
if validateHost(req.URL.Hostname(), true) != nil {
77+
return http.ErrUseLastResponse
78+
}
6079
return nil
6180
},
6281
}
@@ -93,25 +112,3 @@ func (s *Server) handleProbeURL(w http.ResponseWriter, r *http.Request) {
93112
"statusCode": resp.StatusCode,
94113
})
95114
}
96-
97-
// isBlockedHost blocks a small list of well-known SSRF pivot targets.
98-
// In-cluster service hostnames (.svc.cluster.local) are intentionally allowed
99-
// because operators may run their IdP inside the cluster.
100-
func isBlockedHost(host string) bool {
101-
// Strip port if present.
102-
if i := strings.LastIndex(host, ":"); i != -1 && strings.Count(host, ":") == 1 {
103-
host = host[:i]
104-
}
105-
blocked := []string{
106-
"169.254.169.254", // AWS/GCP/OCI IMDS
107-
"metadata.google.internal",
108-
"instance-data",
109-
}
110-
lower := strings.ToLower(host)
111-
for _, b := range blocked {
112-
if lower == b {
113-
return true
114-
}
115-
}
116-
return false
117-
}

0 commit comments

Comments
 (0)