Skip to content

security: a contract could send any environment variable to a host it named - #751

Merged
fas89 merged 5 commits into
mainfrom
feat/acquisition-secretref-policy
Oct 26, 2026
Merged

fas89 merged 5 commits into
mainfrom
feat/acquisition-secretref-policy

Conversation

@fas89

@fas89 fas89 commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Second and final PR for the roadmap board's "[P1][Security][forge-cli] Acquisition secretRef env:// sends any environment variable to a host the contract names" (Trello 1025). The first half shipped as #750.

The defect

connection.secretRef: env://NAME let the contract choose both halves — the environment variable to read, and the host the resolved value is sent to as a password — and nothing checked either. A run whose environment holds secrets (CI, an operator's shell) could therefore have any variable exfiltrated to a host the contract picked.

All six acquisition runners go through resolve_connection_secrets: airbyte, debezium, dlt, duckdb, kafka_connect, meltano. The card named four — debezium and kafka_connect are callers too.

The policy

Option B, as decided: env:// resolves only when every destination the connection block carries is loopback, or when the operator has listed the variable in FLUID_ACQUISITION_SECRET_REF_ALLOWLIST as NAME (any host) or NAME@host.

  • It lives in resolve_connection_secrets, the one place that sees both halves, so all six runners inherit it without any runner changing.
  • It runs before the reference resolves, so a refused send never reads the secret, and the refusal names the variable and host but never the value — asserted over the whole rendered error, not one field.
  • Only env:// is policed. vault://, aws://, gcp://, azure:// and file:// name a secret the operator provisioned and granted this process access to, which is already an operator decision.
  • The allowlist is operator-only via credentials/protected_env.py, because fluid apply hydrates .env, .env.<env> and FLUID_SECRETS_FILE, which are committed beside the contract. Without that, a contract repository could grant itself the allowlist meant to constrain it. The existing federation mechanism was reused, not reimplemented.

Two things the work itself turned up

  • TESTCONTAINERS_HOST_OVERRIDE was not operator-only while FLUID_RUNNER_HOST_OVERRIDE was. Both rewrite a contract-declared loopback host to a reachable one after the policy clears the send as local-only, so a project .env able to set either could have redirected a credential allowed only because it was staying on this machine — defeating the single allowance this policy grants. Now registered.
  • I had written a second loopback check. _acquisition_common._is_loopback_host already existed and is what apply_loopback_host_override rewrites on; the exemption is sound only while the two cover identical hosts, since a wider one would exempt a send the override leaves pointing at a real host. It now delegates to that one definition, with a test asserting they agree over nine cases.

What the adversarial review found

A four-lens review of this branch returned bypass=FAIL, correctness=FAIL, 8 must-fix. All are fixed in 87545ca5; three were my own mistakes and are worth stating plainly:

  • A working bypass. The policy trusted the first destination field that parsed, so a contract could set host: localhost as a decoy beside a remote connection_string (debezium's mongodb shape) and have the send cleared on the decoy while the credential went to the remote host. Every destination is now collected and all must clear; the field set gained connection_string, server and endpoint.
  • A false claim in an earlier commit message. It said the airbyte deployment.auth.secretRef path was policed. It was not — that edit had failed on a bad anchor, I noted "nothing written" at the time, and then asserted it was done. It is policed now, and a test reads the module for the call site rather than trusting the claim.
  • Fail-open parsing. An allowlist entry NAME@ — a name with an empty host — granted every host, and my own "malformed entry grants nothing" test did not cover it. It now grants nothing, as federation does.

Plus, each with a test: an inline credential is no longer policed (the reference is never read, which the upgrade note already promised); an object-store URI is no longer read as a host, since its authority is a bucket; localhost. is loopback; AcquisitionSecretPolicyError was missing @dataclass so its code reported FluidUserError; the duckdb runner no longer flattens the refusal through except Exception and drops the fix; and the fix now warns that a host written with a port never matches, which is the trip-up federation already warns about.

The limitation that is documented, not closed

The review's sharpest point was not a code defect. The earlier CHANGELOG asserted "the set of contracts this breaks is exactly the set that was vulnerable" and that loopback "needs no configuration and is unaffected". Both are false for kafka_connect and the default bring-your-own / managed modes of debezium and airbyte: there forge-cli never connects to the database at all — it hands the whole connector config, resolved credential included, to the orchestrator at a contract-chosen properties.<engine>.deployment.server_url, which nothing allowlists. A loopback connection.host on those paths is a zero-configuration exfiltration, and it needs no allowlist entry, so it is arguably easier than the route this PR blocks.

Verified unchanged from before this work (resolve_connection_secrets on upstream/main has no policy call, and KafkaConnectRestClient is byte-identical), so nothing regressed — but publishing a guarantee that does not hold is worse than publishing none. There is now a ### Known limitations section naming it, alongside the object-store gap and {{ env.NAME }} interpolation, which bypasses secretRef entirely. deployment.server_url has no host allowlist of the kind FLUID_COMMAND_CENTER_HOST_ALLOWLIST, FLUID_FEDERATION_HOST_ALLOWLIST and FLUID_WEBHOOK_HOST_ALLOWLIST already provide elsewhere; that gap wants its own card.

Validation

  • Full suite, CI-shaped, both versions: 4 failures on duckdb 2.0.0 and 1.5.6 — the same four that fail identically on clean main. 0 caused by this change.
  • Eight existing tests refused correctly and now configure the allowlist, which is the one-line change an operator makes on upgrade, rather than the policy being loosened to accommodate them. The eighth was only caught by the full suite, in the debezium credential-hygiene suite.
  • 50 tests in the policy file; one per review finding.
  • ruff and CI-pinned black==24.10.0 clean whole-tree; lint-imports 6 contracts kept, 0 broken; startup budget 6/6; detect-secrets hook clean over the changed files; duckdb-floor gate 3/3.
  • ripwire --quality-delta gating 16, exactly the base — no quality regressions.

Documentation

  • No docs needed — this change does not affect user-facing behaviour

It does change behaviour for a contract using env:// against a remote host, and that is covered in CHANGELOG.md under ### Upgrade notes with the exact variable to set, the warning that a project .env is ignored, and the note that a host written with a port never matches.

fas89 added 3 commits October 26, 2026 19:31
… named

`connection.secretRef: env://NAME` let the contract choose BOTH halves — the
variable to read and the host the resolved value is sent to as a password — and
nothing checked either. A run whose environment holds secrets (CI, an operator's
shell) could therefore have any variable exfiltrated to a host the contract
picked. All six acquisition runners go through `resolve_connection_secrets`, so
all six were affected: airbyte, debezium, dlt, duckdb, kafka_connect and
meltano. The card named four; debezium and kafka_connect are callers too.

Option B, founder-chosen on 2026-10-26 after the three options were laid out:
`env://` resolves only for a LOOPBACK destination, or when the operator listed
the variable in FLUID_ACQUISITION_SECRET_REF_ALLOWLIST as `NAME` or `NAME@host`.
The check sits in `resolve_connection_secrets`, the one place that sees both
halves, so every runner inherits it without touching any runner. It runs BEFORE
resolution, so a refused send never reads the secret, and the refusal names the
variable and host but never the value — asserted over the whole rendered error,
not one field.

Only `env://` is policed. `vault://`, `aws://`, `gcp://`, `azure://` and
`file://` name a secret the operator provisioned and granted this process access
to, which is already an operator decision; `env://` turns whatever happens to be
in the environment into a credential for a host the contract chose.

The allowlist is operator-only, registered in `credentials/protected_env.py`
beside the federation one: `fluid apply` hydrates `.env`, `.env.<env>` and
FLUID_SECRETS_FILE, and those files are committed beside the contract, so
without this a contract repository could grant itself the allowlist meant to
constrain it. That existing mechanism was reused rather than reimplemented.

TWO THINGS THE WORK ITSELF TURNED UP, both fixed here:

1. `TESTCONTAINERS_HOST_OVERRIDE` was NOT operator-only, while
   FLUID_RUNNER_HOST_OVERRIDE was. Both rewrite a contract-declared loopback
   host to a reachable one AFTER this policy clears the send as local-only, so a
   project dotenv file could have redirected a credential allowed only because
   it was staying on this machine — defeating the single allowance option B
   grants. Now registered.
2. I had written a second loopback check. `_acquisition_common._is_loopback_host`
   already existed and is what `apply_loopback_host_override` rewrites on, and
   the exemption is sound ONLY while the two cover the same hosts: wider here
   would exempt a send the override leaves pointing at a real host. It now
   delegates to that one definition, and a test asserts they agree over nine
   cases. The narrowing is deliberate — `db.localhost` is remote and needs an
   entry, because the override would not rewrite it either.

Resolved values are registered with the exact-value redactor (#884): a literal a
client library echoes in its own error is now masked by value, not by hoping a
pattern matches its shape.

Seven existing tests refused correctly — they use `env://` against `db.test` and
`db.example`, which are remote. They now set the allowlist, which is the same
one-line change an operator makes on upgrade, rather than the policy being
weakened to accommodate them. Three per-class copies of that fixture were
hoisted to one module-level fixture.

CHANGELOG carries the upgrade note: which contracts stop working, the single
variable that restores each, and that local development is unaffected.

Verified: 36 new policy tests and 1735 build_runners tests green; import-linter
6 contracts kept, 0 broken; startup budget 6/6; ruff and CI-pinned black clean
whole-tree.
The full-suite run caught an eighth refusal the targeted runs had not:
tests/security/test_credentials_stay_out_of_files_and_diagnostics.py drives the
DEBEZIUM runner — one of the two callers the card never listed — with
`env://PG_PASSWORD_T211` against the bare host `db`, which is not loopback.

A correct refusal, so the test sets the allowlist rather than the policy being
loosened. That suite is about the secret not reaching a file or a diagnostic
once it is legitimately in use, which is a different control from whether the
send is permitted at all.

Swept every test file mentioning env:// afterwards; none still refuses.
299 security tests green.
The 4-lens review of the policy returned bypass=FAIL, correctness=FAIL and 8
must-fix. It was right, and two of the findings were my own errors stated as
fact in the previous commit message.

THE BYPASS (high). `connection_host` trusted the first destination field that
parsed, so a contract set `host: localhost` as a decoy beside a remote
`connection_string` (debezium's mongodb shape) and the send was cleared on the
decoy while the credential went to the remote host. `connection_hosts` now
returns EVERY destination and the policy clears all of them; the field set also
gained `connection_string`, `server` and `endpoint`.

THE CLAIM I GOT WRONG. The previous commit said the airbyte
`deployment.auth.secretRef` path was policed. It was not: that edit had failed
on a bad anchor, I noted "nothing written" at the time and never redid it, then
asserted it was done. It is policed now, and a test reads the module source for
the call site rather than trusting a claim.

FAIL-OPEN PARSING. An allowlist entry of the form `NAME@` — a name with an
empty host — parsed to "any host", the opposite of fail-closed, and my own
"malformed entry grants nothing" test did not cover it. `NAME@` now grants
nothing. federation fails closed here too, which is where I should have looked.

Also fixed, each with a test:
- A `secretRef` whose target field already holds an inline credential is no
  longer policed: the reference is never read and nothing is sent, which is
  what the upgrade note already promised.
- An object-store URI (`s3://`, `gs://`, `az://`, `file://`) is no longer read
  as a host. Its authority is a bucket, so refusing on it broke working
  contracts and suggested an allowlist entry naming a bucket. Now explicitly
  out of the policy's model, and recorded as a known limitation.
- `localhost.`, the FQDN form, is loopback.
- `AcquisitionSecretPolicyError` was missing `@dataclass`, so its
  machine-readable `code` reported `FluidUserError`.
- The duckdb runner flattened the refusal through `except Exception`, dropping
  the `fix` the operator needs; it now propagates so the CLI renders the panel.
- The refusal's `fix` warns that a host written with a port never matches —
  the trip-up federation already warns about — and a test pins that it fails
  closed.
- `describe()` was dead code.

THE CHANGELOG NOW SAYS WHAT IS TRUE. The review's sharpest point was not a code
defect: the previous text asserted "the set of contracts this breaks is exactly
the set that was vulnerable" and that loopback "needs no configuration and is
unaffected". Both are false for kafka_connect and the `bring-your-own` /
`managed` modes of debezium and airbyte — the DEFAULT modes — where forge-cli
does not connect to the database at all but hands the whole connector config,
credential included, to the orchestrator at a contract-chosen
`deployment.server_url` that nothing allowlists. A loopback `connection.host`
there is a zero-configuration exfil, unchanged from before this release.
Publishing a guarantee that does not hold is worse than publishing none, so
there is now a `### Known limitations` section naming that gap, the
object-store gap, and `{{ env.NAME }}` interpolation, which bypasses
`secretRef` entirely.

Rebased onto 98e51fb (#750), resolving the CHANGELOG conflict and
consolidating `## [Unreleased]` back to one section per heading.

2830 tests green across build_runners, cli/discover and security; 50 in the
policy file itself.
@github-actions github-actions Bot added security Security-related changes or reports tests Test coverage or test infrastructure changes labels Oct 26, 2026
Comment thread fluid_build/build_runners/_secret_policy.py Fixed
Comment thread tests/build_runners/test_acquisition_secret_policy.py Fixed
fas89 added 2 commits October 26, 2026 20:03
…s clean

`Lint & Format` failed on four detect-secrets findings in this branch, all
false positives, and my pre-push run had missed them: it was not the pinned
1.5.0 over the PR's own changed-file list. Reproduced faithfully first
(detect-secrets==1.5.0, the same `detect-secrets-hook --baseline` call CI
makes over `git diff --name-only BASE HEAD`), then fixed:

* The upgrade note's example wrote the allowlist as `NAME="VALUE"`, which is
  the keyword detector's exact shape. Reworded to name the variable and give
  the value as prose, so the note needs no pragma wedged into rendered text.
* Four lines in the policy tests carry `password`/userinfo literals that ARE
  the shape under test. Each gets an inline `pragma: allowlist secret`.

Two traps worth recording. The detector reports one finding per value per
file, so allowlisting line 183 surfaced the identical literal at 322 — a
second round was needed, not a sign the first fix failed. And black then
exploded both literals and carried the trailing pragma onto the closing
bracket, where detect-secrets does not look: each pragma had to move back
onto the line that actually holds the value. Verified with CI's pinned
black==24.10.0, not the newer black in this machine's venvs, which disagrees
with it on 30 unrelated files.

`.secrets.baseline` carries refreshed line numbers for the files this branch
moved. The diff is line numbers and `generated_at` only — no hashed secret is
added, so nothing new is being recorded or suppressed.

Gates: detect-secrets-hook rc=0 over the changed-file list; ruff clean;
black==24.10.0 clean whole-tree; 383 passed, 2 skipped across the policy
suite and the three existing suites this branch touches.
The code-quality bot found `typing.Dict` in `_secret_policy.py` and `os` in
the policy test; both are leftovers from earlier drafts and neither is
referenced. Removed.

Repo-wide lint cannot catch these: `F401` is in the `ignore` list in
pyproject's `[tool.ruff.lint]` ("many are re-exports"), so `ruff check
fluid_build/ tests/` passes over a dead import and only the bot flags it. The
one job that does select `F401` scopes itself to `fluid_build/copilot` and
`fluid_build/forge_datamodel`. Worth running `ruff check --select F401,F841`
over a branch's own files before pushing, since nothing else will.

Doing that here found five more, all **identical on the base commit**
`98e51fb6` and so none of this branch's doing: four function-local `import
duckdb` statements in `build_runners/duckdb/runner.py` (lines 1023, 1128,
1525, 1583) and an unused `ctx` local in `test_acquisition_common.py:801`.
Left alone deliberately — a security PR is the wrong place to touch four
unrelated call sites in the duckdb runner, and the `ctx` fix is one ruff
itself marks unsafe.

Gates after the change: ruff clean whole-tree, black==24.10.0 clean
whole-tree, `ruff --select F401` clean on both edited files,
detect-secrets-hook rc=0 with the baseline unchanged, import-linter 6 kept /
0 broken, startup budget 6 passed, 383 passed / 2 skipped across the policy
suite and the three existing suites this branch touches.
@fas89
fas89 merged commit 135ad48 into main Oct 26, 2026
37 checks passed
fas89 added a commit that referenced this pull request Oct 27, 2026
… named (#758)

* security: a contract could send a product's credential to any host it named

On the kafka-connect path, and on the DEFAULT `bring-your-own` / `managed`
modes of debezium and airbyte, forge-cli never connects to the source
database. It resolves the contract's `connection.secretRef`, puts the
plaintext into the connector config (`database.password`,
`connection.password`) and POSTs the whole config to
`properties.<engine>.deployment.server_url` — a URL the CONTRACT chooses.
Nothing constrained that host, so the credential could go anywhere, needing no
allowlist entry and no operator mistake; a loopback `connection.host` even made
the build look local, so the `env://` policy from #751 cleared it as staying on
this machine.

A public orchestrator host must now be named in
`FLUID_ACQUISITION_DEPLOYMENT_HOST_ALLOWLIST`. Loopback and private hosts need
no entry — the ordinary cluster deployment, and a credential that stays inside
the organisation is not the leak this closes. Link-local and unspecified are
refused outright, allowlist or not: that is where instance metadata lives.

**The three existing HOST_ALLOWLIST variables are the wrong model.** The card
said to mirror `FLUID_COMMAND_CENTER_HOST_ALLOWLIST`,
`FLUID_FEDERATION_HOST_ALLOWLIST` and `FLUID_WEBHOOK_HOST_ALLOWLIST`. All three
are SSRF allowlists: unset, they PERMIT any public host and refuse private
ones, and the Command Center gate still lets a credential reach any public
`https` endpoint unlisted. Mirroring them would have reproduced the hole. The
deny-unless-named shape comes from `_secret_policy.py`, which answers the same
question for `connection.secretRef`.

**A mixed DNS answer is public.** `_net.hostname_scope` reports `public`,
`non_public`, `mixed` or `unresolved`, and examines every address. Stopping at
the first non-public one — which the first version of this did — was a complete
bypass: an attacker naming the host answers with one private record beside
their public one, the policy calls the host internal and allows it unlisted,
and `safe_http` pins the public address anyway. `_hostname_is_private` keeps
answering True for `mixed` and `unresolved`, its fail-closed direction and the
opposite of this gate's, which is why the states cannot be collapsed.

**The address predicate and the non-routable-range list move to tier-0 `_net`
and `safe_http` re-exports them.** They existed in both places and disagreed:
`_hostname_is_private` judged on bare `ipaddress` flags, so a Tailscale/CGNAT
address (`100.64.0.0/10`) was public there and non-public in `safe_http`. This
policy therefore refused a CGNAT orchestrator as "public" — wrong answer, wrong
reason. One list now, consulted by both; `_hostname_is_private` gets stricter
for CGNAT/6to4/NAT64/TEST-NET, which only ever refuses more.

**The gate runs where each runner reads `server_url`,** before the connector
config is built — and building it is what resolves the `secretRef`. The client
constructors keep the same check as the chokepoint for any other caller, which
is also where an operator-supplied URL is distinguished. The constructor check
ALONE was not enough and the first version of this promised an ordering it did
not have: all three runners build the config first, so a refused deployment had
already fetched the password from Vault/KMS.

**A refusal is no longer swallowed.** The kafka-connect status poll caught it as
eventual consistency and retried to the deadline; the outer handler flattened it
to `str(exc)`, dropping the `why` and the `fix` that names the variable. Both
re-raise it now.

`fluid import airbyte --server-url` exposed an over-reach in the live run: that
URL is the OPERATOR's, and the policy exists because a CONTRACT can choose the
destination. It refused a legitimate action and told the operator to set an
allowlist variable to permit what they had just asked for.
`operator_supplied=True` marks that path, with a message that blames no contract
and does not offer the allowlist as the remedy for a metadata address.

Modular by request: one policy module, two client constructors (debezium shares
kafka-connect's), three runner call sites, so a fourth engine costs one line.
The variable is operator-only in `credentials/protected_env.py`, because `fluid
apply` hydrates `.env`, `.env.<env>` and `FLUID_SECRETS_FILE`, committed beside
the contract.

Validation. 56 tests, DNS faked throughout — the first version resolved
`example.com` and skipped on failure, so six of eight DNS-dependent assertions
could pass without testing anything. Five mechanisms are mutation-checked:
first-hit classification, the unconsulted deny-list, the missing `mixed`
branch, the removed runner-level gate and a no-op request guard each fail their
own test. Full suite CI-shaped on both floors, run sequentially: 27989 and
27988 passed, 4 failed — the same four that fail on clean base. Live:
`fluid import airbyte --server-url https://example.com` passes the gate and
fails on the real endpoint; the same command against 169.254.169.254 is
refused; a contract-chosen public host is refused with exit 1. ruff,
black==24.10.0, license headers, import-linter (6 kept / 0 broken), startup
budget and detect-secrets all clean.

* test(build_runners): one import style for the kafka_connect runner

The ordering test reached the runner module through a dotted `import ... as`
while every other test in the file used `from ... import`, which the quality
bot flagged on #758. The from-import of the module object keeps the
monkeypatch target (the runner's own namespace, which is the whole point of
that test) without a `sys.modules` lookup.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

security Security-related changes or reports tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant