Skip to content

fix(bright_data): confine get_screenshot output to a configurable directory - #522

Open
yonib05 wants to merge 4 commits into
strands-agents:mainfrom
yonib05:fix/bright-data-output-path
Open

fix(bright_data): confine get_screenshot output to a configurable directory#522
yonib05 wants to merge 4 commits into
strands-agents:mainfrom
yonib05:fix/bright-data-output-path

Conversation

@yonib05

@yonib05 yonib05 commented Jun 27, 2026

Copy link
Copy Markdown
Member

Summary

get_screenshot previously wrote the downloaded screenshot bytes to the raw output_path without any validation, so the file could land anywhere on the filesystem.

This change resolves output_path and confines it to a single root directory:

  • The root is the current working directory by default, or the directory named by the STRANDS_BRIGHT_DATA_OUTPUT_DIR environment variable when set.
  • The resolved path must be the root itself or a location inside it; otherwise a ValueError is raised.
  • A final path component that is a symlink is rejected so the write is not redirected elsewhere.
  • Relative paths are resolved against the root; the returned value is the resolved absolute path.

Behavior change

get_screenshot now writes within the working directory by default. To write somewhere else, set STRANDS_BRIGHT_DATA_OUTPUT_DIR to the desired directory (it may point anywhere the operator chooses, including a broad location); relative output_path values are taken from that directory and absolute paths inside it are allowed. The docstring, tool parameter docs, and the rejection error message now state this so the override is discoverable.

Testing

  • Added regression tests covering: a relative path written inside the configured directory, a relative traversal (../../etc/x) rejected, an absolute path outside the root rejected, a symlink target rejected, default-root (no env var) traversal/absolute-outside rejection, an actionable rejection message, and an operator-chosen output directory accepting an absolute path inside it.
  • pytest tests/test_bright_data.py passes (21 tests).
  • ruff format --check and ruff check pass on the changed files.

…ectory

Resolve the get_screenshot output_path and require it to be the current
working directory (or the directory named by STRANDS_BRIGHT_DATA_OUTPUT_DIR)
or a location inside it. Reject a final path component that is a symlink.

Add regression tests covering relative traversal, absolute paths outside
the root, and symlink targets.
@yonib05
yonib05 marked this pull request as draft June 29, 2026 21:46
@opieter-aws

Copy link
Copy Markdown
Contributor

@strandly-the-agent can you review this PR please?

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Changes requested: the pathname check and later write are not atomic, and the model-facing schema still omits the new confinement contract.

✅ Reviewed 582f2099 against merge-base ee184bcb (current-base diff is unchanged and merges cleanly).
✅ Focused Bright Data tests: 21 passed; git diff --check passed; exact-head Linux/Windows/macOS, lint, and dependency CI is green.
⚠️ Local full-suite collection lacked optional dev dependencies; exact-head CI covers the repository matrix.
API sign-off needed: this intentionally changes the directly importable BrightDataClient.get_screenshot() path/return behavior. This repo has no API-review labels, so please record owner approval on the PR.

Review passes
  • Correctness/safety: one reproduced confinement escape and one request-order issue.
  • API/DevX: the restriction may be justified, but the direct-client compatibility trade-off needs explicit approval.
  • Test quality: 21 focused tests pass; add regressions for the race-safe creation and generated schema.
  • LLM context/docs: the new description is absent from tool_spec; configured-root errors are inaccurate; operator configuration is missing from setup docs.
  • Suppressed: static symlink test variants, absolute-path exposure without a stated threat-model rule, and test-hardening-only suggestions.

Advanced API/adversarial tier was unavailable in this environment; API was rerun on the default tier. I independently reproduced the safety issue twice.

Comment thread src/strands_tools/bright_data.py Outdated
Comment thread src/strands_tools/bright_data.py Outdated
Comment thread src/strands_tools/bright_data.py Outdated
Comment thread src/strands_tools/bright_data.py Outdated
Comment thread src/strands_tools/bright_data.py
…re request

- Bind screenshot writes to a descriptor of the trusted root (openat-style
  traversal with O_NOFOLLOW per component) so a directory swapped for a
  symlink after validation cannot redirect the write; add a deterministic
  regression that swaps a validated directory mid-request
- Validate output_path lexically before the remote request so an invalid
  destination no longer performs billable work
- Fix Args indentation in the tool docstring so the generated tool_spec
  carries the output_path confinement description; add a tool_spec regression
- Correct the rejection message when STRANDS_BRIGHT_DATA_OUTPUT_DIR is set
  to name only the configured directory
- Document STRANDS_BRIGHT_DATA_OUTPUT_DIR in the module setup block and the
  README environment table
@yonib05
yonib05 marked this pull request as ready for review July 24, 2026 15:02
@yonib05
yonib05 requested a review from a team as a code owner July 24, 2026 15:02
@yonib05
yonib05 requested review from arielnabavian and opieter-aws and removed request for arielnabavian July 24, 2026 15:02
@yonib05

yonib05 commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

@strandly-the-agent can you re-review this PR please?

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Changes requested: the prior five findings are fixed, but the new secure-open path still has two reproduced confinement escapes.

✅ Re-reviewed exact head d5286466; focused tests: 25 passed; git diff --check passed.
✅ Latest ordinary exact-head CI is green across Linux/Windows/macOS, dependencies, lint, and CI Gate.
⚠️ Overall rollup failure is the trusted pull_request_target checkout guard—not a Bright Data test failure; a superseded run was concurrency-cancelled.
🔴 Root-swap and fallback check/open escapes reproduced twice each.
API sign-off still needed: can a Bright Data/codeowner approve the intentional BrightDataClient.get_screenshot() behavior change? No approval is visible yet.

Delta review details
  • The previous schema, configured-root error, setup-docs, lexical pre-request rejection, and original child-path findings are resolved; I did not repeat them.
  • Correctness/adversarial: retained two new confinement bypasses and the remaining billable-request behavior.
  • Test quality: retained the race regression because it passes against known-vulnerable 582f2099; suppressed lower-value nested-success/fd-cleanup gaps.
  • LLM context/docs: generated output_path schema is now correct; implementation claims remain too broad only where folded into the security findings.
  • Advanced API/adversarial tiers were unavailable due retention validation; both were rerun on the default tier.

No safe small suggestion exists for the handle-lifetime/platform security changes; they need an architectural fix rather than another pathname check.

symlink_errnos = {errno.ELOOP, errno.ENOTDIR, getattr(errno, "EMLINK", errno.ELOOP)}

if os.open in os.supports_dir_fd:
dir_fd = os.open(str(root), os.O_RDONLY | os.O_DIRECTORY)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The configured root can be replaced before this pathname is reopened. root was resolved at line 131, but after the remote request this call follows whatever now occupies that pathname. I made requests.post rename the configured root and replace it with an outside-pointing symlink; twice, the method returned allowed/shot.png while the original root stayed empty and outside/shot.png contained b"ATTACK".

Please securely acquire and pin the intended root handle before the request, retain it through the response, and create the file relative to that handle while rejecting root-level symlink/reparse substitution. There is no safe small suggestion here—the handle lifetime and API between validation/request/write need to change together.

probe = probe / part
if probe.is_symlink():
raise _confinement_error(root, f"output_path must not contain a symlink: {final_path}.")
fd = os.open(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The non-dir_fd fallback still has the original check/open race. Lines 191–195 inspect components, then this separately opens the full pathname. In a forced-fallback repro I replaced images with an outside-pointing symlink immediately after its successful is_symlink() check; twice, the returned path stayed under allowed/images, but b"ATTACK" was written outside.

O_NOFOLLOW may be unavailable here and, when present, only protects the final component. Because supported Windows uses this branch, please use handle-based/no-reparse traversal there or fail closed and explicitly scope the platform limitation. No small suggestion is safe for this platform-specific handle change.

Raises:
ValueError: If output_path resolves outside the allowed output directory.
"""
# Validate the destination before making the (billable) remote request.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Only lexical containment is validated before the billable request. With an existing final symlink, the current code made one mocked request and only then raised ValueError in _open_confined_output_file(); I repeated that twice. So this comment and the method docstring’s “destination is validated” claim overstate the behavior.

Prefer securely acquiring/validating the root and destination parent before the request—this also supports the root-handle fix above. Otherwise narrow the wording to say that only lexical containment is checked before requesting.

Comment thread tests/test_bright_data.py
outside = tmp_path / "outside"
outside.mkdir()

def swap_then_respond(*args, **kwargs):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This regression passes against the known-vulnerable implementation. I copied this current test to old head 582f2099; it passed (1 passed) because that version validates only after requests.post, sees the already-installed symlink, and never reaches its check/open race.

Synchronize the swap after validation and immediately before the final open/write in the replacement design. The security regression should fail on 582f2099 and pass only when validation and creation are bound safely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants