Skip to content

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:

  1. Stored in Kubernetes Secret

    kubectl create secret generic cloudflare-credentials \
      --from-literal=api-token="${CF_API_TOKEN}"
    
  2. Scoped with minimum permissions

  3. Account: Cloudflare Tunnel (Edit, Read)

  4. Rotated regularly

  5. Create new token in Cloudflare dashboard
  6. Update Kubernetes secret
  7. Controller picks up new token on restart

  8. Never committed to git

  9. 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

helm verify cloudflare-tunnel-gateway-controller-<version>.tgz

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:

  1. Revoke Cloudflare API token immediately
  2. Delete the controller deployment
  3. Review Cloudflare audit logs for unauthorized changes
  4. Rotate tunnel credentials if needed
  5. 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