Repository navigation
security: a contract could send any environment variable to a host it named - #751
Merged
Merged
Conversation
… 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.
…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.
This was referenced Oct 26, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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://NAMElet 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 inFLUID_ACQUISITION_SECRET_REF_ALLOWLISTasNAME(any host) orNAME@host.resolve_connection_secrets, the one place that sees both halves, so all six runners inherit it without any runner changing.env://is policed.vault://,aws://,gcp://,azure://andfile://name a secret the operator provisioned and granted this process access to, which is already an operator decision.credentials/protected_env.py, becausefluid applyhydrates.env,.env.<env>andFLUID_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_OVERRIDEwas not operator-only whileFLUID_RUNNER_HOST_OVERRIDEwas. Both rewrite a contract-declared loopback host to a reachable one after the policy clears the send as local-only, so a project.envable 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._acquisition_common._is_loopback_hostalready existed and is whatapply_loopback_host_overriderewrites 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 in87545ca5; three were my own mistakes and are worth stating plainly:host: localhostas a decoy beside a remoteconnection_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 gainedconnection_string,serverandendpoint.deployment.auth.secretRefpath 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.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;AcquisitionSecretPolicyErrorwas missing@dataclassso itscodereportedFluidUserError; the duckdb runner no longer flattens the refusal throughexcept Exceptionand drops thefix; and thefixnow 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/managedmodes 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-chosenproperties.<engine>.deployment.server_url, which nothing allowlists. A loopbackconnection.hoston 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_secretsonupstream/mainhas no policy call, andKafkaConnectRestClientis byte-identical), so nothing regressed — but publishing a guarantee that does not hold is worse than publishing none. There is now a### Known limitationssection naming it, alongside the object-store gap and{{ env.NAME }}interpolation, which bypassessecretRefentirely.deployment.server_urlhas no host allowlist of the kindFLUID_COMMAND_CENTER_HOST_ALLOWLIST,FLUID_FEDERATION_HOST_ALLOWLISTandFLUID_WEBHOOK_HOST_ALLOWLISTalready provide elsewhere; that gap wants its own card.Validation
main. 0 caused by this change.ruffand CI-pinnedblack==24.10.0clean whole-tree;lint-imports6 contracts kept, 0 broken; startup budget 6/6;detect-secretshook clean over the changed files; duckdb-floor gate 3/3.ripwire --quality-deltagating 16, exactly the base — no quality regressions.Documentation
It does change behaviour for a contract using
env://against a remote host, and that is covered inCHANGELOG.mdunder### Upgrade noteswith the exact variable to set, the warning that a project.envis ignored, and the note that a host written with a port never matches.