Security model
Vesper runs code from pull requests, written by anyone allowed to open one, inside a cluster you care about. This page says what Vesper protects against, what it does not, and how to install it safely. To report a vulnerability, see SECURITY.md.
A namespace is a soft boundary. Vesper puts several layers between a preview and the rest of the cluster, but they all rest on the Linux kernel and on Kubernetes: a vulnerability there can break them. Vesper does not provide hard multi-tenant isolation. Do not run previews of untrusted code on a cluster that also runs sensitive workloads.
Threat model
Section titled “Threat model”| Who | What they control | What Vesper must prevent |
|---|---|---|
| The code of a pull request | everything inside the containers of its preview | reaching other previews, the API server, the nodes, the services of the cluster; running as root or with privileges; using more than its share of the cluster; staying after its TTL |
| A user who creates previews | the spec of a PreviewEnvironment: image, port, replicas, env, TTL |
running unpinned images, unbounded previews, or objects outside the preview namespace; taking over a namespace or an object Vesper did not create |
| Someone on the Internet | requests to the public address of previews | reaching anything but the application of a preview through the ingress controller |
| The operator, if compromised | its own rights | as little as possible: no access to Secrets, no pod creation, no write outside what previews need |
Guarantees
Section titled “Guarantees”Each guarantee names the code that enforces it and the test that checks it.
A preview runs without privileges
Section titled “A preview runs without privileges”The preview namespace enforces the Pod Security Standard restricted
(label pod-security.kubernetes.io/enforce: restricted), and the Deployment of
the preview meets it: non-root, no privilege escalation, every capability
dropped, the RuntimeDefault seccomp profile, no ServiceAccount token
mounted. The API server rejects any pod of the namespace that breaks the
standard, even one created by hand.
- Code:
deployment.go(mutateDeployment),previewenvironment_controller.go(namespace labels). - Tests:
TestReconcileCreatesDeployment,TestReconcileRestoresNamespaceLabels,TestEnvtestRejectsPrivilegedPod.
A preview has bounded resources
Section titled “A preview has bounded resources”Each preview namespace gets a LimitRange, which gives every container a default request and limit, and a ResourceQuota: by default 4 CPU, 4 GiB of memory, 10 pods, and no NodePort or LoadBalancer Service, so a preview cannot open a port on the nodes. The API server refuses more than 5 replicas.
- Code:
quota.go, theMaximum=5marker inpreviewenvironment_types.go. - Tests:
TestReconcileCreatesDefaultLimits,TestReconcileCreatesLimitsBeforeDeployment,TestEnvtestCreatesLimits,TestEnvtestAPIValidation. On a real server, a pod over the quota was refused (try on a server).
A preview is isolated on the network
Section titled “A preview is isolated on the network”A NetworkPolicy in each preview namespace (ADR-0005) lets in only the application port, from the preview itself and from namespaces that are not previews (the ingress controller). It lets out only the pods of the preview, DNS, and the public internet: not the private, link-local and shared address ranges, nor the public addresses of the nodes and of the API server, which the operator reads and keeps up to date.
-
Code:
networkpolicy.go,hostaddresses.go. -
Tests:
TestReconcileCreatesNetworkPolicy,TestReconcileRestoresChangedNetworkPolicy,TestReconcileDeniesHostAddresses,TestEnvtestDeniesNewNodeAddress, and the e2e testTestPreviewNetworkIsolation, which probes the isolation from inside a preview on kind: another preview, the API server through its Service and through the address of its endpoint, and the kubelet through the address of the node are blocked; the internet is reached. -
Verified with these CNI plugins, in their default configuration:
CNI Version Checked by kindnet the one of kind v0.33.0 the e2ejob of CI, on every pull requestCalico v3.33.0 the Isolationworkflow, on changes to the isolation, every week, and on demandCilium 1.20.4 the Isolationworkflow, as abovekube-router the one of k3s v1.37.2+k3s1 by hand, on a server (#96) Cilium matches traffic to the nodes by identity rather than by address; the policy holds there too.
-
The public addresses of the nodes were first missed: GHSA-m437-fj8p-6x8h, fixed in v0.1.0-rc.2.
The objects of a preview exist before it runs, and are restored
Section titled “The objects of a preview exist before it runs, and are restored”The operator creates the quota, the LimitRange and the NetworkPolicy before the Deployment: no pod of a preview runs without them. If one is changed or deleted by hand, the operator puts it back. If it cannot write the NetworkPolicy, or read the addresses it needs, it does not create the Deployment.
- Code:
previewenvironment_controller.go(Reconcile). - Tests:
TestReconcileCreatesLimitsBeforeDeployment,TestReconcileRestoresChangedQuota,TestReconcileLeavesForeignNetworkPolicyUntouched(no Deployment without the policy),TestEnvtestRestoresDeletedObjects.
The input is validated before the operator sees it
Section titled “The input is validated before the operator sees it”The CRD schema rejects, on the write path of the API server, even when the
operator does not run: an image that is not pinned with a tag other than
latest or a digest, a TTL outside 1 minute to 7 days, replicas outside 1 to
5, a port outside 1 to 65535, environment variable names that are not
identifiers, and names over 63 characters.
- Code: the markers of
previewenvironment_types.go. - Tests:
TestEnvtestAPIValidation,TestEnvtestAPIDefaults.
Vesper only touches what it created
Section titled “Vesper only touches what it created”Preview namespaces are named from the namespace and name of the preview with a hash, so two previews cannot share one. The operator changes or deletes an object only if it carries the labels of its preview; it leaves any other object alone and reports a conflict. Deletions carry a precondition on the UID of the object, so an object deleted and created again in between is not removed by mistake.
- Code:
naming.go,labels.go,finalizer.go. - Tests:
TestNamespaceNameAvoidsCollisions,TestReconcileLeavesForeignNamespaceUntouched,TestReconcileLeavesForeignDeploymentUntouchedand the other...Foreign...tests,TestDeletionKeepsForeignNamespace,TestEnvtestNamespaceConflict,TestDeleteSendsUID(vesperctl).
A preview does not outlive its TTL
Section titled “A preview does not outlive its TTL”A preview is deleted when its TTL passes, and deleting a preview deletes its namespace and everything in it, through a finalizer.
- Code:
ttl.go,finalizer.go. - Tests:
TestDeletionDeletesNamespaceThenPreview,TestEnvtestExpiresAtFollowsTTL, the e2e testTestCLIExpiry.
The operator has narrow rights
Section titled “The operator has narrow rights”The ClusterRole of the operator lists only what the code uses. It cannot read
Secrets or ConfigMaps, nor create pods; it reads nodes and EndpointSlices
without writing them. Its own pod meets the restricted standard, with a
read-only root filesystem, in a namespace that enforces it.
- Code: the
+kubebuilder:rbacmarkers inpreviewenvironment_controller.go, generated intoconfig/rbac/role.yaml;config/manager/manager.yaml. - Check:
make manifestsregenerates the role, and CI fails if it drifts (generatedjob). The list and the reason for each right are in Development.
HTTPS without private keys in previews
Section titled “HTTPS without private keys in previews”With --preview-tls, previews are served over HTTPS with the default
certificate of the ingress controller (ADR-0006):
no certificate or key is ever written to a preview namespace, and the
operator has no right on Secrets.
- Code:
ingress.go. - Tests:
TestReconcileServesHTTPS,TestEnvtestAcceptsTLSIngress; on a real server, the preview namespace held no Secret (try on a server).
The webhook endpoint authenticates every delivery
Section titled “The webhook endpoint authenticates every delivery”vesper-github, the GitHub integration, is the only part of Vesper that
faces the Internet (ADR-0007). It reads a
delivery with a limit of 5 MiB and checks its HMAC SHA-256 signature, in
constant time, before anything looks at it; it never logs the secret or the
payload. It has no right on Secrets: the secret of the GitHub App is mounted
in its pod. Its other rights are previews and sources only, so that a flaw in
it reaches no namespace, no Deployment and no Secret.
- Code:
webhook.go,role.yaml. - Tests:
TestReceiver,TestReceiverLogsNoSecret,TestRBACIsMinimal.
Metrics require authentication
Section titled “Metrics require authentication”With config/default, the metrics endpoint is served over HTTPS and checks
the token of each request with the API server; only a caller allowed by the
ClusterRole vesper-metrics-reader reads it. No label holds the name of a
preview.
- Code:
cmd/main.go(filters.WithAuthenticationAndAuthorization),metrics.go. - Check: by hand, as Metrics describes (401 without a token); no automated test covers the authentication.
The release can be verified
Section titled “The release can be verified”Release assets come with SHA-256 checksums and provenance attestations signed
by the release workflow; install.yaml pins the operator image by digest; the
image is distroless and runs as a non-root user. Base images and actions are
pinned by digest or commit SHA.
- Code:
.github/workflows/release.yml,.goreleaser.yaml,Dockerfile. - Check: Releasing.
Known limits
Section titled “Known limits”- A namespace is a soft boundary, as above. A container escape through a kernel or runtime vulnerability is out of reach of every layer here. Stronger isolation (gVisor, Kata Containers, one cluster per tenant) is not supported yet.
- The network isolation depends on the CNI. kindnet, k3s, Calico and Cilium enforce NetworkPolicies, and are verified as listed above; flannel alone does not, and the policy is then ignored without an error. Another CNI, or a configuration other than the default, is not verified.
- A preview reaches the internet. It can call external APIs, and so it can also send data out or download code.
- DNS is open to any address, on port 53, because the DNS server of the cluster has a private address. Data can leave through DNS queries.
- Preview URLs are public. Anyone who has the URL opens the application; restricting access is planned (#132).
- Images are not verified. Vesper checks that an image is pinned, not who built it: there is no signature check of preview images.
- Environment variables are plain text in the spec of the preview, readable
by anyone who can read
PreviewEnvironmentobjects. Do not put secrets there.vesperctlhides them in its JSON and YAML output (TestJSONOutputIsValidAndHidesEnv), butkubectldoes not. - Creating a preview means running any image. The right to create
PreviewEnvironmentobjects in a namespace is the right to run code in the cluster, within the limits above: grant it as such. - A preview cannot call a service of the cluster, nor a public URL served by the same cluster: that is the price of the isolation.
Hardening checklist
Section titled “Hardening checklist”For whoever installs Vesper:
- Run previews of untrusted code on a cluster of their own, not next to production workloads.
- Use a CNI that enforces NetworkPolicies, and check it: the e2e test
TestPreviewNetworkIsolationshows how, and runs with Calico or Cilium withE2E_CNI(Development). - Do not expose the API server or the kubelet to the Internet. On a server, allow only 22, 80 and 443, with a final rule that drops the rest (try on a server).
- Grant
createonPreviewEnvironmentcarefully: the ClusterRolesvesper-previewenvironment-editor-roleandvesper-previewenvironment-admin-rolegive it;vesper-previewenvironment-viewer-rolereads only. - Install a release by digest and verify it (
gh attestation verify), rather than building or pulling by tag. - Give the metrics endpoint a real certificate (the
CERTMANAGERsections ofconfig/default), and bindvesper-metrics-readeronly to Prometheus. - Keep the preview domain separate from domains that carry cookies or sessions of other applications: a preview serves arbitrary content under it.
- Upgrade when a release fixes a vulnerability: advisories are published under Security advisories.