Skip to content

Security

This page describes the security controls built into DroidFarm. Each section covers a specific concern area and what the system does to address it.


RBAC

The operator service account uses a split RBAC model to limit blast radius:

  • A lean ClusterRole (*-operator-cluster) covers only genuinely cluster-scoped resources: custom resource types under droidfarm.io and nodes (read-only).
  • A namespace-scoped Role (*-operator) in the operator's own namespace covers pods (no exec), services, configmaps, secrets (no delete), events, gateway.networking.k8s.io HTTPRoutes, jobs, and leases. The Gateway itself is owned by the cluster operator (the shared Cilium Gateway in kube-system, or a dedicated chart-rendered Gateway if streaming.gateway.enabled=true); the DroidFarm operator only writes per-device HTTPRoute objects in its own namespace.
  • Both are bound to the same service account via a ClusterRoleBinding and a RoleBinding respectively.

pods/exec is absent from both roles — the operator drives devices through the Appium sidecar, not direct exec.


Operator Pod Security

The operator pod runs with a hardened security context:

Setting Value Rationale
pod.securityContext.runAsNonRoot true Enforced at pod level
pod.securityContext.runAsUser 65532 Distroless nonroot UID; matches the base image
pod.securityContext.runAsGroup 65532 Consistent file ownership
container.securityContext.allowPrivilegeEscalation false Prevents setuid/setcap privilege gain
container.securityContext.readOnlyRootFilesystem true Filesystem is immutable at runtime
container.securityContext.capabilities.drop [ALL] Removes all Linux capabilities

Emulator Pod Capabilities

Emulator pods require elevated capabilities to run the Cuttlefish hypervisor. No --privileged mode is used. The pod runs with:

  • NET_ADMIN — manage network interfaces inside the container
  • NET_RAW — raw socket access for Cuttlefish networking
  • SYS_ADMIN — mount and cgroup operations inside the VM

The default cuttlefish-qemu backend does not require any device plugin. No host devices are mapped and no privileged DaemonSet is needed.

When using the optional cuttlefish (KVM) backend, device access is granted via the squat/generic-device-plugin DaemonSet using Kubernetes resource limits (squat.ai/kvm: "1", squat.ai/vhost: "3"), keeping /dev/kvm and related devices off the host filesystem path. That DaemonSet runs with securityContext.privileged: true — this is an unavoidable requirement of the Kubernetes Device Plugin API (the same pattern used by NVIDIA and AMD GPU plugins). Its blast radius is limited by a nodeSelector restricting it to KVM-capable nodes and by the absence of any RBAC bindings.


CRD Input Validation

User-controlled string fields that flow into shell commands are validated at the Kubernetes API level via OpenAPI schema constraints in the CRD manifests:

Field Validation
appConfig[].packageName pattern: '^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$', maxLength: 255
appConfig[].apkSource.url pattern: '^https?://.+', maxLength: 2048 — blocks file://, data:, ftp:// schemes
systemConfig.extraProperties maxProperties: 50, value maxLength: 91 (Android's PROP_VALUE_MAX), CEL rule blocking shell metacharacters (;, &, |, `, $) in keys
managedConfig[].key maxLength: 256
managedConfig[].value maxLength: 4096

The CEL validation rule on extraProperties requires Kubernetes 1.25+ (CustomResourceValidationExpressions feature gate, GA in 1.29). On older clusters, remove the x-kubernetes-validations block and rely on the pattern/maxLength constraints alone.


TURN Credentials

TURN credentials for coturn are stored in a Kubernetes Secret object (coturn-secret.yaml), not in values.yaml.

  • turn.credentials.createSecret: true creates the Secret from values passed via --set at install time.
  • turn.credentials.existingSecret accepts the name of a Secret provisioned out-of-band (Vault, External Secrets Operator, Sealed Secrets) for production environments.
  • Credentials are injected into the coturn container via secretKeyRef environment variables (COTURN_USERNAME, COTURN_PASSWORD) and passed as --user=$(COTURN_USERNAME):$(COTURN_PASSWORD). Kubernetes performs variable substitution at the kubelet level, keeping credentials out of visible argument strings.

Never commit TURN credentials to values files or GitOps repositories. Use --set or an encrypted values overlay (SOPS + age, Helm Secrets).


Per-Device HTTPRoute Edge Boundary

The per-device HTTPRoute is the auth boundary between the public internet and a single emulator. The cluster's shared Cilium Gateway (kube-system/cilium-gateway, listener section https) terminates TLS; each device is then exposed only via one HTTPRoute (<pool>-device-<ordinal>) that matches a specific hostname <pool>-<ordinal>.<streaming.domain> and forwards exclusively to that device's Service.

Property Value Rationale
Hostname scope One hostname per device A leaked URL exposes one device, not the fleet
Path scope / (catch-all to the envoy sidecar) The envoy sidecar is the only thing that ever sees the request
TLS Terminated at the shared Gateway via cert-manager Certificate No long-lived TLS material in emulator pods
Listener hostname *.<streaming.domain> wildcard on the shared Gateway's https listener New devices reuse the same certificate; no per-device cert issuance
Route lifetime Created on pod ready, deleted on pod recycle A removed device's URL stops resolving at the gateway

Authentication itself is enforced by the layer in front of the Gateway (your IdP / OIDC proxy of choice). The point of the per-device route is to give that layer a stable per-device identifier — the hostname — to attach policy to.


Network Policies

Six NetworkPolicy objects segment traffic between components:

Policy Effect
emulator-default-deny Deny all ingress and egress for emulator pods (baseline)
emulator-allow-ingress Allow inbound on ports 8080 (envoy / gRPC-Web), 8443 (WebRTC), 5554 (ADB), 4723 (Appium)
emulator-allow-egress Allow outbound to coturn (TURN ports) and DNS only
operator-allow-api-server Allow operator → Kubernetes API server (443) and DNS
coturn-allow-ingress Allow TURN signaling ingress on 3478/5349
coturn-allow-egress Allow coturn full egress (required for UDP media relay)

Tightening options:

  • emulator-allow-ingress allows any cluster-internal source. Add a from.podSelector pointing at your CI runner or the Cilium gateway pods to restrict further.
  • coturn-allow-egress is fully open, which is necessary for a TURN relay serving external peers. If all peers are cluster-internal, restrict to a podSelector.
  • operator-allow-api-server allows egress to any IP on port 443. Pin to an ipBlock in clusters where the API server address is stable.

Residual Risks

Risk Mitigation
coturn credentials stored in Helm release history Enable Helm Secrets backend encryption (SOPS + age) or use an external secrets operator
Emulator pods run with NET_ADMIN, NET_RAW, SYS_ADMIN Required by Cuttlefish (see ADR-001); cannot be removed without breaking virtualization
Device plugin runs privileged on KVM nodes (optional path) Only deployed when using emulationBackend: cuttlefish; unavoidable Device Plugin API requirement; mitigated by nodeSelector
extraProperties CEL metachar rule requires Kubernetes 1.25+ Verify target cluster version before deploying

Running a Security Scan

Scan the operator image for known CVEs:

trivy image ghcr.io/christopherime/droidfarm-operator:latest

Scan the rendered Helm chart manifests with kubesec:

helm template charts/droidfarm/ | kubesec scan /dev/stdin