Skip to content

security: member builds can read the shared registry push credential and push to any member's images #113

Open

Description

@sre-helmcode

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.json returns the builder credential.
  • tr '\0' '\n' </proc/1/environ returns GIT_PASSWORD (PID 1 is the executor, same user). RUN does not inherit the env, but /proc exposes it.
  • /proc/1/cmdline contains 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:

  1. git-clone initContainer (e.g. alpine/git, digest-pinned). Gets the GitHub token via GIT_ASKPASS (never written to disk and never put in the URL, so it is not in .git/config), and runs git clone --depth 1 … && git checkout <sha> into an emptyDir /workspace. It runs no member code: clone runs no hooks, and submodules stay off.
  2. wait-network-isolation initContainer (already in nan-cloud-api#262).
  3. kaniko initContainer: --context=dir:///workspace/<sub-path> --no-push --tar-path=/out/image.tar. No registry mount, no GIT env. Member RUN steps can see only their own source.
  4. push main 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 an ownerReference to the Job, so it is garbage-collected with it. This replaces the shared registry-builder-creds.
  • Per space pull: a space-<ns> account limited to pull on space-<ns>/*, written as that namespace's registry-puller. This replaces the shared puller.
  • 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

  1. GitHub token scoped to the one repo, contents:read (small).
  2. Layer A, which removes the builder credential from member code. This alone closes the cross-tenant push.
  3. Layer B, which covers pulls and any future credential exposure.
  4. Rotate builder and puller after step 2 or 3 lands. Both must be treated as exposed: any build since the registry went live could have read builder.

Tests to write (spec first)

  • Job spec: kaniko has no volume mounts from Secrets and no GIT_* env; only push mounts the credential; the image destination and credential scope match.
  • Token endpoint: a build credential for space-a/app gets push only on space-a/app, and gets nothing on space-b/* or space-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/environ shows nothing sensitive.

Activity

  1. sre-helmcode commented on Sep 30, 2026

    @sre-helmcode
    ContributorAuthor

    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:

    • kaniko reads the shared puller, which can pull every repository;
    • builder and puller are static, all-repo credentials.

    Phase B scopes every registry credential to one space. Design only. Nothing is implemented here.

    1. Registry: distribution token auth

    Replace auth: htpasswd in the registry-docker-registry config:

    auth:
      token:
        realm: https://api.nan.builders/registry/token   # in-cluster callers use the Service URL via the CoreDNS rewrite
        service: registry.nan.builders
        issuer: nan-cloud-api
        rootcertbundle: /etc/registry/token/ca.pem         # public cert only, mounted from a Secret/ConfigMap

    Distribution 3.0 verifies RS256 or ES256 JWTs against rootcertbundle. It checks iss, aud (= service), exp/nbf, and the access claim ([{type: repository, name, actions}]). Catalog (registry:catalog:*) is never granted.

    2. cloud-api: GET /registry/token

    Docker token protocol: basic-auth in, {"token","access_token","expires_in","issued_at"} out.

    • Input: service, one or more scope=repository:<name>:<actions>, and Authorization: Basic <account>:<password>.
    • Accounts live in a new table 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.
    • Authorization is the intersection of requested and allowed. It never errors on an over-broad scope; it just issues less (Docker semantics).
      • repository:<space>/<anything>: name must satisfy path.Clean(name) == name and strings.HasPrefix(name, space_namespace+"/").
      • actions ⊆ account.actions.
      • Any other type (for example registry:catalog) is dropped.
    • Token: signed with a key from a k8s Secret (registry-token-signing, never in git), with exp = 5 min, a kid header, and jti. Rotation: publish the new cert in the bundle first, then switch the signer.
    • Limits: rate limit per account and per IP (auth path). Unknown or revoked account → 401 with the same body and timing as a bad password. The endpoint is served only on the in-cluster Service plus api.nan.builders, with no CORS. Tests cover the prefix check with .., space-a vs space-ab/, a mixed-case name, and multiple scopes.

    3. Credentials

    Account Where Allowed Lifetime
    per-build push Minted at Builder.Trigger, one per Job. Stored as a per-Job Secret with ownerReference → Job (GC'd with it). Mounted only in push. push,pull on exactly <space>/<app> expires_at = now + activeDeadline + 5m; revoked by the buildwatcher on terminal state
    per-build pull Same Secret, separate key, mounted in kaniko pull on <space>/* same
    per-space pull registry-puller in each space-* namespace (replaces the copied template), created at space provisioning, and referenced by the SA imagePullSecrets as today pull on <space>/* long-lived, rotatable, revoked on space delete/churn
    platform nan, tagoro, nan-smoke, test repos explicit prefixes long-lived, operator-managed

    This removes the shared registry-builder-creds and registry-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)

    1. Ship the token endpoint and account minting behind a flag. The registry is still on htpasswd, so nothing changes yet.
    2. Backfill per-space pull accounts. Write the new registry-puller Secret next to the old one (a new name, then switch the SA imagePullSecrets).
    3. Switch builds to per-build Secrets (flag on).
    4. Flip the registry to auth: token. Keep the htpasswd users only as token-endpoint accounts during the transition (kind=legacy, all-repo), then revoke them.
    5. Verify:
      • a space pull account gets 401/403 on another space's manifest;
      • a build token cannot push outside <space>/<app>;
      • _catalog is denied.

    5. Open questions

    • Private FROM across spaces (member A building from member B's public image) is not supported unless we add an explicit share. Proposal: not supported.
    • Marketplace images that live outside space-*: they need an operator-managed platform account.
    • Whether to also move the GitHub token scoping (repositories:[repo], contents:read) into the same PR. It is independent and small.

    Required regardless of phase B: rotate builder and puller now (listed as a follow-up in #271), because every build before #271 could read builder.

  2. sre-helmcode commented on Sep 30, 2026

    @sre-helmcode
    ContributorAuthor

    Correction to the phase B design above: in distribution 3.0 with htpasswd and no authz rules, the htpasswd module ignores the action, so puller can push as well. As a result:

    • helmcode/nan-cloud-api#271 no longer mounts any registry credential in kaniko. A private FROM registry.nan.builders/... now gets 401. The "kaniko reads the shared puller" residual above no longer applies.
    • In phase B, the "pull-only" guarantee comes only from the token access claim. No htpasswd user should survive the switch to auth: token.
    • Rotating both builder and puller is required, not optional.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions