SECURITY: load_credentials is not re-entrant — a second service load silently returns the FIRST service's credentials with exit 0 (defeats estate separation) #1052

Open
opened 2026-08-05 16:35:01 +00:00 by Mos · 0 comments
Contributor

Summary

load_credentials uses the ${VAR:-default} idiom for every exported field. Because the variables it
exports are the SAME names across sibling services (GITEA_URL / GITEA_TOKEN for both
gitea-mosaicstack and gitea-usc), a second call in the same shell cannot overwrite the first.
It is a silent no-op that returns 0.

~/.config/mosaic/tools/_lib/credentials.sh:185-189:

export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: ... not found" >&2; return 1; }

The guard on the next line can never fire once GITEA_TOKEN is non-empty from a previous load — so the
fail-closed check is unreachable exactly when it matters.

Reproduction

# fresh shell, homelab only — correctly fails closed
$ load_credentials gitea-mosaicstack
Error: gitea.mosaicstack.token not found
  exit=1 token_len=0

# after loading USC first, in the same shell — silently "succeeds"
$ load_credentials gitea-usc >/dev/null; load_credentials gitea-mosaicstack
  exit=0 token_len=40 url=https://git.uscllc.com

The caller asked for HOMELAB and got USC's token pointed at USC's URL, with exit 0 and no
warning. Nothing in the return value or the environment distinguishes this from a successful load.

Impact — this defeats estate separation

Estate separation (HOMELAB git.mosaicstack.dev vs USC git.uscllc.com) is a binding operator
requirement. This bug breaks it through ordinary sequential use in one shell: any script or agent that
touches both estates gets the first estate's credentials for the second estate's work, believing it
switched. An agent that "switched to homelab" and then wrote is operating on USC.

It is the same failure class as #1044 (get_gitea_token fail-open on unset identity): a credential path
that, when it cannot do the right thing, silently does a different thing and reports success.

It also has a live victim. .gitea.mosaicstack.token is absent from credentials.json, so
every homelab load in a fresh shell fails closed (correct) — but every homelab load after a USC load
silently returns USC. This was found while verifying an unrelated finding; the verification itself ran
unauthenticated without announcing it, because the empty token was passed to curl as a valid-looking
header.

Fix

  1. Assign unconditionally, then validate. Drop :- for service-scoped fields — a load must
    overwrite, not defer to whatever a previous service left behind.
  2. Clear the namespace first: unset GITEA_URL GITEA_TOKEN at the top of each service branch, so a
    partial config can never be completed by a sibling's values.
  3. Add a re-entrancy test: load A, load B, assert B's values; load A again, assert A's values.
  4. Consider service-prefixed exports (MOSAIC_GITEA_MOSAICSTACK_TOKEN) so sibling services cannot
    collide by construction.
  5. Separately: populate .gitea.mosaicstack.token or remove the service so it fails loudly at
    config level rather than silently at use.

Related

  • #1044 — fail-open on unset identity (same class, API path)
  • #1013 — long-lived bearer tokens in files/argv
  • mosaic cred umbrella — this is precisely what a single governed, fail-closed, audited credential
    path is meant to eliminate.
## Summary `load_credentials` uses the `${VAR:-default}` idiom for every exported field. Because the variables it exports are the SAME names across sibling services (`GITEA_URL` / `GITEA_TOKEN` for both `gitea-mosaicstack` and `gitea-usc`), a second call in the same shell **cannot overwrite the first**. It is a silent no-op that **returns 0**. `~/.config/mosaic/tools/_lib/credentials.sh:185-189`: ```bash export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}" export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}" [[ -n "$GITEA_TOKEN" ]] || { echo "Error: ... not found" >&2; return 1; } ``` The guard on the next line can never fire once `GITEA_TOKEN` is non-empty from a previous load — so the **fail-closed check is unreachable exactly when it matters.** ## Reproduction ``` # fresh shell, homelab only — correctly fails closed $ load_credentials gitea-mosaicstack Error: gitea.mosaicstack.token not found exit=1 token_len=0 # after loading USC first, in the same shell — silently "succeeds" $ load_credentials gitea-usc >/dev/null; load_credentials gitea-mosaicstack exit=0 token_len=40 url=https://git.uscllc.com ``` The caller asked for **HOMELAB** and got **USC's token pointed at USC's URL, with exit 0** and no warning. Nothing in the return value or the environment distinguishes this from a successful load. ## Impact — this defeats estate separation Estate separation (HOMELAB `git.mosaicstack.dev` vs USC `git.uscllc.com`) is a **binding** operator requirement. This bug breaks it through ordinary sequential use in one shell: any script or agent that touches both estates gets the first estate's credentials for the second estate's work, believing it switched. An agent that "switched to homelab" and then wrote is **operating on USC**. It is the same failure class as #1044 (`get_gitea_token` fail-open on unset identity): a credential path that, when it cannot do the right thing, silently does a *different* thing and reports success. **It also has a live victim.** `.gitea.mosaicstack.token` is **absent** from `credentials.json`, so every homelab load in a fresh shell fails closed (correct) — but every homelab load *after* a USC load silently returns USC. This was found while verifying an unrelated finding; the verification itself ran **unauthenticated** without announcing it, because the empty token was passed to `curl` as a valid-looking header. ## Fix 1. **Assign unconditionally, then validate.** Drop `:-` for service-scoped fields — a load must overwrite, not defer to whatever a previous service left behind. 2. **Clear the namespace first**: `unset GITEA_URL GITEA_TOKEN` at the top of each service branch, so a partial config can never be completed by a sibling's values. 3. **Add a re-entrancy test**: load A, load B, assert B's values; load A again, assert A's values. 4. **Consider service-prefixed exports** (`MOSAIC_GITEA_MOSAICSTACK_TOKEN`) so sibling services cannot collide by construction. 5. Separately: **populate `.gitea.mosaicstack.token`** or remove the service so it fails loudly at config level rather than silently at use. ## Related - #1044 — fail-open on unset identity (same class, API path) - #1013 — long-lived bearer tokens in files/argv - `mosaic cred` umbrella — this is precisely what a single governed, fail-closed, audited credential path is meant to eliminate.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mosaicstack/stack#1052