ContainerSSH Kubernetes Backend: Hardened Pod-per-Session SSH Access

ContainerSSH Kubernetes Backend: Hardened Pod-per-Session SSH Access

The Problem

ContainerSSH is an SSH server that, rather than granting shell access to the host it runs on, proxies each incoming connection into an ephemeral container. With the Kubernetes backend, that container is a Pod launched by the ContainerSSH service account at connection time and torn down at disconnection. From the user’s perspective, they typed ssh bastion.example.com and landed in an interactive shell. From the cluster’s perspective, a new Pod appeared in a dedicated namespace, attached to the SSH session, and will be deleted when the session ends.

That model offers genuine isolation: no two users share a process namespace, a writable filesystem, or a network identity. But isolation is not the same as security, and the gap between those two properties is exactly where this article lives.

The security of every session Pod is entirely determined by its spec, and the spec is returned dynamically by a config webhook that ContainerSSH calls before Pod creation. If that webhook returns a default spec — or if the operator never configures a hardened template — users receive Pods with allowPrivilegeEscalation: true, automounted service account tokens, no seccomp profile, and no resource limits. Any of those individually is a misconfiguration. Together, they hand an attacker who can reach the SSH listener a privileged foothold inside the cluster.

There are three compounding factors that make the ContainerSSH Kubernetes backend specifically risky:

The service account has create-and-exec permissions by default. ContainerSSH needs to create Pods, wait for them to be ready, and then attach to them via exec. An overly broad service account — particularly one granted cluster-admin for convenience, or one that has pods/* across all namespaces — means that code running inside a session Pod can call the Kubernetes API using the node or namespace service account token and perform operations the operator never intended to permit.

The config webhook response is the entire security boundary. ContainerSSH itself does not harden the Pod spec. It takes whatever the webhook returns and sends it to the Kubernetes API verbatim. If the webhook returns an empty spec, the Pod inherits defaults. If the webhook’s auth is misconfigured and an attacker can forge a webhook response, they control the spec of every session Pod created by the next SSH connection.

Session Pods can outlive their sessions. If ContainerSSH crashes mid-session or is forcibly killed, the session Pod may not be cleaned up. A privileged Pod that persists in the cluster after its session has ended is an orphaned attack surface with no active user monitoring it.

This article hardens all three surfaces: the service account and its RBAC, the Pod spec returned by the config webhook, and the lifecycle management that ensures session Pods do not persist.

Threat Model

Overly permissive ContainerSSH ServiceAccount. ContainerSSH runs with a Kubernetes ServiceAccount to create and manage session Pods. If that ServiceAccount is granted broad permissions — pods/* cluster-wide, or worse, cluster-admin — an attacker who escapes the session Pod container can use the mounted service account token to call the Kubernetes API directly. From a kubectl exec session in the session Pod, the attacker reads secrets across namespaces, lists all workloads, and can create new privileged Pods in any namespace where the service account has create permissions. The session Pod itself becomes a persistence mechanism.

Pod spec with allowPrivilegeEscalation: true. Without an explicit allowPrivilegeEscalation: false in the container’s securityContext, a container process with any file capability set (cap_setuid, cap_setgid) can execute a setuid binary and obtain root within the container. On a misconfigured node with a host path volume or a writable /proc, root in the container becomes a viable path to host escape. Kubernetes defaults are permissive: unless the namespace’s Pod Security Admission enforces restricted, allowPrivilegeEscalation defaults to true.

Missing NetworkPolicy. Session Pods running without a restricting NetworkPolicy can reach any cluster-internal endpoint: the Kubernetes API server, the etcd client port if it is exposed on the control plane subnet, the kubelet API on port 10250 on each node, other Pods in any namespace, and cloud metadata services at 169.254.169.254. An attacker who obtains a shell through a legitimately authenticated SSH session needs only a missing NetworkPolicy to pivot to cluster infrastructure.

Session Pod not deleted on crash. ContainerSSH tracks the lifecycle of session Pods and deletes them when sessions end. But ContainerSSH is itself a process that can crash. A hard OOM kill, a node eviction, or a deployment rollout that terminates ContainerSSH mid-session leaves behind Pods that will not be garbage collected by Kubernetes — Kubernetes only cleans up completed or failed Pods through its Pod garbage collection settings, and a running Pod that is simply no longer monitored stays running indefinitely. If that Pod was created with a non-hardened spec, it is now a privileged shell with no owner watching it.

Config webhook forgery or misconfiguration. The ContainerSSH config webhook is an HTTPS endpoint that ContainerSSH calls on each connection to obtain the session’s Pod spec. If the webhook endpoint is not mutually authenticated, an attacker who can intercept or forge responses can inject an arbitrary Pod spec — hostPID: true, hostNetwork: true, a hostPath volume mounting / — into the next SSH session’s Pod.

Hardening Configuration

1. Dedicated Namespace and Minimal RBAC

Create a dedicated namespace for all SSH session Pods. ContainerSSH itself runs in a separate namespace. The service account that ContainerSSH uses to manage session Pods needs only three permissions in the session namespace: create Pods, delete Pods, and exec into Pods.

# namespace for SSH session Pods
apiVersion: v1
kind: Namespace
metadata:
  name: ssh-sessions
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.28
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted
# ServiceAccount in the ContainerSSH namespace
apiVersion: v1
kind: ServiceAccount
metadata:
  name: containerssh
  namespace: containerssh
automountServiceAccountToken: false
# Role scoped to the ssh-sessions namespace only
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: containerssh-session-manager
  namespace: ssh-sessions
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["create", "delete", "get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods/exec"]
    verbs: ["create"]
  - apiGroups: [""]
    resources: ["pods/log"]
    verbs: ["get"]
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: containerssh-session-manager
  namespace: ssh-sessions
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: containerssh-session-manager
subjects:
  - kind: ServiceAccount
    name: containerssh
    namespace: containerssh

The Role is not a ClusterRole. ContainerSSH has no permissions outside the ssh-sessions namespace. If the ContainerSSH service account token is stolen and used to call the API, the attacker can list Pods in ssh-sessions — that is the ceiling.

2. ResourceQuota on the Session Namespace

Limit the maximum number of concurrent session Pods to prevent a single user (or a credential stuffing attack) from exhausting cluster resources:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: ssh-sessions-quota
  namespace: ssh-sessions
spec:
  hard:
    pods: "50"
    requests.cpu: "25"
    requests.memory: "50Gi"
    limits.cpu: "50"
    limits.memory: "100Gi"

Tune the pod count to your maximum expected concurrent sessions plus a 20% headroom. When the quota is exhausted, ContainerSSH’s pods/create call returns a 403 Forbidden and the new SSH connection is rejected before a Pod is started.

3. Hardened ContainerSSH Configuration with Pod Spec

The following config.yaml for ContainerSSH 0.5+/0.6 configures the Kubernetes backend and embeds a hardened Pod spec template. This spec is the default; the config webhook can override it per-user, but the override should only loosen resource limits, not relax security context fields.

# /etc/containerssh/config.yaml
ssh:
  hostkeys:
    - /etc/containerssh/host_key

auth:
  webhook:
    url: https://auth-webhook.containerssh.svc.cluster.local/password

configserver:
  url: https://config-webhook.containerssh.svc.cluster.local/config

backend: kubernetes

kubernetes:
  connection:
    host: https://kubernetes.default.svc
    cacert: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
    bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token

  pod:
    namespace: ssh-sessions

    podSpec:
      metadata:
        labels:
          app.kubernetes.io/managed-by: containerssh
          containerssh.io/session: "true"
        annotations:
          seccomp.security.alpha.kubernetes.io/pod: runtime/default

      spec:
        automountServiceAccountToken: false
        enableServiceLinks: false
        hostNetwork: false
        hostPID: false
        hostIPC: false

        securityContext:
          runAsNonRoot: true
          runAsUser: 1000
          runAsGroup: 1000
          fsGroup: 1000
          seccompProfile:
            type: RuntimeDefault
          sysctls: []

        containers:
          - name: shell
            image: ghcr.io/example/ssh-session-image:v1.2.3@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab
            imagePullPolicy: Always

            securityContext:
              allowPrivilegeEscalation: false
              readOnlyRootFilesystem: true
              runAsNonRoot: true
              runAsUser: 1000
              runAsGroup: 1000
              capabilities:
                drop:
                  - ALL
              seccompProfile:
                type: RuntimeDefault

            resources:
              requests:
                cpu: "100m"
                memory: "128Mi"
              limits:
                cpu: "500m"
                memory: "512Mi"

            volumeMounts:
              - name: tmp
                mountPath: /tmp
              - name: home
                mountPath: /home/user

        volumes:
          - name: tmp
            emptyDir:
              sizeLimit: "100Mi"
          - name: home
            emptyDir:
              sizeLimit: "1Gi"

        restartPolicy: Never

Key decisions in this spec:

  • automountServiceAccountToken: false at the Pod spec level prevents the Kubernetes token from being mounted even if a container image’s default entrypoint expects it. An attacker in the session Pod has no token to call the Kubernetes API with.
  • readOnlyRootFilesystem: true with explicit emptyDir volumes for /tmp and /home/user means the container filesystem is immutable. Tools that write to /etc, /usr, or other locations fail. This is intentional: session containers should not be modifying themselves.
  • restartPolicy: Never ensures that if the container process exits (normal or abnormal), Kubernetes does not restart it. The session ends. There is no lingering container.
  • Image is pinned to a digest, not a tag. If the image registry is compromised and a new image is pushed to the same tag, existing sessions are not affected and new sessions use the pinned digest.
  • enableServiceLinks: false prevents Kubernetes from injecting environment variables describing other services in the namespace into the session container — these variables leak internal service names and ports.

4. NetworkPolicy for Session Pods

The following NetworkPolicy denies all ingress to session Pods and restricts egress to a specific internal service (replace target-service with your actual destination):

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: ssh-session-isolation
  namespace: ssh-sessions
spec:
  podSelector:
    matchLabels:
      containerssh.io/session: "true"
  policyTypes:
    - Ingress
    - Egress
  ingress: []
  egress:
    # Allow DNS resolution via CoreDNS
    - ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
      to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns

    # Allow egress to specific target service only
    # Replace this block with your actual allowed destinations
    - ports:
        - protocol: TCP
          port: 8443
      to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: target-namespace
          podSelector:
            matchLabels:
              app: target-service

This policy has no ingress rules at all, which means no inbound traffic is permitted. ContainerSSH connects to the session Pod via pods/exec — that is an API server-mediated websocket, not a direct network connection to the Pod, so the NetworkPolicy does not block it.

Critically, this policy blocks egress to:

  • The Kubernetes API server (no route to control plane subnet)
  • The kubelet API on port 10250
  • Cloud metadata services at 169.254.169.254
  • Other Pods in all namespaces not explicitly allowed
  • The internet

5. Pod Cleanup: CronJob for Orphaned Sessions

ContainerSSH deletes session Pods when sessions end, but orphaned Pods can accumulate if ContainerSSH is killed mid-session. The following CronJob garbage-collects any session Pod in the ssh-sessions namespace that has been running for more than one hour:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: ssh-session-gc
  namespace: ssh-sessions
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ssh-session-gc
  namespace: ssh-sessions
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["list", "delete", "get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ssh-session-gc
  namespace: ssh-sessions
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: ssh-session-gc
subjects:
  - kind: ServiceAccount
    name: ssh-session-gc
    namespace: ssh-sessions
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: ssh-session-gc
  namespace: ssh-sessions
spec:
  schedule: "*/15 * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: ssh-session-gc
          automountServiceAccountToken: true
          restartPolicy: OnFailure
          securityContext:
            runAsNonRoot: true
            runAsUser: 65534
            runAsGroup: 65534
            seccompProfile:
              type: RuntimeDefault
          containers:
            - name: gc
              image: bitnami/kubectl:1.28@sha256:placeholder000000000000000000000000000000000000000000000000000000
              imagePullPolicy: IfNotPresent
              securityContext:
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                capabilities:
                  drop:
                    - ALL
              command:
                - /bin/sh
                - -c
                - |
                  CUTOFF=$(date -d '1 hour ago' --iso-8601=seconds)
                  kubectl get pods -n ssh-sessions \
                    -l containerssh.io/session=true \
                    --field-selector=status.phase=Running \
                    -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.creationTimestamp}{"\n"}{end}' \
                  | while IFS=$'\t' read -r name ts; do
                      if [ "$ts" \< "$CUTOFF" ]; then
                        echo "Deleting orphaned session pod: $name (created: $ts)"
                        kubectl delete pod "$name" -n ssh-sessions --grace-period=30
                      fi
                    done
              resources:
                requests:
                  cpu: "50m"
                  memory: "32Mi"
                limits:
                  cpu: "100m"
                  memory: "64Mi"

The CronJob runs every 15 minutes. It uses containerssh.io/session=true as the selector, which is the label applied by the ContainerSSH Pod spec template above. The grace period of 30 seconds allows any active exec connections to drain before the Pod is forcibly terminated.

6. Kyverno Policy Enforcing Hardened Pod Spec

Even with a hardened config webhook, the ssh-sessions namespace should have a Kyverno ClusterPolicy that blocks any Pod in that namespace which does not meet the required security posture. This prevents a misconfigured webhook response from creating a non-hardened session Pod:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: enforce-ssh-session-pod-security
  annotations:
    policies.kyverno.io/title: Enforce SSH Session Pod Security
    policies.kyverno.io/category: SSH Access Security
    policies.kyverno.io/severity: high
    policies.kyverno.io/description: >-
      All Pods in the ssh-sessions namespace must have a hardened security
      context. This policy blocks Pods that allow privilege escalation, mount
      service account tokens, use host namespaces, or lack seccomp profiles.
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: require-non-root
      match:
        any:
          - resources:
              kinds: ["Pod"]
              namespaces: ["ssh-sessions"]
      validate:
        message: "SSH session Pods must run as non-root (runAsNonRoot: true)"
        pattern:
          spec:
            securityContext:
              runAsNonRoot: true

    - name: deny-privilege-escalation
      match:
        any:
          - resources:
              kinds: ["Pod"]
              namespaces: ["ssh-sessions"]
      validate:
        message: "SSH session Pod containers must set allowPrivilegeEscalation: false"
        pattern:
          spec:
            containers:
              - securityContext:
                  allowPrivilegeEscalation: false

    - name: require-readonly-rootfs
      match:
        any:
          - resources:
              kinds: ["Pod"]
              namespaces: ["ssh-sessions"]
      validate:
        message: "SSH session Pod containers must have readOnlyRootFilesystem: true"
        pattern:
          spec:
            containers:
              - securityContext:
                  readOnlyRootFilesystem: true

    - name: deny-automount-serviceaccount-token
      match:
        any:
          - resources:
              kinds: ["Pod"]
              namespaces: ["ssh-sessions"]
      validate:
        message: "SSH session Pods must set automountServiceAccountToken: false"
        pattern:
          spec:
            automountServiceAccountToken: false

    - name: deny-host-namespaces
      match:
        any:
          - resources:
              kinds: ["Pod"]
              namespaces: ["ssh-sessions"]
      validate:
        message: "SSH session Pods must not use host namespaces"
        pattern:
          spec:
            =(hostPID): false
            =(hostIPC): false
            =(hostNetwork): false

    - name: require-drop-all-capabilities
      match:
        any:
          - resources:
              kinds: ["Pod"]
              namespaces: ["ssh-sessions"]
      validate:
        message: "SSH session Pod containers must drop ALL capabilities"
        pattern:
          spec:
            containers:
              - securityContext:
                  capabilities:
                    drop:
                      - ALL

    - name: require-resource-limits
      match:
        any:
          - resources:
              kinds: ["Pod"]
              namespaces: ["ssh-sessions"]
      validate:
        message: "SSH session Pod containers must specify CPU and memory limits"
        pattern:
          spec:
            containers:
              - resources:
                  limits:
                    memory: "?*"
                    cpu: "?*"

This policy uses validationFailureAction: Enforce, which means it blocks non-compliant Pod creation at admission time. If ContainerSSH’s config webhook returns a spec that fails any of these rules, the API server rejects the Pod and ContainerSSH returns an error to the SSH client — the session fails closed rather than open.

The background: true setting also runs these rules against existing Pods, flagging any that were created before the policy was applied or that slipped through during a Kyverno outage.

Expected Behaviour

The following table maps user actions to the Pod lifecycle and the corresponding audit events that should appear in your Kubernetes audit log.

User Action Pod Lifecycle Audit Event
User connects via SSH ContainerSSH calls config webhook; webhook returns hardened Pod spec; ContainerSSH calls POST /api/v1/namespaces/ssh-sessions/pods; Pod enters Pending then Running ResponseComplete for create pods by the containerssh ServiceAccount in ssh-sessions
User runs a command ContainerSSH calls POST /api/v1/namespaces/ssh-sessions/pods/{name}/exec; API server upgrades to WebSocket; command runs in container ResponseComplete for create pods/exec by the containerssh ServiceAccount
User disconnects cleanly ContainerSSH closes the exec WebSocket; calls DELETE /api/v1/namespaces/ssh-sessions/pods/{name} ResponseComplete for delete pods by the containerssh ServiceAccount
ContainerSSH crashes mid-session Pod continues running in Running state; ContainerSSH does not call delete No delete event; Pod remains until CronJob GC runs (within 15 minutes)
Session Pod reaches 1h age CronJob GC script identifies Pod by creation timestamp; calls kubectl delete pod with 30s grace period ResponseComplete for delete pods by the ssh-session-gc ServiceAccount
ResourceQuota exhausted (>50 Pods) POST /api/v1/namespaces/ssh-sessions/pods returns 403 Forbidden; ContainerSSH rejects the SSH connection ResponseForbidden for create pods by the containerssh ServiceAccount; quota event in ssh-sessions
Kyverno blocks non-compliant Pod spec Admission webhook rejects the Pod creation; API server returns 403 Forbidden to ContainerSSH Kyverno audit log entry with BLOCKED result for enforce-ssh-session-pod-security; SSH connection fails

Trade-offs

The hardened configuration above makes deliberate choices that constrain what session Pods can do. Some of those constraints create friction.

Decision Security Benefit Operational Trade-off
Dedicated ssh-sessions namespace with scoped Role ContainerSSH service account has no cluster-wide permissions; token theft cannot reach other namespaces Pods cannot access resources in other namespaces even if the session scenario requires it; separate namespace adds an administrative boundary to manage
automountServiceAccountToken: false No Kubernetes API token is available inside session Pods; stolen credentials cannot call the cluster API Users who need kubectl access from within a session (e.g., for a developer debugging namespace) cannot authenticate to the cluster; a separate, scoped token must be injected via a projected volume if this is genuinely required
readOnlyRootFilesystem: true Container filesystem is immutable; attackers cannot write payloads, install tools, or modify binaries on disk Tools that write to paths outside the declared emptyDir volumes fail; session images must be built with all required files pre-installed; pip install, apt-get, and similar runtime installs will not work
ResourceQuota capping Pod count at 50 Prevents a credential-stuffing attack or a single user from exhausting cluster compute; limits blast radius of a session-level DoS When the quota is reached, legitimate users are rejected with an opaque error; quota must be tuned based on peak concurrent session counts and increased proactively as usage grows
restartPolicy: Never Session Pods do not restart on exit; no lingering containers after session end A legitimate session that crashes due to an OOM or container fault is not recovered; the user must reconnect and start a new session rather than resuming
NetworkPolicy denying egress by default Session Pods cannot reach Kubernetes API, kubelet, etcd, cloud metadata, or other Pods Any new target service that session Pods need to reach requires an explicit NetworkPolicy egress rule; forgetting to add the rule breaks the session silently from the user’s perspective

Failure Modes

The table below lists the most operationally significant failure modes, their symptoms, and how to detect and remediate each one.

Failure Mode Symptom Detection Remediation
ContainerSSH ServiceAccount missing pods/exec permission SSH connections appear to succeed (Pod is created and starts) but the session hangs or immediately drops; no interactive shell is delivered Kubernetes audit log shows ResponseForbidden for create pods/exec by the containerssh ServiceAccount; ContainerSSH logs show exec error Add pods/exec create verb to the Role in ssh-sessions; verify with kubectl auth can-i create pods/exec --as=system:serviceaccount:containerssh:containerssh -n ssh-sessions
ResourceQuota exhausted New SSH connections are rejected; users receive a connection refused or an authentication error depending on ContainerSSH error handling Kubernetes event in ssh-sessions namespace with reason: FailedCreate and exceeded quota; kubectl describe resourcequota ssh-sessions-quota -n ssh-sessions shows hard limits reached Increase the pods quota if usage growth is legitimate; investigate whether orphaned Pods are consuming quota (run GC CronJob manually); alert on kube_resourcequota_used / kube_resourcequota_hard > 0.8
Kyverno blocks ContainerSSH Pod creation Session Pods are rejected at admission; users receive errors; Kyverno admission webhook returns 403; ContainerSSH logs show admission webhook denied Kyverno policy report in ssh-sessions shows FAIL results; Kyverno controller logs show enforce-ssh-session-pod-security blocking the Pod Diagnose which rule is failing: the config webhook is returning a spec that does not meet the policy requirements; fix the webhook response, not the policy
Orphaned Pod not garbage collected Session Pods remain in Running state after the SSH session has ended; quota is consumed by idle Pods; orphaned Pods represent unmonitored attack surfaces Alert on Pods in ssh-sessions with age >1h; check GC CronJob logs for failures; kubectl get pods -n ssh-sessions --sort-by=.metadata.creationTimestamp If CronJob is failing, check its service account permissions and logs; manually delete orphaned Pods with kubectl delete pods -l containerssh.io/session=true -n ssh-sessions; investigate why ContainerSSH is not cleaning up (crash loop? webhook timeout?)
NetworkPolicy too restrictive for session target Session Pods cannot reach the intended internal service; commands that connect to the target fail with connection refused or timeout Users report that their session tools cannot connect to expected endpoints; no network traffic in the allowed egress direction Add a specific egress rule to ssh-session-isolation NetworkPolicy for the target service’s namespace, pod selector, and port; do not widen to 0.0.0.0/0 to fix the problem
Config webhook returns default/empty Pod spec Session Pods are created with Kubernetes defaults: allowPrivilegeEscalation: true, automounted service account token, no seccomp profile Kyverno policy reports show FAIL for deny-privilege-escalation, require-non-root, etc. in ssh-sessions; if Kyverno is enforcing, Pods are blocked Fix the config webhook to return a complete hardened spec; treat any FAIL in the Kyverno background scan for ssh-sessions as a P1 incident