fix(security): enforce local-file confinement on the S3 storage branch (#14638)
* fix(security): enforce local-file confinement on the S3 storage branch
`LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS` is documented as a property of the
built-in file-reading components, with no caveat about the storage backend.
It only held for `LANGFLOW_STORAGE_TYPE=local`.
`storage_utils.read_file_bytes` accepts a `resolve_path` callback whose sole
purpose is to run `enforce_local_file_access` before a file is read. On the
`storage_type == "s3"` branch the function first checks `_is_existing_local_file`
and, for any absolute path pointing at a real file on disk, returned
`Path(file_path).read_bytes()` immediately -- never invoking the callback it was
given. The local-storage branch a few lines below called it correctly. The
containment control existed and was correctly wired, but was architecturally
absent from one of the two branches.
Because the containment logic and the reserved-secret denial
(`_RESERVED_SECRET_FILENAMES` / `_reserved_secret_paths`) both live inside
`enforce_local_file_access`, neither ran on that branch -- so server-managed
files under `config_dir` were reachable too.
The `_is_existing_local_file` short-circuit itself is a deliberate escape hatch
(#13798: the Assistant references installed component paths under S3 storage).
It is preserved; it just no longer bypasses the control that was added later.
Changes:
- `base/data/storage_utils.py`: new `_confine_local_read` applied to the S3
real-local-file short-circuit in `read_file_bytes` and `get_file_size`. When
the caller supplies `resolve_path`, that resolver is authoritative and full
per-tenant scoping applies. Callers with no scope to offer get a
storage-root floor instead. `get_file_size` matters because `file_exists`
wraps it, making it a size/existence oracle otherwise.
- `utils/file_path_security.py`: `enforce_local_file_access` gains
`allow_storage_root`, which widens the boundary to `config_dir` and drops the
mandatory scope. Documented as a defense-in-depth floor, not tenant
isolation; reserved-secret denial still applies.
- `components/langchain_utilities/{csv_agent,json_agent}.py`: both reached the
shared reader with no resolver on the S3 branch. They now pass a scoped
`_confine_local_path`, so real per-tenant scoping applies rather than just
the floor.
Both controls are no-ops when the restriction is disabled, so the default
(unrestricted) configuration and the #13798 behavior are unchanged.
`base/data/base_file.py::_validate_and_resolve_paths` was checked and already
calls `enforce_local_file_access` on its S3 absolute-path branch (#14428); it
is unchanged here.
Regression tests: `read_file_bytes` / `read_file_text` / `get_file_size` /
`file_exists` under `storage_type=s3` + restriction, covering an out-of-scope
path with and without a resolver, a symlink alias inside the storage scope
pointing outward, and a reserved secret file directly under `config_dir` (the
sharpest case -- plain containment would admit it). Each is paired with a
`storage_type=local` negative control and an in-scope positive case, plus an
assertion that the unrestricted #13798 read still works. Equivalent
denied/allowed pairs added for both agent components.
Fixes LE-2251
* [autofix.ci] apply automated fixes
* docs(security): correct why the reserved-secret denial is load-bearing
The comment said scope containment already rejects these files and the exact-path
check was defense in depth. That is only true when a scope narrows the root below
config_dir. Under allow_storage_root, config_dir itself is an allowed root, so
this check is the control that keeps secret_key, private_key.pem and the SQLite
DB out of reach. Point at the test that covers it so a future cleanup does not
remove it as redundant.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> E
Eric Hare committed
6f3e17b8c9f779cfc9ef90ed7da4daeaf18ec7d5
Parent: 186f892
Committed by GitHub <noreply@github.com>
on 8/19/2026, 5:50:48 PM