Repository navigation
security: a declared remote prefix handed a local directory to contract SQL - #757
Merged
Merged
Conversation
…ct SQL
A contract that declares a remote prefix whose text also names a local path got
that directory granted to its SQL. DuckDB resolves every `allowed_directories`
entry against the file system, and a link in the working directory -- which the
contract's own repository supplies -- is enough: with `s3: -> /` present,
`DuckDBAllowlist.none().with_remote("s3://etc/")` reported `/private/etc/` in
`allowed_directories` and `read_text('/etc/hosts')` returned a row, from an
allowlist that granted no directory at all. Reproduced on duckdb 1.5.1, 1.5.6
and 2.0.0, and reachable from a real call site: `providers/local/local.py` hands
a raw `s3://` URI to `with_declared`, and `cli/discover/filesystem.py` hands one
to `with_locations`.
The read-back added in #743 could not catch it. Its accommodation for DuckDB
1.5 -- which reports `s3://bucket/landing/` as `<cwd>/s3:/bucket/landing/` --
was written as `realpath(prefix)`, and that follows symlinks inside the prefix,
so the escape's own resolution entered the granted set and excused the grant.
Two measurements decided the shape of the fix, each killing an earlier plan:
* Remote prefixes are NOT inert, so they cannot simply be left out of
`allowed_directories`. With `httpfs` loaded before the lock, remote reads
work after it despite `enable_external_access = false`, and the prefix is
what bounds them: against the public `s3://noaa-ghcn-pds/` bucket on 1.5.1
and 1.5.6, granting no prefix refuses, granting a different bucket refuses,
granting the target's own prefix reads. Dropping the entry would have broken
legitimate remote reads.
* Existence is the wrong test. DuckDB grants a resolved path that does not
exist yet -- `s3://staging-area/` with `s3: -> /` grants `/staging-area` --
and contract SQL can write into it.
What actually separates the honest case from the escape is symlink resolution:
the form DuckDB 1.5 reports is the LEXICAL join of cwd and the prefix, and an
escaping prefix differs from it only because its components follow a link.
`_escaping_remote_prefix` compares the two and returns the directory DuckDB
would grant. `_refuse_escaping_remote_prefixes` runs BEFORE
`SET allowed_directories`, so the connection never holds the grant, and
`_refuse_ungranted_dirs` keeps catching it as a backstop now that its
accommodation is the lexical form. Tests pin each barrier with the other
bypassed, and one test witnesses the defect with both bypassed so the file
proves what it guards.
Also here, because the live run through `fluid init --discover` surfaced it: a
sandbox refusal reached the discover handler's `except Exception` and was
reframed as `ConnectivityProbeError`, whose fix reads "Check VPN / network
policy / credentials" -- advice that sends the reader to their network for a
link in their working directory. A typed error now keeps its own what/why/fix,
still masked, because the refusal names the location it refused and that can
carry userinfo; verified with `https://alice:<secret>@example.com/data.csv`,
which renders `***REDACTED***` and leaves no copy of the secret. An awkward
`FluidUserError` subclass that `replace` cannot rebuild falls back to the old
framing rather than raising a second error while handling the first. The
prefixes live in `why` rather than `what` because the renderer uses `what` as
the panel title and truncates it to one line, which was cutting off the detail
the message exists to give.
Validation. 8 new tests pass on 1.5.1, 1.5.6 and 2.0.0; 6 of the 8 fail when
the production change is reverted, on 1.5.6 and 2.0.0 alike (the other 2 are
the legitimate-prefix guard and the defect witness, which must not). Full suite
CI-shaped on both floors: 27853 and 27851 passed, 4 failed -- the same 4 that
fail on clean `7cf5471a`, verified in a detached base worktree. Perf run alone
38 passed / 7 xfailed on both. Live: `fluid init --discover
s3://noaa-ghcn-pds/csv/by_year/2024.csv` emits a contract from a clean
directory (8 columns, exit 0) and is refused with exit 1 and no contract
written from one holding `s3: -> /`. ruff, black==24.10.0, license headers,
import-linter (6 kept / 0 broken) and the startup budget all clean.
…ur of six A four-lens adversarial review of this branch raised 17 findings; 12 were refuted on inspection and 5 confirmed, all LOW. Two lenses found the same real one, and it is this branch's doing. `_kept_or_reframed` masked `what`, `why`, `fix` and `where` and copied `extras` and `doc` through verbatim. `FluidUserError.as_json` emits both. On `origin/main` the function always built a fresh `ConnectivityProbeError`, whose `extras` is empty, so no field of the raised error could survive — this branch created the first path that propagates a discoverer-supplied `extras` dict, and left the one dict-valued field out of the masking it applies in the same expression. A discoverer registered out of tree that carried the location under a key of its own (`dsn`, say; `uri` is overwritten here) would have had the credential serialised in full. Latent — no in-tree error sets `extras` on this path — but `as_json` is exactly what the sibling tests assert a credential never reaches, so the inconsistency is worth closing rather than documenting. `_masked_extras` walks nested dicts and lists, because `as_json` dumps whatever depth it is given. `doc` is masked too. The rebuild's `except` is now broad: a subclass can refuse to rebuild in more ways than `TypeError`, and this runs while an error is live, where raising a second one would attach the driver's error, URI and password as context. The review's other three confirmed findings were all about the tests, and all three were fair: * The read-back's deferral to the escape message was never exercised. In `test_the_read_back_still_refuses_when_the_pre_set_check_is_bypassed` the patched no-op IS the function the read-back defers to, so that test witnesses the generic "did not ask for" path. `test_the_read_back_defers_to_the_escape_message` drives `_refuse_ungranted_dirs` directly with the sibling suite's `_Reporting` stand-in so the real check runs inside it. * The masking test put the credential only in `why`, which made its `what` and `fix` assertions vacuous. It now puts the credential in what, why, fix, doc, where and a nested `extras`, asserts it is absent from each and from `as_json`, and asserts the host is still named so the value is masked rather than dropped. A second test covers the rebuild fallback with a subclass `replace` cannot reconstruct. * The file's docstring claimed "6 of 8 fail when the production change is reverted" without saying what was reverted. A literal revert deletes `_refuse_escaping_remote_prefixes`, so the tests that patch it error rather than fail. The docstring now says which revert was measured and what the literal one does. Each of the three new tests was mutation-checked and fails with its own mechanism removed: dropping the extras/doc masking fails the masking test, dropping the try/except fails the fallback test, dropping the deferral call fails the deferral test. Also settled: the two extra failures a contended run showed (`test_no_change_yields_no_decisions`, `test_fluid_validate_cli_passes_end_to_end`) are wall-clock, not logic — hypothesis's "Input generation is slow" health check and a 120s `subprocess.TimeoutExpired` on a `fluid validate` child, with 32 workers across two venvs competing. Re-run with a single venv, this branch shows exactly the 4 failures that clean `7cf5471a` shows under the same command.
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.
Closes the roadmap board's "[P0][Security][forge-cli] A declared remote prefix resolves as a local path, granting the host to contract SQL" (Trello 1209). Found by the security review of #743 and left open there deliberately, because the root cause is code #743 does not touch.
The defect
DuckDB resolves every
allowed_directoriesentry against the file system. A contract declares the remote prefix, and its own repository supplies the working directory — so a link named for the scheme is enough. Withs3: -> /present:Reproduced on duckdb 1.5.1, 1.5.6 and 2.0.0. Reachable from real call sites:
providers/local/local.pyhands a raws3://URI towith_declared, andcli/discover/filesystem.pyhands one towith_locations.#743's read-back could not catch it. Its accommodation for DuckDB 1.5 — which reports
s3://bucket/landing/as<cwd>/s3:/bucket/landing/— was written asrealpath(prefix), and that follows symlinks inside the prefix, so the escape's own resolution entered the granted set and excused the grant it had just produced.Two measurements that killed two earlier plans
Both are worth recording, because each looked right on paper.
enable_external_access = false, so stop putting them inallowed_directories." False. Withhttpfsloaded before the lock, remote reads work after it, and the prefix is what bounds them. Against the publics3://noaa-ghcn-pds/bucket on 1.5.1 and 1.5.6: no prefix granted → refused; a different bucket granted → refused; the target's own prefix → reads. Dropping the entry would have broken legitimate remote reads.s3://staging-area/withs3: -> /grants/staging-area— and contract SQL can write into it.What actually separates the honest case from the escape is symlink resolution: DuckDB 1.5's reported form is the lexical join of cwd and the prefix, and an escaping prefix differs from it only because its components follow a link.
The fix
_escaping_remote_prefix(prefix, cwd)compares the lexical join againstrealpath, and returns the directory DuckDB would grant._refuse_escaping_remote_prefixes(allow)runs beforeSET allowed_directories, so the connection never holds the grant._refuse_ungranted_dirskeeps catching it as a backstop, now that its accommodation is the lexical form, and defers to the escape message — the generic one tells the reader to report a sandbox bug, which is wrong here, since a contract plus its repository is all it takes.Two barriers, each tested with the other bypassed, plus a test that witnesses the grant with both bypassed, so the file proves what it guards instead of only asserting the guard runs.
A second defect the live run surfaced
Driving the real CLI (not the library) exposed that a sandbox refusal reached the discover handler's
except Exceptionand came out framed asConnectivityProbeError, whose fix reads "Check VPN / network policy / credentials" — sending the reader to their network for a link in their working directory. A typed error now keeps its ownwhat/why/fix, still masked by the existing machinery, because the refusal names the location it refused and a URL can carry a password in its userinfo. The prefixes moved fromwhatintowhy: the renderer useswhatas the panel title and truncates it to one line, which was cutting off the detail the message exists to give.What the adversarial review found
Four lenses (bypass, other-security, correctness, test-quality), each finding then handed to a skeptic told to refute it: 17 raised, 12 refuted, 5 confirmed, all LOW. All five are fixed in the second commit. The one that mattered was mine and newly introduced here:
_kept_or_reframedmasked four of the six fieldsas_jsonemits and copiedextrasanddocthrough verbatim. Onmainthe function always built a freshConnectivityProbeError, whoseextrasis empty, so no field of the raised error could survive; this branch created the first path propagating a discoverer-suppliedextrasdict and then left the one dict-valued field out of the masking applied in the same expression. Latent — no in-tree error setsextrason this path, and theurikey is overwritten — but a discoverer registered out of tree carrying the location under a key of its own would have had the credential serialised in full, andas_jsonis exactly what the sibling tests assert a credential never reaches._masked_extrasnow walks nested dicts and lists;docis masked; the rebuild'sexceptis broad because this runs while an error is live, where raising a second one would attach the driver's error, URI and password as context.The other three were all fair criticisms of my tests: the read-back's deferral was never actually exercised (the patched no-op is the function it defers to, so that test witnessed the generic path); the masking test put the credential only in
why, making its other assertions vacuous; and the file's docstring claimed "6 of 8 fail on revert" without saying which revert was measured. All three corrected, and each new test mutation-checked — dropping the extras/doc masking, the try/except, or the deferral call fails its own test.Refuted, for the record:
..as the URL host, a scheme-named real directory rather than a symlink, and a scheme-stripped copy appearing in the message. A plain directory nameds3:does yield a grant inside the repository the contract author already controls, which is why the symlink case is the one that escapes.Validation
7cf5471a. Perf suite alone: 38 passed / 7 xfailed on both. Startup budget 6/6.Input generation is slowhealth check, andsubprocess.TimeoutExpired ... after 120 secondson afluid validatechild, with 32 workers across two venvs. With a single venv this branch shows exactly base's four.fluid init --discover s3://noaa-ghcn-pds/csv/by_year/2024.csvemits a contract from a clean directory (8 columns, exit 0) and is refused with exit 1 and no contract written from one holdings3: -> /, naming the prefix and where it resolved. Withhttps://alice:<secret>@example.com/data.csvthe output shows***REDACTED***and no copy of the secret.black==24.10.0, license headers, import-linter (6 kept / 0 broken), duckdb-floor gate, anddetect-secrets(CI-pinned 1.5.0, baseline untouched) all clean.ripwire --quality-deltastill exits 2 on 29 pre-existing rows from elsewhere in the tree. Of the rows on these files, the clone and verbosity ones are fixed (the test probes are now imported from the sibling suite rather than copied). What remains is threeduplicationrows pairing a three-linemkdir/symlink/returnhelper with unrelated tmp-dir fixtures at 25–30 tokens, and threeshort-horizon-churnrows caused by iterating on these very functions. Merging that helper with dotenv-project fixtures would be a wrong abstraction, so they are left and named here rather than gamed.Found in passing — not fixed here
Remote discovery is broken on duckdb 2.0.0.
fluid init --discover s3://…fails withCannot read a CA certificate bundle: file system access is restricted by configuration, because the sandbox forbids reading/etc/ssl/.... Works on 1.5.1 and 1.5.6. Reproduced on clean7cf5471abefore any edit here, so it is independent of this change and wants its own card.Documentation