Repository navigation
security: member builds can read the shared registry push credential and push to any member's images #113
Description
Summary
Every member build can push to, and pull from, every other member's image path in registry.nan.builders. A RUN step in any Dockerfile can read the platform-wide builder registry credential. With it, a member can overwrite another member's space-<owner>-<slug>/<app>:<tag>, and that member's next rollout or pod restart runs attacker code in their Space. It can also read any member's image, including source and any baked-in secrets.
Tenant: NaN community (Spaces/Apps). Found in the builds-system security review on 2026-09-29. Companion PRs: helmcode/nan-cloud-api#262, helmcode/nan-devops#615.
Current state (verified 2026-09-29, read-only)
| Piece | Today |
|---|---|
| Registry | registry-system/registry-docker-registry, distribution 3.0.0, R2 backend, public at registry.nan.builders (Traefik/CF Tunnel; in-cluster pushes use the CoreDNS rewrite to Traefik websecure). |
| Auth | REGISTRY_AUTH=htpasswd, 2 users: builder, puller. htpasswd auth has no per-repository ACL: any valid user can push and pull every repository. |
| Build pods | builds-system Kaniko Job. registry-builder-creds (shared builder docker config) is mounted at /kaniko/.docker, and GIT_PASSWORD (GitHub installation token) is an env var of the Kaniko container. |
| Pulls | registry-puller Secret (shared puller creds) is copied into 49 space-* namespaces (plus nan, tagoro) from registry-puller-template. |
| GitHub token | InstallationToken() mints an installation-wide token with the app's full permissions: not scoped to the repo being built, and not read-only. |
Can Kaniko keep the credentials away from RUN steps?
No. Kaniko is not a sandbox. It unpacks the base image into its own root filesystem and executes every RUN in that same container, as root. It only excludes /kaniko from the image snapshot; it does not hide it. From any RUN step:
cat /kaniko/.docker/config.jsonreturns thebuildercredential.tr '\0' '\n' </proc/1/environreturnsGIT_PASSWORD(PID 1 is the executor, same user).RUNdoes not inherit the env, but/procexposes it./proc/1/cmdlinecontains every--build-arg, so build-time env values are visible too. They are the member's own, so this is not cross-tenant.
No flag changes this. Also note that upstream GoogleContainerTools/kaniko was archived in 2025 (maintained forks exist), so the executor should be revisited anyway.
Design
Two layers. A removes the credentials from the container that runs member code. B makes whatever a credential can do tenant-scoped, which also covers pulls.
A. Separation of duties in the build Job (cloud-api only, no registry change)
Split the pod so that the container that runs member code holds no secrets at all:
git-cloneinitContainer (e.g.alpine/git, digest-pinned). Gets the GitHub token viaGIT_ASKPASS(never written to disk and never put in the URL, so it is not in.git/config), and runsgit clone --depth 1 … && git checkout <sha>into anemptyDir/workspace. It runs no member code: clone runs no hooks, and submodules stay off.wait-network-isolationinitContainer (already in nan-cloud-api#262).kanikoinitContainer:--context=dir:///workspace/<sub-path> --no-push --tar-path=/out/image.tar. No registry mount, no GIT env. MemberRUNsteps can see only their own source.pushmain container (e.g.crane push /out/image.tar <dest>, digest-pinned) mounts the push credential. Member code can tamper with/out/image.tar, but can only push it to the member's own destination, which it controls anyway.
Knock-on work: the buildwatcher and the BuildLogs WS read kaniko logs (now an initContainer, plus push); OOM detection must look at InitContainerStatuses; the tarball needs emptyDir sizing (large bases like open-webui are multi-GB, so use an ephemeral-storage request/limit). Kaniko's FROM registry.nan.builders/... (private base) would need pull creds in the kaniko container. Check whether any app uses that. If none do, forbid it. If some do, give kaniko a read-only credential scoped to the member's own space (layer B).
Also scope the GitHub token: POST /app/installations/{id}/access_tokens with {"repositories":["<repo>"],"permissions":{"contents":"read"}}. This is a small, separate change.
B. Token auth with per-repository scope (registry + cloud-api)
Switch distribution to auth: token (realm, service, issuer, rootcertbundle). The token server issues JWTs whose access claim names exact repositories and actions. Options:
| Option | Pros | Cons |
|---|---|---|
Token endpoint in cloud-api (GET /registry/token), signing key in a k8s Secret; distribution trusts its cert |
cloud-api already knows owner → space → app; credentials can be minted per build with exp; no new service |
New public endpoint on the auth path: needs rate limits and tests |
cesanta/docker_auth with ACL rules like {match: {account: "/^space-(.+)$/", name: "space-${account:1}/*"}} |
Off-the-shelf, supports static users and regex ACLs | Another service to run and update; users/passwords still need provisioning per space |
Recommended: cloud-api token endpoint, with these credentials:
- Per build: at
Trigger, mint a random password bound to{repo: <space>/<app>, actions: [push, pull], exp: activeDeadline + 5m}, stored hashed, and put it in a per-Job Secret with anownerReferenceto the Job, so it is garbage-collected with it. This replaces the sharedregistry-builder-creds. - Per space pull: a
space-<ns>account limited topullonspace-<ns>/*, written as that namespace'sregistry-puller. This replaces the sharedpuller. - Keep an admin account for ops only, not mounted anywhere.
Migration: run token auth alongside htpasswd behind a feature flag in cloud-api. Re-mint all 49 registry-puller Secrets, then flip REGISTRY_AUTH. Kubelet pulls go through the public path, and imagePullSecrets stay the same name, so no pod spec changes are needed.
Order of work
- GitHub token scoped to the one repo,
contents:read(small). - Layer A, which removes the builder credential from member code. This alone closes the cross-tenant push.
- Layer B, which covers pulls and any future credential exposure.
- Rotate
builderandpullerafter step 2 or 3 lands. Both must be treated as exposed: any build since the registry went live could have readbuilder.
Tests to write (spec first)
- Job spec:
kanikohas no volume mounts from Secrets and noGIT_*env; onlypushmounts the credential; the image destination and credential scope match. - Token endpoint: a build credential for
space-a/appgetspushonly onspace-a/app, and gets nothing onspace-b/*orspace-a/other; an expired credential is rejected; no credential in logs or responses. - E2E (staging, no real member key): a Dockerfile
RUN cat /kaniko/.docker/config.json; cat /proc/1/environshows nothing sensitive.
Activity
sre-helmcode commentedon Sep 30, 2026
Phase B design: per-space registry token auth
Phase A is in helmcode/nan-cloud-api#271. After it, member build code holds no push credential and no GitHub token. Two residuals remain:
puller, which can pull every repository;builderandpullerare static, all-repo credentials.Phase B scopes every registry credential to one space. Design only. Nothing is implemented here.
1. Registry: distribution
tokenauthReplace
auth: htpasswdin theregistry-docker-registryconfig:Distribution 3.0 verifies RS256 or ES256 JWTs against
rootcertbundle. It checksiss,aud(=service),exp/nbf, and theaccessclaim ([{type: repository, name, actions}]). Catalog (registry:catalog:*) is never granted.2. cloud-api:
GET /registry/tokenDocker token protocol: basic-auth in,
{"token","access_token","expires_in","issued_at"}out.service, one or morescope=repository:<name>:<actions>, andAuthorization: Basic <account>:<password>.registry_accounts(id, kind, space_namespace, repo_prefix, password_hash, actions, expires_at, revoked_at, created_at). Passwords are random 32 bytes, stored as SHA-256 (high-entropy secrets, so no bcrypt cost), and compared in constant time.repository:<space>/<anything>:namemust satisfypath.Clean(name) == nameandstrings.HasPrefix(name, space_namespace+"/").actions ⊆ account.actions.registry:catalog) is dropped.registry-token-signing, never in git), withexp= 5 min, akidheader, andjti. Rotation: publish the new cert in the bundle first, then switch the signer.api.nan.builders, with no CORS. Tests cover the prefix check with..,space-avsspace-ab/, a mixed-case name, and multiple scopes.3. Credentials
Builder.Trigger, one per Job. Stored as a per-Job Secret withownerReference→ Job (GC'd with it). Mounted only inpush.push,pullon exactly<space>/<app>expires_at = now + activeDeadline + 5m; revoked by the buildwatcher on terminal statekanikopullon<space>/*registry-pullerin eachspace-*namespace (replaces the copied template), created at space provisioning, and referenced by the SAimagePullSecretsas todaypullon<space>/*nan,tagoro,nan-smoke,testreposThis removes the shared
registry-builder-credsandregistry-puller-template. A leaked build credential can only push to that member's own app, for at most about 20 minutes. A leaked space pull credential can only read that space.4. Rollout (no downtime)
registry-pullerSecret next to the old one (a new name, then switch the SAimagePullSecrets).auth: token. Keep the htpasswd users only as token-endpoint accounts during the transition (kind=legacy, all-repo), then revoke them.<space>/<app>;_catalogis denied.5. Open questions
FROMacross spaces (member A building from member B's public image) is not supported unless we add an explicit share. Proposal: not supported.space-*: they need an operator-managed platform account.repositories:[repo],contents:read) into the same PR. It is independent and small.Required regardless of phase B: rotate
builderandpullernow (listed as a follow-up in #271), because every build before #271 could readbuilder.sre-helmcode commentedon Sep 30, 2026
Correction to the phase B design above: in distribution 3.0 with htpasswd and no authz rules, the htpasswd module ignores the action, so
pullercan push as well. As a result:FROM registry.nan.builders/...now gets 401. The "kaniko reads the sharedpuller" residual above no longer applies.accessclaim. No htpasswd user should survive the switch toauth: token.builderandpulleris required, not optional.