Skip to content

security: a declared remote prefix handed a local directory to contract SQL - #757

Merged
fas89 merged 2 commits into
mainfrom
fix/duckdb-remote-prefix-escape
Oct 27, 2026
Merged

fas89 merged 2 commits into
mainfrom
fix/duckdb-remote-prefix-escape

Conversation

@fas89

@fas89 fas89 commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

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_directories entry 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. With s3: -> / present:

allow = DuckDBAllowlist.none().with_remote("s3://etc/")   # allow.dirs is EMPTY
con = secure_duckdb_connect(":memory:", allow=allow)
# allowed_directories == ['/private/etc/', <spill>]
con.execute("SELECT count(*) FROM read_text('/etc/hosts')")   # returns a row

Reproduced on duckdb 1.5.1, 1.5.6 and 2.0.0. Reachable from real call sites: providers/local/local.py hands a raw s3:// URI to with_declared, and cli/discover/filesystem.py hands one to with_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 as realpath(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.

  1. "Remote prefixes are inert under enable_external_access = false, so stop putting them in allowed_directories." False. With httpfs loaded before the lock, remote reads work after it, and the prefix is what bounds them. Against the public s3://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.
  2. "The honest form is a path that does not exist, so test existence." Wrong test. DuckDB grants a resolved path that is not there 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: 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 against realpath, and returns the directory DuckDB would grant.
  • _refuse_escaping_remote_prefixes(allow) runs before SET allowed_directories, so the connection never holds the grant.
  • _refuse_ungranted_dirs keeps 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 Exception and came out framed as ConnectivityProbeError, 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 own what/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 from what into why: 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.

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_reframed masked four of the six fields as_json emits and copied extras and doc through verbatim. On 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 propagating a discoverer-supplied extras dict and then left the one dict-valued field out of the masking applied in the same expression. Latent — no in-tree error sets extras on this path, and the uri key 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, and as_json is exactly what the sibling tests assert a credential never reaches. _masked_extras now walks nested dicts and lists; doc is masked; the rebuild's except is 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 named s3: does yield a grant inside the repository the contract author already controls, which is why the symlink case is the one that escapes.

Validation

  • 9 tests in the new file pass on duckdb 1.5.1, 1.5.6 and 2.0.0.
  • Full suite, CI-shaped, both floors, run sequentially so nothing contends: 27934 passed / 4 failed (2.0.0) and 27932 passed / 4 failed (1.5.6) — the same four that fail on clean base, verified in a detached worktree at 7cf5471a. Perf suite alone: 38 passed / 7 xfailed on both. Startup budget 6/6.
  • Two extra failures appeared only in a contended run and are wall-clock, with the reasons printed by the runner: hypothesis's Input generation is slow health check, and subprocess.TimeoutExpired ... after 120 seconds on a fluid validate child, with 32 workers across two venvs. With a single venv this branch shows exactly base's four.
  • Live, through the CLI: 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: -> /, naming the prefix and where it resolved. With https://alice:<secret>@example.com/data.csv the output shows ***REDACTED*** and no copy of the secret.
  • ruff, black==24.10.0, license headers, import-linter (6 kept / 0 broken), duckdb-floor gate, and detect-secrets (CI-pinned 1.5.0, baseline untouched) all clean.
  • ripwire --quality-delta still 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 three duplication rows pairing a three-line mkdir/symlink/return helper with unrelated tmp-dir fixtures at 25–30 tokens, and three short-horizon-churn rows 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 with Cannot 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 clean 7cf5471a before any edit here, so it is independent of this change and wants its own card.

Documentation

  • No docs needed — this change does not affect documented behaviour. It refuses a configuration that was never intended to work; the refusal explains itself and names what to change.

fas89 added 2 commits October 27, 2026 18:12
…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.
@github-actions github-actions Bot added provider Changes or requests related to providers cli Changes to the CLI surface or implementation tests Test coverage or test infrastructure changes labels Oct 27, 2026
@fas89
fas89 merged commit 060dd1b into main Oct 27, 2026
33 checks passed
@fas89
fas89 deleted the fix/duckdb-remote-prefix-escape branch October 27, 2026 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Changes to the CLI surface or implementation provider Changes or requests related to providers tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant