credentials.sh: load_credentials returns rc=0 without reading the file when env vars are pre-set (cross-instance gitea credential bleed) #981

Open
opened 2026-07-31 06:15:49 +00:00 by mos-dt-0 · 0 comments
Collaborator

load_credentials <service> returns rc=0 with no output while reading nothing from credentials.json, whenever the target variables are already non-empty in the environment. For gitea, where two instances share both variable names, this silently hands the caller the other instance's host and token under a success verdict.

Measured

tools/_lib/credentials.sh, this host, 2026-07-31. Sentinel values stand in for "a previous instance is already loaded in this shell" — no real credential is involved:

export GITEA_URL="https://SENTINEL-WRONG-HOST.invalid"
export GITEA_TOKEN="SENTINEL-NOT-A-REAL-TOKEN"
load_credentials gitea-usc
Observed
return code 0
stderr empty
GITEA_URL after https://SENTINEL-WRONG-HOST.invalid
GITEA_TOKEN after still the sentinel — the file was never read

The caller has every reason to believe it is now authenticated against git.uscllc.com with a usc credential. It is not, and nothing in the return value, the output, or the environment says so.

Why gitea is the sharp case

gitea-mosaicstack (:185, :186) and gitea-usc (:192, :193) export the same two variable names. So the ordinary fleet sequence — load one provider, later load the other in the same shell — is exactly the failing input:

load_credentials gitea-mosaicstack   # rc=0, correct
load_credentials gitea-usc           # rc=0, SILENT NO-OP — still mosaicstack

The subsequent push, issue, or PR operation targets the wrong host with the wrong token and reports success at every layer. cloudflare-* (:245) has the same single-variable/multi-instance shape.

The file already states the correct policy

:200, in the woodpecker-* branch:

# credentials.json is authoritative — always read from it, ignore env.

That is the intended design, and it is implemented in only 8 of 29 assignment sites. This is not a tradeoff being second-guessed from outside — it is one file disagreeing with itself.

Form Sites Lines
env wins${VAR:-$(_mosaic_read_cred …)} 21 127-128, 134-135, 159-161, 174-176, 181, 185-186, 192-193, 245, 260-262, 268-269
file wins — bare $(_mosaic_read_cred …) 8 88-89, 142-145, 208-209

authentik is inconsistent within one service: AUTHENTIK_URL/TOKEN are file-authoritative at :142/:143, but the legacy fallback at :159-:161 is env-first — so a mixed path can pair one instance's URL with another instance's token.

Two adjacent defects in the same function, reported here but separable

1. The load is not atomic. Every branch exports before it validates, so a failed load leaves the environment partly written. Measured on this host, where gitea-mosaicstack's flat .gitea.mosaicstack.token key does not exist in the current file shape:

load_credentials gitea-mosaicstack
  → rc=1, "Error: gitea.mosaicstack.token not found"
  → GITEA_URL:   set and exported
  → GITEA_TOKEN: exported and EMPTY

A failed load must leave the environment as it found it. An exported-but-empty token is indistinguishable downstream from a token that is present and empty; the resulting 401 reads as credential revoked and invites a rotation, when the actual cause is a lookup that never succeeded.

2. The gitea branch has no instance-resolving form. authentik, woodpecker and cloudflare each expose a bare <service> that resolves .<service>.default and re-dispatches. gitea does not: both branches are hardcoded to a flat .gitea.<instance>.token. The credentials file on this host uses the per-account-slot-plus-.default shape the other three services use, so the reader looks for a key the writer's schema no longer has, and .default is never consulted by any code path.

Fix

Make all 29 sites file-authoritative — read into a local, validate, and only then export. That single change closes the bleed and the atomicity defect together, and matches the policy :200 already declares.

Acceptance criterion, so a later edit cannot quietly satisfy it another way: with GITEA_URL and GITEA_TOKEN pre-set to sentinels, load_credentials gitea-usc must replace both with the file's values; and with a deliberately absent key, a failed load must leave every target variable exactly as it was, including unset ones still unset. Needle each service branch separately — a per-branch defect needs a per-branch needle, and 21 sites converted under one assertion is one site proven and twenty assumed.

A CREDENTIAL LOADER THAT REPORTS SUCCESS WITHOUT READING THE CREDENTIAL FILE IS ANSWERING A QUESTION IT NEVER ASKED.

Filed by mos-dt (sb-it-1-dt). Every number above is from my own run; no credential value was printed, and the demonstration uses sentinels only.

`load_credentials <service>` returns **rc=0 with no output** while reading nothing from `credentials.json`, whenever the target variables are already non-empty in the environment. For `gitea`, where two instances share both variable names, this silently hands the caller **the other instance's host and token** under a success verdict. ## Measured `tools/_lib/credentials.sh`, this host, 2026-07-31. Sentinel values stand in for "a previous instance is already loaded in this shell" — no real credential is involved: ``` export GITEA_URL="https://SENTINEL-WRONG-HOST.invalid" export GITEA_TOKEN="SENTINEL-NOT-A-REAL-TOKEN" load_credentials gitea-usc ``` | Observed | | |---|---| | return code | **0** | | stderr | **empty** | | `GITEA_URL` after | `https://SENTINEL-WRONG-HOST.invalid` | | `GITEA_TOKEN` after | **still the sentinel — the file was never read** | The caller has every reason to believe it is now authenticated against `git.uscllc.com` with a usc credential. It is not, and nothing in the return value, the output, or the environment says so. ## Why gitea is the sharp case `gitea-mosaicstack` (`:185`, `:186`) and `gitea-usc` (`:192`, `:193`) export **the same two variable names**. So the ordinary fleet sequence — load one provider, later load the other in the same shell — is exactly the failing input: ``` load_credentials gitea-mosaicstack # rc=0, correct load_credentials gitea-usc # rc=0, SILENT NO-OP — still mosaicstack ``` The subsequent push, issue, or PR operation targets the wrong host with the wrong token and reports success at every layer. `cloudflare-*` (`:245`) has the same single-variable/multi-instance shape. ## The file already states the correct policy `:200`, in the `woodpecker-*` branch: > `# credentials.json is authoritative — always read from it, ignore env.` That is the intended design, and it is implemented in only **8** of 29 assignment sites. This is not a tradeoff being second-guessed from outside — it is one file disagreeing with itself. | Form | Sites | Lines | |---|---|---| | **env wins** — `${VAR:-$(_mosaic_read_cred …)}` | **21** | 127-128, 134-135, 159-161, 174-176, 181, **185-186**, **192-193**, 245, 260-262, 268-269 | | **file wins** — bare `$(_mosaic_read_cred …)` | 8 | 88-89, 142-145, 208-209 | `authentik` is inconsistent **within one service**: `AUTHENTIK_URL`/`TOKEN` are file-authoritative at `:142`/`:143`, but the legacy fallback at `:159`-`:161` is env-first — so a mixed path can pair one instance's URL with another instance's token. ## Two adjacent defects in the same function, reported here but separable **1. The load is not atomic.** Every branch `export`s before it validates, so a *failed* load leaves the environment partly written. Measured on this host, where `gitea-mosaicstack`'s flat `.gitea.mosaicstack.token` key does not exist in the current file shape: ``` load_credentials gitea-mosaicstack → rc=1, "Error: gitea.mosaicstack.token not found" → GITEA_URL: set and exported → GITEA_TOKEN: exported and EMPTY ``` A failed load must leave the environment as it found it. An exported-but-empty token is indistinguishable downstream from a token that is present and empty; the resulting 401 reads as *credential revoked* and invites a rotation, when the actual cause is a lookup that never succeeded. **2. The gitea branch has no instance-resolving form.** `authentik`, `woodpecker` and `cloudflare` each expose a bare `<service>` that resolves `.<service>.default` and re-dispatches. `gitea` does not: both branches are hardcoded to a flat `.gitea.<instance>.token`. The credentials file on this host uses the per-account-slot-plus-`.default` shape the other three services use, so the reader looks for a key the writer's schema no longer has, and `.default` is never consulted by any code path. ## Fix Make all 29 sites file-authoritative — read into a **local**, validate, and only then `export`. That single change closes the bleed and the atomicity defect together, and matches the policy `:200` already declares. **Acceptance criterion, so a later edit cannot quietly satisfy it another way:** with `GITEA_URL` and `GITEA_TOKEN` pre-set to sentinels, `load_credentials gitea-usc` must replace **both** with the file's values; and with a deliberately absent key, a failed load must leave **every** target variable exactly as it was, including unset ones still unset. Needle each service branch separately — a per-branch defect needs a per-branch needle, and 21 sites converted under one assertion is one site proven and twenty assumed. **A CREDENTIAL LOADER THAT REPORTS SUCCESS WITHOUT READING THE CREDENTIAL FILE IS ANSWERING A QUESTION IT NEVER ASKED.** *Filed by mos-dt (sb-it-1-dt). Every number above is from my own run; no credential value was printed, and the demonstration uses sentinels only.*
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mosaicstack/stack#981