## Summary

A distinct fixed-version bypass was confirmed for CVE-2026-49297 in `GCSToSFTPOperator`. The parent reproduction used attacker-controlled GCS object names containing `..` segments. The variant uses an attacker-controlled GCS object name with **no `..` segment** (`subdir/link/symlink_escape_1.txt`) and a destination tree containing a symlink (`destination_path/subdir/link`) that points outside `destination_path`. Apache Airflow Google provider `22.2.1` accepts the path because `_resolve_destination_path()` performs only lexical `normpath` prefix containment. The subsequent SFTP write follows the symlink and creates the file outside the configured SFTP destination directory.

## Fix Coverage / Assumptions

- The original fix in `apache-airflow-providers-google==22.2.1` changes `GCSToSFTPOperator._resolve_destination_path()` in `airflow/providers/google/cloud/transfers/gcs_to_sftp.py`.
- The fixed code joins `destination_path` and the GCS object name, applies `os.path.normpath()`, and rejects paths where the normalized string is outside the normalized base directory.
- The invariant assumed by the fix is: if the normalized destination path string begins with the normalized `destination_path` prefix, the SFTP write is contained under `destination_path`.
- That invariant covers ordinary lexical traversal using `..` segments and absolute-path absorption. It does not cover filesystem- or SFTP-server-level path resolution after the lexical check, especially symlinks already present inside `destination_path`.
- The related `GCSTimeSpanFileTransformOperator` path in `airflow/providers/google/cloud/operators/gcs.py` already uses `Path.resolve().is_relative_to(...)` for worker-local temp downloads, so the tested bypass is specific to the SFTP destination path check in `GCSToSFTPOperator`.
- Airflow's security model treats DAG authors as trusted and says arbitrary DAG-author code is not itself a vulnerability. This variant preserves the same trust boundary as the parent claim: a less-trusted principal controls GCS object names returned by the bucket listing API, while the DAG and SFTP destination are deployment-controlled. The additional precondition is that the configured SFTP destination tree contains a symlink to another server-side path.

## Variant / Alternate Trigger

- **Variant type:** fixed-version bypass via alternate object path semantics.
- **Entrypoint:** Airflow task/operator execution of `GCSToSFTPOperator`.
- **Attacker-controlled input:** GCS object name returned by bucket listing: `subdir/link/symlink_escape_1.txt`.
- **Important distinction from parent:** the object name contains no `..` path segment (`object_contains_dotdot: false` in the evidence JSON).
- **Server-side setup/precondition:** the SFTP destination directory contains `subdir/link` as a symlink to `outside_target`, which is outside the configured `destination_path`.
- **Code path:**
  1. `GCSToSFTPOperator.execute()` lists GCS objects for wildcard `source_object='subdir/*'`.
  2. It calls `_resolve_destination_path(source_object, prefix=...)`.
  3. In fixed `22.2.1`, `_resolve_destination_path()` performs lexical `os.path.normpath(os.path.join(...))` containment and returns the path because it is lexically inside `destination_path`.
  4. `_copy_single_object()` downloads the GCS object to a temp file and calls `SFTPHook.store_file(destination_path, tmp.name)`.
  5. The SFTP server resolves `destination_path/subdir/link/symlink_escape_1.txt` through the symlink and writes `outside_target/symlink_escape_1.txt` outside the intended destination.

## Impact

- **Package/component affected:** `apache-airflow-providers-google`, specifically `airflow.providers.google.cloud.transfers.gcs_to_sftp.GCSToSFTPOperator`.
- **Affected versions as tested:**
  - Vulnerable control: `apache-airflow-providers-google==22.1.0`.
  - Fixed-version bypass target: `apache-airflow-providers-google==22.2.1` at Apache Airflow tag `providers-google/22.2.1`, commit `c7bcb8d40f5fa42a98161fadb2166a0fa7fa5150`.
- **Risk level and consequences:** Medium, with a deployment-dependent precondition. If an Airflow deployment ingests GCS buckets writable by less-trusted principals and the configured SFTP destination contains symlinks to other writable locations, the bucket writer can choose object names that write outside the configured destination path despite the `22.2.1` lexical traversal fix.

## Impact Parity

- **Disclosed/claimed maximum impact for parent:** arbitrary file overwrite/path traversal on the SFTP server or worker host through attacker-controlled GCS object names.
- **Reproduced impact from this variant run:** creation of an attacker-controlled file outside the configured SFTP `destination_path` on the fixed provider version `22.2.1`.
- **Parity:** `partial` to `full` depending on deployment. The file-write primitive outside `destination_path` is fully reproduced; exploitability additionally requires a symlink inside the destination tree.
- **Not demonstrated:** code execution, privilege escalation, or creation of the symlink by the GCS bucket writer. The proof assumes the symlink already exists in the SFTP destination tree.

## Root Cause

The same underlying sink remains reachable because the fixed `GCSToSFTPOperator` verifies only the lexical path string before handing it to the SFTP server. The parent bug was caused by joining attacker-controlled GCS object names to a destination filesystem path without proving containment at the eventual write sink. The `22.2.1` fix rejects obvious lexical escapes such as `subdir/../../escaped.txt`, but it still treats a path as safe solely because the normalized string is under `destination_path`. Real filesystem/SFTP resolution can map that lexically in-bounds path outside the base when an intermediate component is a symlink.

The relevant fixed code is in `airflow/providers/google/cloud/transfers/gcs_to_sftp.py`:

- `execute()` obtains object names from `gcs_hook.list(...)` and calls `_resolve_destination_path()`.
- `_resolve_destination_path()` computes `resolved = os.path.normpath(os.path.join(self.destination_path, source_object))` and checks string-prefix containment.
- `_copy_single_object()` later calls `sftp_hook.store_file(destination_path, tmp.name)` without verifying the SFTP server's canonical target.

The exact fix commit was not identified from a single patch URL in the ticket, but the tested fixed release tag is `providers-google/22.2.1` resolved to commit `c7bcb8d40f5fa42a98161fadb2166a0fa7fa5150`.

## Reproduction Steps

1. Run `bundle/vuln_variant/reproduction_steps.sh`.
2. The script ensures/reuses Python environments for provider versions `22.1.0` and `22.2.1`, starts a real `fake-gcs-server` JSON API endpoint, starts a real Paramiko SFTP server, creates a destination tree with a symlink inside `destination_path`, and executes the real `GCSToSFTPOperator` directly.
3. Expected evidence:
   - The vulnerable version writes outside `destination_path` through the symlink.
   - The fixed version `22.2.1` also writes outside `destination_path` through the symlink.
   - The fixed attempt JSON records `object_contains_dotdot: false`, `escaped_file_exists: true`, `operator_succeeded: true`, and `file_inside_destination_by_realpath: false`.
4. Exit code semantics: exit `0` means the bypass reproduced on the fixed version; exit `1` means no fixed-version bypass was confirmed. The script was run twice and exited `0` both times.

## Evidence

Primary artifacts:

- Main variant proof log: `bundle/logs/vuln_variant_reproduction_steps.log`
- Vulnerable control JSON: `bundle/logs/vuln_variant_attempt_22.1.0_1.json`
- Fixed bypass JSON: `bundle/logs/vuln_variant_attempt_22.2.1_1.json`
- Fixed source identity: `bundle/logs/vuln_variant_fixed_version.txt`
- Vulnerable source identity: `bundle/logs/vuln_variant_vulnerable_version.txt`
- Structured verdict: `bundle/vuln_variant/validation_verdict.json`
- Source identity: `bundle/vuln_variant/source_identity.json`
- Runtime manifest: `bundle/vuln_variant/runtime_manifest.json`

Representative fixed-version evidence from `bundle/logs/vuln_variant_attempt_22.2.1_1.json`:

- `"attacker_controlled_gcs_object": "subdir/link/symlink_escape_1.txt"`
- `"object_contains_dotdot": false`
- `"operator_succeeded": true`
- `"escaped_file_exists": true`
- `"file_inside_destination_by_realpath": false`
- The SFTP `open` operation's lexical local path was under `.../sftp_root/inbox/subdir/link/symlink_escape_1.txt`, while its `realpath` was `.../sftp_root/outside_target/symlink_escape_1.txt`.

The main log from both verification runs includes:

- `vulnerable_exploited=True`
- `fixed_exploited=True`
- `RESULT: confirmed fixed-version bypass (exit 0)`

Environment/source details captured:

- `apache-airflow==3.2.2`
- `apache-airflow-providers-google==22.1.0` for the vulnerable control
- `apache-airflow-providers-google==22.2.1` for the fixed target
- `apache-airflow-providers-sftp==5.8.2`
- Fixed provider tag commit: `c7bcb8d40f5fa42a98161fadb2166a0fa7fa5150`

## Recommendations / Next Steps

- Treat SFTP destination containment as a sink-level property, not only a lexical string property.
- If the intended guarantee is strict containment under `destination_path`, resolve and reject symlinked intermediate path components before writing. For SFTP this may require walking each path component with `lstat`/`readlink` where supported, rejecting symlinks under the configured base, or documenting that `destination_path` must not contain symlinks.
- Consider adding an option such as `allow_destination_symlinks=False` defaulting to the safer behavior for GCS-to-SFTP transfers.
- Add regression tests where a lexically safe object name traverses an existing symlink inside the SFTP destination tree.
- Keep the current lexical `..`/absolute path guard, but extend it with symlink-aware checks or an explicit documented limitation and deployment hardening guidance.

## Additional Notes

- The variant reproduction script is idempotent: each run recreates per-version test roots under `bundle/vuln_variant/artifacts/` and overwrites stable JSON logs.
- The script was executed twice successfully as required; both runs completed without crashing and exited `0` because the fixed-version bypass reproduced.
- This finding has a narrower precondition than the parent `..` traversal: a symlink must exist inside the SFTP destination tree. It is nevertheless a meaningful bypass of the patch's containment assumption because the fixed version accepts and writes a path that resolves outside `destination_path`.
