Security¶
This document covers the security policy and best practices for the Cloudflare Tunnel Gateway Controller.
Supported Versions¶
| Version | Supported |
|---|---|
| 3.x.x | Yes |
| 2.x.x | No |
Reporting Vulnerabilities¶
Do Not Use Public Issues
Please do not report security vulnerabilities through public GitHub issues.
Report vulnerabilities via email:
- Email: f@lex.la
- GPG Key:
F57F 85FC 7975 F22B BC3F 2504 9C17 3EB1 B531 AA1F
What to Include¶
- Type of vulnerability
- Full paths of affected source files
- Location of affected source code (tag/branch/commit)
- Step-by-step reproduction instructions
- Proof-of-concept or exploit code (if possible)
- Impact assessment
Response Timeline¶
| Stage | Timeline |
|---|---|
| Initial Response | Within 48 hours |
| Status Update | Within 7 days |
| Fix Timeline | Depends on severity |
Security Best Practices¶
API Token Management¶
The Cloudflare API token is sensitive and should be:
-
Stored in Kubernetes Secret
-
Scoped with minimum permissions
-
Account: Cloudflare Tunnel (Edit, Read)
-
Rotated regularly
- Create new token in Cloudflare dashboard
- Update Kubernetes secret
-
Controller picks up new token on restart
-
Never committed to git
- Use external secret management (Vault, AWS Secrets Manager)
RBAC Configuration¶
The controller requires specific Kubernetes permissions:
# Minimum required permissions (v3) -- matches charts/.../templates/clusterrole.yaml
rules:
# Gateway API - read specs
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["httproutes", "grpcroutes", "referencegrants", "backendtlspolicies", "listenersets"]
verbs: ["get", "list", "watch"]
# GatewayClasses - the controller manages the spec-defined gateway-exists
# finalizer (metadata write outside the status subresource)
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["gatewayclasses"]
verbs: ["get", "list", "watch", "update", "patch"]
# Gateways - status patches require update/patch on the parent resource
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["gateways"]
verbs: ["get", "list", "watch", "update", "patch"]
# Gateway API status subresources - write status
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["gatewayclasses/status", "gateways/status", "httproutes/status", "grpcroutes/status", "backendtlspolicies/status", "listenersets/status"]
verbs: ["get", "update", "patch"]
# ServiceImport - a backendRef may target an imported multicluster Service
- apiGroups: ["multicluster.x-k8s.io"]
resources: ["serviceimports"]
verbs: ["get", "list", "watch"]
# CustomResourceDefinitions - single Get of the gatewayclasses CRD to read
# the bundle-version annotation for the SupportedVersion condition
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get"]
# Core API
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list", "watch"]
# Services - read everywhere (backend resolution) plus full write for the
# per-Gateway data planes: the controller renders a headless config Service
# per opted-in Gateway. Rendered objects are controller-owned via
# ownerReferences and deleted only when owned.
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
# Secrets - read for credentials; create for the generated config-API auth Secret, both the per-Gateway one and the shared plane's (no update/delete: token never rotated).
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "create"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
# EndpointSlice - the proxy endpoint reconciler discovers proxy pods so a
# newly-joined replica gets the cached config pushed immediately
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list", "watch"]
# Events - route reconcilers emit Events via both the core (v1) and the new
# (events.k8s.io/v1) recorders; grant both so neither path is denied
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
- apiGroups: ["events.k8s.io"]
resources: ["events"]
verbs: ["create", "patch"]
# Deployments - the proxy Secret reconciler patches the proxy Deployment's
# pod-template annotation to roll pods when the tunnel-token Secret rotates,
# and the per-Gateway data planes render a dedicated proxy Deployment per
# opted-in Gateway (full write, cluster-wide, because Gateways live in
# arbitrary namespaces)
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# HorizontalPodAutoscalers - rendered per opted-in Gateway when its
# GatewayConfig requests autoscaling
- apiGroups: ["autoscaling"]
resources: ["horizontalpodautoscalers"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
# NetworkPolicies - rendered per opted-in Gateway to lock the proxy's
# config-API port to the controller (+ monitoring) namespaces
- apiGroups: ["networking.k8s.io"]
resources: ["networkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
# GatewayConfig CRD - per-Gateway data-plane parameters referenced from
# Gateway.spec.infrastructure.parametersRef
- apiGroups: ["cf.k8s.lex.la"]
resources: ["gatewayconfigs"]
verbs: ["get", "list", "watch"]
# GatewayClassConfig CRD
- apiGroups: ["cf.k8s.lex.la"]
resources: ["gatewayclassconfigs"]
verbs: ["get", "list", "watch"]
- apiGroups: ["cf.k8s.lex.la"]
resources: ["gatewayclassconfigs/status"]
verbs: ["get", "update", "patch"]
# ExternalBackend CRD - a backendRef may target an out-of-cluster endpoint
- apiGroups: ["cf.k8s.lex.la"]
resources: ["externalbackends"]
verbs: ["get", "list", "watch"]
# Leader election
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
RBAC scope
The controller reads Secrets and ConfigMaps and writes status subresources. Its workload writes are scoped to the data planes it owns: patching the shared proxy Deployment's pod-template annotation when the tunnel-token Secret rotates (a native rolling restart), and rendering a dedicated proxy Deployment, headless config Service, and optional HorizontalPodAutoscaler for each Gateway opted into a per-Gateway data plane via infrastructure.parametersRef. Those rendered objects are controller-owned via ownerReferences, kept in sync against drift, and deleted only when actually owned — a name collision with a user resource can never turn into a deletion. Workload write access is cluster-wide because Gateways live in arbitrary namespaces.
Because the RBAC grant for these resources is broad (delete on Deployments, Services, and HorizontalPodAutoscalers cluster-wide), the in-code ownership check is the security boundary, not the RBAC scope. Every apply and create path for a per-Gateway rendered object — including that plane's generated config-API auth Secret — refuses to adopt, update, or GC an object at a rendered name unless it already carries this Gateway's controller ownerReference. A pre-existing object with a foreign owner (or none) is left untouched and the reconcile surfaces a RenderFailed event instead of overwriting it. The shared plane's generated auth Secret (below) has no Gateway to check ownership against, so it has no equivalent adoption check: it reuses whatever Secret already exists at its deterministic name unconditionally, by design. This is safe specifically because that Secret always lives in the controller's own release namespace — anyone able to create a Secret there could already replace the controller's Deployment, so an ownership check on this one Secret would not defend anything the namespace boundary doesn't already defend. The per-Gateway check above exists because that Secret lives in an arbitrary tenant namespace instead, where no such trust is implied. See Config API Authentication.
Multi-Tenancy¶
Tenant isolation is layered: admission-level scoping (per-tenant listeners, allowedListeners/allowedRoutes, the opt-in hostname-ownership ValidatingAdmissionPolicy), an independent controller-side enforcement of the same hostname-ownership rule (a route that bypasses admission is still never programmed), and optional hard data-plane isolation with a dedicated proxy and tunnel per Gateway. The boundaries and trade-offs are documented in the Multi-Tenancy guide and the Per-Gateway Isolation guide.
GatewayConfig is workload-creation-equivalent
Because the controller renders Deployments for opted-in Gateways and GatewayConfig.spec.image selects the container image, granting a user create on GatewayConfig (plus a Gateway with infrastructure.parametersRef) is privilege-equivalent to granting create on Deployments in that namespace: the controller becomes the deputy that runs the chosen image under the namespace's default ServiceAccount (the rendered pod disables the SA-token mount, since the proxy needs no API access). Treat RBAC on gatewayconfigs accordingly. A rendered data plane's config API is authenticated by default — the controller generates a per-Gateway bearer-token Secret when authTokenSecretRef is unset — and network-restricted by default — the controller renders a NetworkPolicy per data plane admitting the config API port only from the controller's namespace, not the tenant's (set proxy.networkPolicy.monitoringNamespaceSelector to also admit your monitoring namespace for scraping). See the Per-Gateway Isolation guide.
Container Security¶
The controller container follows security best practices:
| Setting | Value | Rationale |
|---|---|---|
runAsNonRoot | true | Never run as root |
runAsUser | 65534 | nobody user |
readOnlyRootFilesystem | true | Prevent filesystem modifications |
allowPrivilegeEscalation | false | Prevent privilege escalation |
capabilities.drop | ALL | Drop all Linux capabilities |
seccompProfile.type | RuntimeDefault | Use default seccomp profile |
Network Security¶
Config API Authentication¶
The shared proxy's config API (where the controller pushes the routing table) is authenticated and network-restricted by default, matching the per-Gateway data planes described above. When proxy.authTokenSecretRef.name is left empty, the controller itself generates a random bearer token into a Secret (<fullname>-proxy-auth-token, where <fullname> is the Helm release fullname, typically <release>-cloudflare-tunnel-gateway-controller) on startup and uses it directly for its own push auth, and the proxy reads the same Secret via a pod-level secretKeyRef; the token is created once and reused on every restart, never rotated. Generating it via a live API call rather than at Helm template time means this is correct under GitOps controllers that render client-side with no cluster access (e.g. ArgoCD's default helm template), where a template-time lookup would silently mint a fresh value on every sync. proxy.networkPolicy.enabled (default true) additionally locks the config-API port to the controller's own namespace. Set proxy.authTokenSecretRef.name to bring your own Secret instead — the controller resolves it through the same direct-API mechanism, never a secretKeyRef on its own pod, and never creates or modifies it: a missing bring-your-own Secret fails the controller closed rather than silently generating one at the operator's chosen name. Set proxy.networkPolicy.enabled: false to drop the NetworkPolicy on a cluster where it would be inert or unwanted — see the Helm values reference.
Egress Requirements¶
The controller only needs egress to:
| Destination | Port | Purpose |
|---|---|---|
api.cloudflare.com | 443 | Cloudflare API |
| Kubernetes API | 443/6443 | Watch resources |
NetworkPolicy Example¶
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: cloudflare-tunnel-gateway-controller
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: cloudflare-tunnel-gateway-controller
policyTypes:
- Ingress
- Egress
ingress:
# Prometheus scraping
- from:
- namespaceSelector:
matchLabels:
name: monitoring
ports:
- port: 8080
egress:
# Kubernetes API and Cloudflare API
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 443
- port: 6443
# DNS
- to: []
ports:
- port: 53
protocol: UDP
Supply Chain Security¶
Container Image Verification¶
Container images are signed with cosign (keyless):
cosign verify ghcr.io/lexfrei/cloudflare-tunnel-gateway-controller:latest \
--certificate-identity-regexp="https://github.com/lexfrei/cloudflare-tunnel-gateway-controller" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com"
Helm Chart Verification¶
Secrets in Logs¶
The controller is designed to never log sensitive information:
- API tokens are not logged
- Tunnel tokens are not logged
- Secret contents are not logged
Report Log Leaks
If you find sensitive data in logs, please report it as a security issue.
Security Scanning¶
The project uses automated security scanning:
| Tool | Purpose |
|---|---|
| Trivy | Vulnerability scanning in CI |
| gosec | Go security linter |
| Dependabot/Renovate | Dependency updates |
Incident Response¶
If you believe the controller has been compromised:
- Revoke Cloudflare API token immediately
- Delete the controller deployment
- Review Cloudflare audit logs for unauthorized changes
- Rotate tunnel credentials if needed
- Report the incident via security email
Secure Deployment Checklist¶
- API token stored in Kubernetes Secret (not in values.yaml)
- API token has minimal required permissions
- Controller running as non-root
- Read-only root filesystem enabled
- NetworkPolicy restricting egress
- ServiceAccount with minimal RBAC
- Container image verified with cosign
- Prometheus monitoring enabled
- Alerts configured for anomalous behavior