Host Filtering
Host filtering is Kloak's mechanism for restricting which TLS destinations can receive a secret's real value. Even if an attacker gains code execution inside your container, they cannot exfiltrate secrets to unauthorized hosts -- the eBPF program will refuse to perform the rewrite.
Why Host Filtering Matters
Without host filtering, any outbound TLS connection from a Kloak-enabled pod could receive the real secret value. Consider this scenario:
- Your application sends an API key to
api.stripe.comin theAuthorizationheader - An attacker exploits an SSRF vulnerability and makes your app send the same header to
evil.attacker.com - Without host filtering, the eBPF program rewrites the
kloak:placeholder for both destinations
With host filtering enabled, the eBPF program checks the TLS connection's destination hostname. If it does not match the allowed host, the placeholder is not rewritten -- the remote server receives the harmless kloak:… placeholder instead of your real secret.
DANGER
Without host filtering, Kloak protects secrets from being visible in application memory, but does not prevent network-level exfiltration. Always configure getkloak.io/hosts for production secrets.
Configuring Host Filtering
Add the getkloak.io/hosts label to your Secret with a single allowed hostname, next to the getkloak.io/enabled label:
apiVersion: v1
kind: Secret
metadata:
name: stripe-api-key
labels:
getkloak.io/enabled: "true"
getkloak.io/hosts: "api.stripe.com"
type: Opaque
data:
api-key: c2stbGl2ZS1rZXktMTIzNDU2Nzg5MA== # sk-live-key-1234567890Or using kubectl:
kubectl create secret generic stripe-api-key \
--from-literal=api-key="sk-live-key-1234567890" \
-n payments --dry-run=client -o yaml | \
kubectl label -f - getkloak.io/enabled="true" --local -o yaml | \
kubectl label -f - getkloak.io/hosts="api.stripe.com" --local -o yaml | \
kubectl apply -f -DANGER
In this release getkloak.io/hosts and getkloak.io/port are labels. Set as annotations, they are ignored: the secret has no host or port restriction and is rewritten for any destination. Nothing catches this at admission. (They became annotations in v0.1.2.)
The value is one lowercase DNS name (RFC 1123, max 63 bytes, the label value limit). IP addresses are not supported in this release: an address is treated as a hostname, never matches, and the secret is never rewritten. * is not a valid label value: omit the label to allow any host.
Multiple Allowed Hosts
WARNING
Multiple hosts per secret are not supported yet. getkloak.io/hosts holds a single hostname, and label values can't contain commas. To restrict a secret to more than one destination today, create a separate secret per host. Multi-host support is tracked in spinningfactory/kloak#102.
To filter by port instead of (or in addition to) host, use the separate getkloak.io/port label with a TCP port number (e.g. "443") — a port cannot be appended to the hostname.
No Host Filter (Wildcard)
If the getkloak.io/hosts label is omitted or empty, the secret is allowed for all hosts:
metadata:
labels:
getkloak.io/enabled: "true"
# No getkloak.io/hosts label = rewrite for any destinationHow Host Resolution Works
Kloak uses DNS-verified host filtering — a language-agnostic approach that works identically for all supported TLS runtimes (Go, Python, Node.js, etc.) without depending on SNI or HTTP headers.
DNS-Verified Trust Chain
The eBPF program builds a chain of trust from DNS resolution to TLS write:
DNS Capture — A kprobe on the kernel's
udp_recvmsgfunction intercepts DNS responses on the node. Only responses from trusted DNS servers are accepted: the kube-dns ClusterIP (auto-discovered) plus any servers set incontroller.dns.trustedServers(the--trusted-dns-serversflag). For hostnames listed ingetkloak.io/hostslabels (thewatched_hostsset), the resolved A/AAAA record IPs are stored indns_ip_mapwith their TTL.Connection Tracking — Tracepoints on
sys_enter_connectandsys_exit_connectrecord every connection's file descriptor → destination IP and port inconn_ip_map. If the destination IP exists indns_ip_map, the fd is recorded inlast_verified_fdfor that process.Host Resolution at TLS Write Time — When the TLS write function is called,
resolve_host()finds the connection's fd (for OpenSSL, read from the SSL object's write BIO and cached per SSL object; otherwise fromlast_verified_fd), then maps fd →conn_ip_map→dns_ip_mapto get the verified hostname.Secret Filtering — The resolved hostname is compared exactly against the secret's allowed host (and the destination port against any port filter). Match → secret is rewritten. Mismatch → placeholder sent as-is.
TTL Enforcement — DNS entries include a TTL from the original DNS response. Expired entries are skipped on lookup, forcing re-verification through fresh DNS responses.
Connection Cleanup — A tracepoint on
sys_enter_closeremovesconn_ip_mapentries when file descriptors are closed, preventing stale mappings from being used after fd reuse.
TIP
This approach is language-agnostic — it works the same way for Go, Python, Node.js, and any OpenSSL-based runtime. No SNI capture or HTTP header parsing is needed.
Host Resolution Flow
| Runtime | TLS Hook | Host Resolution Method |
|---|---|---|
| Python (OpenSSL) | SSL_write uprobe | DNS-verified via udp_recvmsg kprobe |
| Node.js (bundled OpenSSL) | SSL_write uprobe | DNS-verified via udp_recvmsg kprobe |
| Go (crypto/tls) | crypto/tls.(*Conn).Write uprobe | DNS-verified via udp_recvmsg kprobe |
| Rust, Ruby, PHP, curl | SSL_write / SSL_write_ex uprobe | DNS-verified via udp_recvmsg kprobe |
Practical Examples
Example 1: Stripe API Key (Single Host)
Only allow the secret to be sent to Stripe's API:
apiVersion: v1
kind: Secret
metadata:
name: stripe-key
labels:
getkloak.io/enabled: "true"
getkloak.io/hosts: "api.stripe.com"
type: Opaque
data:
key: c2stbGl2ZS1rZXktMTIzNDU2Nzg5MA== # sk-live-key-1234567890Result:
- Request to
https://api.stripe.com/v1/charges-- secret is rewritten with real value - Request to
https://evil.example.com/steal-- secret remains as thekloak:…placeholder
Example 2: Two Secrets, Different Hosts
A common pattern: one secret for an allowed API, another restricted to a different host:
# Secret allowed for httpbin.org
kubectl create secret generic secret-allowed \
--from-literal=api-key="sk-live-key-1234567890" \
-n demo --dry-run=client -o yaml | \
kubectl label -f - getkloak.io/enabled="true" --local -o yaml | \
kubectl label -f - getkloak.io/hosts="httpbin.org" --local -o yaml | \
kubectl apply -f -
# Secret only allowed for example.com
kubectl create secret generic secret-blocked \
--from-literal=api-key="sk-live-REAL-SECRET-KEY-12345" \
-n demo --dry-run=client -o yaml | \
kubectl label -f - getkloak.io/enabled="true" --local -o yaml | \
kubectl label -f - getkloak.io/hosts="example.com" --local -o yaml | \
kubectl apply -f -When the application sends both secrets to httpbin.org:
X-Secret-Allowed: sk-live-key-1234567890 # Replaced -- host matches
X-Secret-Blocked: kloak:XD9BX2HXJRYFCTE001KQ1CP # NOT replaced -- host mismatchExample 3: Raw TLS Filtering (Non-HTTP)
Host filtering works even for non-HTTP TLS protocols. The DNS resolution of the hostname is what enables host verification — no HTTP headers or SNI capture required:
import ssl
import socket
ctx = ssl.create_default_context()
# DNS resolution of "api.stripe.com" is captured by the kprobe
# and stored in dns_ip_map for host verification
with socket.create_connection(("api.stripe.com", 443)) as sock:
with ctx.wrap_socket(sock, server_hostname="api.stripe.com") as tls:
tls.sendall(b"secret data containing a kloak: placeholder here")Verifying Host Filtering
Check Controller Logs
With trace-level logging enabled (--set log.level=trace), the controller logs each secret it syncs into the eBPF map, including the host restriction:
kubectl logs -n kloak-system -l app.kubernetes.io/component=controller | grep "synced secret into eBPF map"Each line carries the fields secret, key, hostLen, port, and protocol.
A hostLen greater than 0 confirms a hostname filter is active. A hostLen of 0 means there is no hostname filter.
Test with httpbin
The chart's optional demo (--set demo.enabled=true) sends two secrets to https://httpbin.org/headers: one meant for httpbin.org, one meant only for example.com. Check the responses it logs:
kubectl logs -n kloak-demo -l app.kubernetes.io/component=demo -c demoYou should see the allowed secret replaced with the real value and the blocked secret still showing the kloak: placeholder. The demo is a Go app, and Go uses HTTP/2 by default, so it is affected by the HTTP/2 known issue of this release.
Security Considerations
- Host verification is DNS-based. The trust chain depends on the integrity of DNS responses. Only responses from trusted DNS servers (kube-dns, auto-discovered, plus
controller.dns.trustedServers) are accepted, but a compromised or spoofed trusted resolver could still mislead the host filter. - DNS entries have TTL enforcement. Expired entries are skipped, forcing re-verification through fresh DNS responses. This limits the window for stale IP → hostname mappings.
- Hostnames are limited to 63 bytes, the longest label value Kubernetes accepts. This covers the vast majority of real-world API endpoints. Use lowercase: a hostname with uppercase letters never matches the names in DNS answers.
- Wildcard matching is not supported. You must specify exact hostnames.
*.stripe.comwill not work -- useapi.stripe.comexplicitly. - Host filtering is enforced in-kernel by eBPF. Application code cannot bypass it, even with arbitrary code execution in the container. The ciphertext is patched on the pod's own
eth0, inside the pod's network namespace, where a container withCAP_NET_RAWcould capture the patched ciphertext -- dropNET_RAWfor Kloak-enabled workloads. - Any kloak-enabled pod can redeem a placeholder. A pod that learns another workload's placeholder can have it rewritten, as long as it sends it to the secret's allowed host. Per-pod secret binding was added in v0.2.0.
- DNS and connection tracking are global on the node. All DNS responses and TCP connections are monitored (DNS is filtered by trusted servers and
watched_hosts). This is necessary for containerized environments where DNS proxies may handle resolution in a different process context.