fix(security): contract SQL runs in DuckDB's sandbox, confined to what the contract may declare - #689
Merged
Merged
Conversation
…ad the host
A contract's SQL (builds[].properties.sql, stage SQL, quality and masking
checks) ran on DuckDB connections with the full privileges of the process:
read_csv('/etc/passwd'), read_text('~/.aws/credentials'), glob('/'), an
http(s) URL, ATTACH of any database file, COPY ... TO anywhere. The engine
opened DuckDB in 28 places across 18 modules; two turned external access
off (fluid diff's catalog read, the SQL parser), the other 26 were unconfined.
Every connection now goes through one helper,
fluid_build/providers/_duckdb_sandbox.py::secure_duckdb_connect, which applies
DuckDB's own sandbox in the order "Securing DuckDB" gives: connect; persistent
secrets and community extensions off; the call site's extensions and set-up
(ATTACH of a declared source, object-store secret); autoinstall/autoload off;
home_directory pinned to $HOME (duckdb/duckdb#26064); allowed_directories /
allowed_paths; enable_external_access = false; lock_configuration = true.
Each call site states what it legitimately reads (DuckDBAllowlist is a
required argument): the local provider grants the contract's directory, its
FLUID workspace, ./runtime, the run's scratch dir and every declared input,
output and s3:// prefix; the acquisition runner grants the declared source and
landing files; validate/verify/diff grant the one file they check; discovery
and the MCP driver the one file or database they read.
The duckdb floor in the local extra moves from 1.0 to 1.5.0, and the helper
refuses older: allowed_directories arrived in 1.2, '<dir>/./../' escaped it up
to 1.4.3, and through 1.4.x a symlink in an allowed directory reached its
target (DBConfig::CanAccessFile is lexical there). Reproduced on 1.4.3 and
1.4.4; refused on 1.5.0 and 1.5.6.
A sandbox refusal is no longer retried by the local provider's retry loop
(its message, a path, could match the "500" HTTP pattern), and the error
names what the SQL may read instead.
tests/providers/test_duckdb_sandbox.py runs 22 attacks through the real
embedded-SQL build path, pins the helper's guarantees, and fails if any module
under fluid_build/ opens or queries DuckDB without the helper.
Refs: https://duckdb.org/docs/current/operations_manual/securing_duckdb/overview.html
Refs: duckdb/duckdb#26064
Refs: bordumb/dataing#176
… no longer fails the run
Review of the DuckDB sandbox found four gaps.
A declaration granted the host. Every declared input, output and source
was granted to the contract's SQL with no limit, and the contract's author
writes the declarations. A glob granted the whole directory above its first
wildcard, so declaring '<home>/*.csv' made read_text('~/.aws/credentials')
readable; declaring the file itself, a directory, or './*.py' (resolved
against the server's working directory) did the same, and because DuckDB
realpaths allowlist entries, an in-repo symlink to '/' passed the lexical
root check. DuckDBAllowlist.with_declared now grants a local location only
when its realpath is inside the call site's roots (the contract's directory
and workspace, ./runtime, the run's scratch directory, FLUID_UPSTREAM_CONTRACTS)
or a directory the operator lists in FLUID_DUCKDB_ALLOWED_DIRS, and refuses
anything else with a DuckDBSandboxError that names the variable. A glob is
granted as its pattern plus the files it matches, each confined, never its
directory. The local provider, the acquisition runner (source and landing)
and contract-tests actions use it; an entry that realpaths to '/' is refused
everywhere.
One failing SQL action failed every later action of the same apply. All
actions share one session database; DuckDB shares one instance per file per
process and the sandbox locks it, and a connection left open by a failure
(kept alive by an exception -> frame cycle) made the next connect fail with
"the configuration has been locked". Every local-provider and
contract-tests connection is now closed in a finally, the refusal is raised
without a frame-local cycle, and a second connection to an open file DB
raises a clear DuckDBSandboxError instead of DuckDB's message.
Functions DuckDB used to autoload (sqlite_scan, read_xlsx, ST_Read,
delta_scan, iceberg_scan) are not in the catalog under the sandbox. That
error ("but it exists in the ... extension") is now a sandbox refusal: not
retried, and the hint says the extension is unavailable rather than
advising a SET the lock refuses. Documented as a breaking change.
The sqlite and postgres scanners are not bounded by allowed_directories.
The docs now say so, tests pin it, and a test fails if contract SQL ever
runs on a connection with a database scanner loaded.
docs/duckdb-sandbox.md: confinement and FLUID_DUCKDB_ALLOWED_DIRS (examples
first), what a file / glob / directory declaration grants (the old "its
neighbours do not" was false for globs and directories), the autoload loss,
the real reach of loaded scanners, and one connection per database file.
…es the host, and a glob reads what DuckDB expands Review of the confinement follow-up found four gaps. An in-repo `runtime` symlink still granted the host. The local provider and ducksql.apply_sql grant ./runtime by convention, and ./runtime is in the working directory, usually the contract's own. A repository shipping `runtime -> ../../..` had $HOME granted, because DuckDB realpaths each allowlist entry; only a link resolving to exactly '/' was refused. The new unaliased_dir() grants a conventional directory only when its realpath is where its name says it is, or inside the contract's directory or workspace. Otherwise it is left out of the allowlist and the confinement roots, with a local_runtime_not_granted warning. A sqlite source was never confined. The acquisition runner ATTACHes connection.uri|path|database before the lock, and the sqlite scanner opens files through its own library, which allowed_directories never bounds, so any SQLite file on the host could be landed. The path now goes through confine_declared() (the contract's directory, its workspace and FLUID_DUCKDB_ALLOWED_DIRS) and is refused with a DuckDBSandboxError that names the variable. The realpath that was checked is what gets attached. A glob granted only the files Python's glob found at grant time. DuckDB's own expansion includes dotfiles (macOS ._x.csv on exFAT/SMB) and files that land later, and it refuses the whole read if any one is not granted. An operator-allowed landing directory with an AppleDouble file failed every run. A glob now grants the directory above its first wildcard. With roots, that directory must itself resolve inside them, so the grant is no wider than what the contract could already declare. DuckDB checks each expanded file's realpath, so a matched symlink that leads out is still refused at read time. Granting the matches was also quadratic, and it was repeated on every action's connection: about 15 s for 20,000 files. The glob change removes the expansion. _new now de-duplicates with a set and skips normalising a candidate that is already granted. docs/duckdb-sandbox.md: the glob row, the runtime symlink, sqlite confinement.
…ses the contract under it A contract in a directory such as 'Proj [old]/' had every declared input and output refused. The callers join the contract's directory, the working directory or $HOME in front of a declared path before the allowlist sees it. The allowlist then looked for wildcards in the whole absolute path, so it read the '[' in the directory's name as a pattern. It cut the path to the directory above that component and refused it as outside the contract directory. The refusal named that parent as the resolved path of 'customers.csv'. On origin/main the same contract builds. Measured: examples/02 copied under '<tmp>/Proj [old]/p' and applied as its README says gave rc=1 at f6b21c0. It now gives rc=0 and writes runtime/out/customer-clean-v1.csv. A directory component that exists under its literal name is now a directory, not a pattern. Wildcards are looked for only after it. The callers pass paths they have already made absolute, so the part the contract wrote cannot be told apart at this layer, and the literal existence of the directory is the test. The last component stays a pattern even when a file has that literal name. DuckDB reads what the pattern matches ('a1.csv' for 'a[1].csv'), and the directory granted holds both. Read literally, a path grants less than the cut-off directory did, never more. Confinement still realpaths the result. DuckDB tries such a name as a pattern first. Where it also matches a sibling ('p x/' for 'p [x]/'), DuckDB reads the sibling, which is not granted, so the read is refused rather than widened. The tests pin that. Tests build a contract under 'Proj [old]', 'p*' and 'p[1]' through the embedded-SQL path, the local provider's apply and the real CLI (examples/02 from 'repo [old]/'). They also check the grants, including '?' and a '[' in $HOME. All 15 new cases fail with the old prefix computation restored.
📄 Documentation ReminderThis PR appears to be missing a documentation reference. Our docs live in a separate repo. Please update the PR description with one of:
See the Contributing Guide for details. |
…N example names its password detect-secrets in Lint & Format flagged the sandbox tests' leak sentinel and an assertion on the allow_persistent_secrets setting (test values, now marked with the repo's inline "pragma: allowlist secret"), and the example DSNs in forge_db_tools' module docstring, scanned because this branch touches the file. The examples now read user:$PASSWORD instead of a literal password; a pragma would have rendered in the docstring.
…p helper raises its skip GitHub code quality flagged an empty 'except: pass' around closing an action's DuckDB connection, and a test helper mixing an explicit return with pytest.skip's implicit fall-through. The close failure is now logged at debug (the action's own outcome is what the caller needs), and the helper raises pytest.skip.Exception from the DuckDB error.
The rationale named one downstream service as the system these reads were found in. The engine's docs and docstrings now describe the case generically: services, CI jobs and shared hosts that run contracts other people wrote.
1 task done
fas89
added a commit
that referenced
this pull request
Oct 2, 2026
1 task done
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.
What and why
A contract's SQL (
builds[].properties.sql, stage SQL, quality and masking checks) ran on DuckDB with the full privileges of the process.read_csv('/etc/passwd'),read_text('~/.aws/credentials'),glob('/'), http(s) URLs,ATTACHof any database file, andCOPY ... TOanywhere all worked. Any downstream service that runs a user's contract handed that user its host.This PR routes every DuckDB connection in the engine through
fluid_build/providers/_duckdb_sandbox.py::secure_duckdb_connect. That helper applies DuckDB's own sandbox in the order "Securing DuckDB" gives:home_directoryto$HOME(NewVuln24: home_directory Bypasses allowed_directories Restriction via struct-VARIANT duckdb/duckdb#26064).allowed_directories/allowed_paths.enable_external_access=false.lock_configuration=true.A test fails if any module opens or queries DuckDB without the helper.
A declaration is not a grant of the host (review fix)
Declared inputs, outputs and sources are granted to the SQL, and the contract's author writes them. They are now confined. A declared local path is granted only if its realpath is inside one of these:
./runtimeor the run's scratch directoryFLUID_UPSTREAM_CONTRACTSrootsFLUID_DUCKDB_ALLOWED_DIRS(absolute paths only;/is refused; no contract field can set it)Everything else fails before any SQL runs:
What changed in how declarations are granted:
/is refused.<root>/*/../../xis normalised before the check../*.pyresolves where DuckDB opens it and is confined, so it cannot grant a server's working directory.This applies to the local provider, the DuckDB acquisition runner (source and landing) and
fluid contract-testsactions.One failed action no longer fails the rest of the run (review fix)
All actions of one apply share a session database. DuckDB shares one instance per file per process, and the lock is instance-wide. A connection left open by a failure (kept alive by an exception-to-frame cycle) made every later action fail with
configuration has been locked.Every local-provider and contract-tests connection is now closed in a
finally, and the refusal is raised without that cycle. A second connection to an already-open file DB now raises a clearDuckDBSandboxError("already open in this process").Extensions (review fixes)
SETthe lock refuses.sqlite/postgresscanners open files and sockets through their own libraries, so the allowlist does not bound them. This is now documented and pinned by tests, and a guard test fails if contract SQL ever runs on a connection that has a database scanner loaded.Breaking changes (release notes)
duckdb>=1.5.0is required by thelocalextra. Older versions are refused:<dir>/./../escaped the allowlist up to 1.4.3, and symlinks escaped it through 1.4.x../runtime, the run's scratch directory and the locations it declares.FLUID_DUCKDB_ALLOWED_DIRS=/dir1:/dir2. This includes absolute landing paths and acquisition sources such as/data/landing/*.csv.sqlite_scan,read_xlsx,ST_Read,delta_scan,iceberg_scan. Read CSV, Parquet or JSON instead, or land the data with an acquisition build.~/.duckdb/stored_secrets) are no longer loaded..duckdbfile, or two concurrentpersist=Truelocal runs, now get a clear error.Docs
docs/duckdb-sandbox.mdcovers what SQL can reach, declared-location confinement with examples, the operator opt-in, what each kind of declaration grants, how it works, and the limits (remote prefixes bound the bucket, loaded scanners are unbounded, one connection per file, defence in depth rather than isolation).Tests
tests/providers/test_duckdb_sandbox.pyhas 85 tests:$HOME, an outside directory,..after a glob, relative..,./*.csvfrom a foreign working directory, a symlink to/, an outside output, and a glob match that is a symlink.Every fix has a negative control: the fix is reverted in place, the pinning tests go red, and the file is restored byte-identical. Against the previous commit's code, 19 of the 20 new tests that exercise it fail. The 20th was then strengthened to hold the exception like a real caller, and it fails too.
Gates:
ruff check fluid_build/ tests/is clean.black==24.10.0 --check fluid_build/ tests/is clean.Tested live
Engine-level reproductions through
_execute_embedded_sql_buildandLocalProvider.apply:HOME=<fake>and a declared<fake>/*.csv,read_text('~/.aws/credentials')now exits 1 with the confinement error above. On the previous commit it exited 0 and wrote the credentials to the output.sqlite_scan('data/app.sqlite','t')exits 1 with the extension hint.Refs:
Decisions for the reviewer
./*.pyattack, and a test pins it. Making provider inputs anchor-relative would be a separate semantic change.--allow-dirCLI flag would need changes in shared CLI files and was left out to keep the file set narrow. Owner call: is the env var enough, or do we add the flag in a follow-up?localextra). That should not conflict with the load-API or $ref PRs unless one of them also edits thelocalextra.Changes after review
These changes came from adversarial review rounds; each finding was upheld by at least 2 of 3 independent skeptics before it was fixed. A final re-review of the branch found nothing further.
R1 — f6b21c0
Follow-up: four review findings on the confinement commit
./runtimeno longer grants where it points. The local provider andducksql.apply_sqlgrant./runtimeby convention, and it lives in the working directory, which is usually the contract's own. A repository that shippedruntime -> ../../..had$HOMEgranted, because DuckDB realpaths each allowlist entry. The newunaliased_dir()grantsruntimeonly when its realpath is where its name says it is, or inside the contract's directory or workspace. Otherwiseruntimeis left out of the allowlist and of the confinement roots, and the provider logs alocal_runtime_not_grantedwarning.allowed_directoriesnever bounded theATTACHofconnection.uri|path|database. Any SQLite file on the host could be landed. The path now goes throughconfine_declared(): it must be inside the contract's directory, its workspace orFLUID_DUCKDB_ALLOWED_DIRS, or it is refused with aDuckDBSandboxErrorthat names that variable. The path that was checked is the path that gets attached.._x.csvon exFAT/SMB) and files that land after the grant, and DuckDB refuses the whole read if any one is not granted. That broke operator-allowed landing directories. A glob now grants the directory above its first wildcard. With confinement roots, that directory must itself resolve inside them, so the grant is no wider than what the contract could already declare. DuckDB still refuses a matched symlink that leads out, at read time._newde-duplicates with a set. Building_allowlistover a 20,000-file glob went from 14.6 s to 0.02 s. Before the fix that cost was paid on every action's connection.Each fix has a negative-controlled test in
tests/providers/test_duckdb_sandbox.py: reverting just that source change makes the test fail.docs/duckdb-sandbox.mdis updated: the glob row, theruntimesymlink and sqlite confinement.R2 — f974f17
Wildcard characters in a directory name (review follow-up). A contract in a directory whose name contains
[,?or*(for exampleProj [old]/) had every declared input and output refused. The callers join the contract's directory, the working directory or$HOMEin front of a declared path, and the allowlist read the[in that name as a wildcard. It cut the path to the directory above it and refused that directory as outside the contract. The error named the parent directory as the resolved path. Onmainthe same contract builds.Now a directory component that exists under its literal name is treated as a directory, and wildcards are looked for only after it. The last component is still treated as a pattern, because DuckDB reads what it matches. Read literally, a path grants less than the cut-off directory did, never more, and confinement still resolves it with realpath. If such a name also matches a sibling as a pattern (
p x/forp [x]/), DuckDB reads the sibling. The sibling is not granted, so the read is refused rather than widened, and a test pins this.New tests:
examples/02applied as its README says, fromrepo [old]/);?and a[in$HOME.All 15 new cases fail with the old prefix computation restored.
docs/duckdb-sandbox.mdnotes the rule.Documentation
Companion docs PR: Agenticstiger/forge_docs#139 (new pages for
$refconfinement, the DuckDB sandbox and the contract-loading API, plus 0.18.0 release notes). It merges after 0.18.0 is on PyPI.