Basic Auth / custom labels are API-settable on applications at 4.1.2 — cast just can't say them; rebuilt resources stay unprotected #76

Closed
opened 2026-07-16 12:17:37 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-16 12:17:37 +00:00 (Migrated from github.com)

Summary

UNCAPTURED.md's NO_API_COVERAGE row says Basic Auth / custom Traefik labels are "carried as raw container labels. cast's manifest has no field for them, so a rebuilt resource is UNPROTECTED where the original was not." The #72 audit found that for applications this is a cast vocabulary gap, not a Coolify gap, at v4.1.2:

  • custom_labels (base64-validated, ApplicationsController.php:3836-3854), is_http_basic_auth_enabled, http_basic_auth_username, http_basic_auth_password are all in both create and PATCH allowlists (:914, :2368).
  • PATCH enforces username/password presence when enabling basic auth and regenerates labels (:2446-2463).

So a manifest could declare basic auth (and optionally raw labels) and apply could set them — closing the "rebuilt resource is silently unprotected" hole for the app case.

Caveats that shape the design

  • Reading custom_labels back requires a sensitive-data-enabled token at 4.1.2, and on next this hardens into the read:sensitive token ability (see the v4.2 tracker) — the diff story depends on what the token can see; fail honest when it can't.
  • Label regeneration overwrites: enabling basic auth or changing domains triggers generateLabelsApplication() which clobbers custom_labels unless is_container_label_readonly_enabled — which is itself NOT API-settable at 4.1.2 (it is on next). Declaring both custom_labels and domains/basic-auth on one app is therefore a footgun until v4.2; basic-auth-only is the safe first slice.
  • Services have nothing — no labels, no basic auth fields in ServicesController, on either release. The UNCAPTURED row stays true for services and should say so specifically instead of blanket-covering both. (Unclaimed upstream; #72 feature-request list item 4.)
  • Secret handling: http_basic_auth_password belongs in the age store as a ${REF}, never a manifest literal.

What to do

  • Decide scope: basic-auth manifest fields for applications first; raw custom_labels only if a real use appears (the overwrite caveat makes it hostile until v4.2)
  • Narrow the UNCAPTURED row to services once the app side is expressible

Optional/low-priority — filed so #72's finding isn't lost. Found in #72 (finding 7).

## Summary UNCAPTURED.md's `NO_API_COVERAGE` row says Basic Auth / custom Traefik labels are "carried as raw container labels. cast's manifest has no field for them, so a rebuilt resource is UNPROTECTED where the original was not." The #72 audit found that for **applications** this is a cast vocabulary gap, not a Coolify gap, at v4.1.2: - `custom_labels` (base64-validated, `ApplicationsController.php:3836-3854`), `is_http_basic_auth_enabled`, `http_basic_auth_username`, `http_basic_auth_password` are all in **both** create and PATCH allowlists (`:914`, `:2368`). - PATCH enforces username/password presence when enabling basic auth and regenerates labels (`:2446-2463`). So a manifest could declare basic auth (and optionally raw labels) and `apply` could set them — closing the "rebuilt resource is silently unprotected" hole for the app case. ## Caveats that shape the design - **Reading `custom_labels` back requires a sensitive-data-enabled token** at 4.1.2, and on `next` this hardens into the `read:sensitive` token ability (see the v4.2 tracker) — the diff story depends on what the token can see; fail honest when it can't. - **Label regeneration overwrites:** enabling basic auth or changing domains triggers `generateLabelsApplication()` which clobbers `custom_labels` unless `is_container_label_readonly_enabled` — which is itself NOT API-settable at 4.1.2 (it is on `next`). Declaring both `custom_labels` and domains/basic-auth on one app is therefore a footgun until v4.2; basic-auth-only is the safe first slice. - **Services have nothing** — no labels, no basic auth fields in `ServicesController`, on either release. The UNCAPTURED row stays true for services and should say so specifically instead of blanket-covering both. (Unclaimed upstream; #72 feature-request list item 4.) - Secret handling: `http_basic_auth_password` belongs in the age store as a `${REF}`, never a manifest literal. ## What to do - [ ] Decide scope: basic-auth manifest fields for applications first; raw `custom_labels` only if a real use appears (the overwrite caveat makes it hostile until v4.2) - [ ] Narrow the UNCAPTURED row to services once the app side is expressible Optional/low-priority — filed so #72's finding isn't lost. Found in #72 (finding 7).
dan-claude-bot commented 2026-07-16 15:53:37 +00:00 (Migrated from github.com)

Deferred by operator decision (2026-07-16) — leaving open. Research from attempting it, so the next attempt starts ahead:

1. custom_labels is unsound to manage declaratively at 4.1.2 — recommend dropping it from this issue's scope. It is API-settable, but Coolify regenerates container labels on any domain or basic-auth change and overwrites custom_labels; the flag that would prevent that (is_container_label_readonly_enabled) is itself not API-settable in 4.1.2. So cast would write it and Coolify would clobber it — perpetual drift, or a value silently reverted. That is precisely the shape cast legislates against (#12/#14/#17/#18). It only becomes tractable if is_container_label_readonly_enabled gains API surface — worth checking on next as part of #77.

2. Basic Auth is the sound half, but it forces a design decision. is_http_basic_auth_enabled / http_basic_auth_username / http_basic_auth_password are in both create and PATCH allowlists, and Coolify owns the generated label when basic auth is enabled — so there is no clobbering problem. The snag is that the password is a secret, and cast's iron rule is no secret values in the manifest. The clean design:

  • manifest carries username plus a ${REF} for the password, resolved from the age store exactly as env secrets are (so requiredSecrets must collect it and capture must store it — that is the real cost: it touches the secret-collection path, not just a schema);
  • is_http_basic_auth_enabled + username are readable (removeSensitiveData hides neither) → diffed normally;
  • http_basic_auth_password is hidden behind read:sensitivedeclared-not-compared (the house pattern from backup/is_static/destination), which also means a password-only rotation isn't detectable. Worth saying out loud in the draft/semantics if built.

So the audit's framing holds — this is a cast vocabulary gap for applications — but the sound fix is a secret-machinery change, not a field addition. Hence deferring rather than rushing it.

**Deferred by operator decision (2026-07-16) — leaving open.** Research from attempting it, so the next attempt starts ahead: **1. `custom_labels` is unsound to manage declaratively at 4.1.2 — recommend dropping it from this issue's scope.** It is API-settable, but Coolify **regenerates container labels** on any domain or basic-auth change and overwrites `custom_labels`; the flag that would prevent that (`is_container_label_readonly_enabled`) is itself **not API-settable in 4.1.2**. So cast would write it and Coolify would clobber it — perpetual drift, or a value silently reverted. That is precisely the shape cast legislates against (#12/#14/#17/#18). It only becomes tractable if `is_container_label_readonly_enabled` gains API surface — worth checking on `next` as part of #77. **2. Basic Auth is the sound half, but it forces a design decision.** `is_http_basic_auth_enabled` / `http_basic_auth_username` / `http_basic_auth_password` are in both create and PATCH allowlists, and Coolify *owns* the generated label when basic auth is enabled — so there is no clobbering problem. The snag is that the **password is a secret**, and cast's iron rule is *no secret values in the manifest*. The clean design: - manifest carries `username` plus a `${REF}` for the password, resolved from the **age store** exactly as env secrets are (so `requiredSecrets` must collect it and `capture` must store it — that is the real cost: it touches the secret-collection path, not just a schema); - `is_http_basic_auth_enabled` + `username` are readable (`removeSensitiveData` hides neither) → **diffed** normally; - `http_basic_auth_password` is hidden behind `read:sensitive` → **declared-not-compared** (the house pattern from `backup`/`is_static`/`destination`), which also means a password-only rotation isn't detectable. Worth saying out loud in the draft/semantics if built. So the audit's framing holds — this is a **cast vocabulary gap for applications** — but the sound fix is a secret-machinery change, not a field addition. Hence deferring rather than rushing it.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#76
No description provided.