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 underdroidfarm.ioand 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.ioHTTPRoutes, jobs, and leases. The Gateway itself is owned by the cluster operator (the shared Cilium Gateway inkube-system, or a dedicated chart-rendered Gateway ifstreaming.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
ClusterRoleBindingand aRoleBindingrespectively.
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 containerNET_RAW— raw socket access for Cuttlefish networkingSYS_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: truecreates the Secret from values passed via--setat install time.turn.credentials.existingSecretaccepts 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
secretKeyRefenvironment 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-ingressallows any cluster-internal source. Add afrom.podSelectorpointing at your CI runner or the Cilium gateway pods to restrict further.coturn-allow-egressis fully open, which is necessary for a TURN relay serving external peers. If all peers are cluster-internal, restrict to apodSelector.operator-allow-api-serverallows egress to any IP on port 443. Pin to anipBlockin 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:
Scan the rendered Helm chart manifests with kubesec: