# Patch Analysis for CVE-2026-49297 Variant

## Fix under review

The ticket states that CVE-2026-49297 is fixed in `apache-airflow-providers-google==22.2.1`. The tested fixed target was the PyPI release `apache-airflow-providers-google==22.2.1`, corresponding to Apache Airflow tag `providers-google/22.2.1` resolved to commit `c7bcb8d40f5fa42a98161fadb2166a0fa7fa5150`.

The parent issue concerns path traversal through GCS object names used by Airflow Google provider operators, especially `GCSToSFTPOperator` and `GCSTimeSpanFileTransformOperator`.

## Target threat model / scope

Airflow's published security model states that DAG authors are trusted users and that arbitrary behavior introduced by malicious DAG code is normally outside Airflow's vulnerability model. The relevant trust boundary for this ticket is therefore not “a DAG author can choose a bad path.” The relevant boundary is a less-trusted principal controlling object names in a source GCS bucket that a trusted/deployment-controlled DAG consumes.

This variant keeps that boundary: the attacker-controlled input is still a GCS object name returned by the GCS bucket listing API. The variant adds a deployment-dependent SFTP filesystem precondition: the configured `destination_path` contains an intermediate symlink to a location outside the configured destination root.

## What the fix changes

### `GCSToSFTPOperator`

File: `airflow/providers/google/cloud/transfers/gcs_to_sftp.py`

In vulnerable `22.1.0`, `_resolve_destination_path()` simply returned:

```python
return os.path.join(self.destination_path, source_object)
```

In fixed `22.2.1`, `_resolve_destination_path()` performs a lexical containment check:

```python
resolved = os.path.normpath(os.path.join(self.destination_path, source_object))
base = os.path.normpath(self.destination_path)
escapes = (
    resolved == ".."
    or resolved.startswith(".." + os.sep)
    or (os.path.isabs(resolved) and not os.path.isabs(base))
    or (base != "." and resolved != base and not resolved.startswith(base.rstrip(os.sep) + os.sep))
)
if escapes:
    raise ValueError(...)
return resolved
```

This blocks direct `..` traversal and absolute path absorption before `_copy_single_object()` calls `SFTPHook.store_file()`.

### `GCSTimeSpanFileTransformOperator`

File: `airflow/providers/google/cloud/operators/gcs.py`

The related worker-local temporary download path uses `Path.resolve().is_relative_to(...)` before downloading a GCS blob name to the temp input directory. That is stronger for local filesystem writes because it resolves symlinks on the local worker before the file write.

## Fix assumptions

The SFTP fix assumes that lexical string containment is equivalent to write-sink containment. In other words, it assumes that if:

```text
normpath(destination_path / object_name)
```

starts with `normpath(destination_path)`, then the eventual file write performed by the SFTP server remains under `destination_path`.

That assumption is valid for simple `..` path traversal strings, but it is not valid when the target filesystem path contains symlinks or other server-side path resolution behavior that is not visible in the lexical string.

## What code paths / inputs the fix covers

The `22.2.1` `GCSToSFTPOperator` fix covers:

- GCS object names containing `..` path segments that normalize above `destination_path`.
- Absolute object names that absorb a relative configured base.
- Wildcard and non-wildcard object paths, because both call `_resolve_destination_path()` before `_copy_single_object()`.
- The parent reproduction object name `subdir/../../escaped_1.txt`, which is rejected with `ValueError` before any SFTP write.

The `GCSTimeSpanFileTransformOperator` worker-local temp path appears separately covered for local symlink traversal because it resolves the local destination path and checks it against the resolved temporary directory.

## What code paths / inputs the fix does not cover

The `GCSToSFTPOperator` fix does not verify the canonical path on the SFTP server after symlink resolution. A GCS object name such as:

```text
subdir/link/symlink_escape_1.txt
```

is lexically inside:

```text
destination_path/subdir/link/symlink_escape_1.txt
```

and contains no `..` segment. The fixed `_resolve_destination_path()` accepts it. If `destination_path/subdir/link` is a symlink to a directory outside `destination_path`, `SFTPHook.store_file()` writes outside the configured base.

The SFTP hook is a remote protocol abstraction; local `os.path.realpath()` on the Airflow worker cannot canonicalize the remote server path. A complete containment guarantee requires either symlink-aware SFTP-side checks or a documented/validated restriction that the destination tree not contain symlinks.

## Behavior before and after the fix

### Parent `..` traversal

- `22.1.0`: writes outside the configured SFTP destination path.
- `22.2.1`: rejects before SFTP write with `ValueError`.

### Confirmed symlink variant

- `22.1.0`: writes outside the configured destination through `destination_path/subdir/link` symlink.
- `22.2.1`: also writes outside the configured destination through the same symlink, because the object name is lexically safe and the fixed check does not resolve remote symlinks.

Runtime evidence for the fixed target is in `bundle/logs/vuln_variant_attempt_22.2.1_1.json`:

- `object_contains_dotdot: false`
- `operator_succeeded: true`
- `escaped_file_exists: true`
- `file_inside_destination_by_realpath: false`
- SFTP `open.realpath` points to `outside_target/symlink_escape_1.txt` while the lexical path is under `sftp_root/inbox/subdir/link/symlink_escape_1.txt`.

## Completeness assessment

The `22.2.1` fix is complete for the parent lexical `..` path traversal but incomplete for a strict “never write outside destination_path” guarantee on SFTP servers where symlinks can exist inside the destination tree. The gap is a bypass of the patch assumption, not a separate unrelated parsing bug: the same attacker-controlled GCS object name flows through the same `GCSToSFTPOperator` path construction and SFTP write sink.

## Recommended fix expansion

- Preserve the current lexical check because it blocks direct traversal.
- Add symlink-aware validation for the SFTP sink where supported: walk each path component with SFTP `lstat`/`readlink` and reject symlink components under `destination_path` unless explicitly allowed.
- Consider a default-safe option such as `allow_destination_symlinks=False` for `GCSToSFTPOperator`.
- If remote symlink-safe containment cannot be implemented portably, document that `destination_path` must not contain symlinks and add a best-effort check that fails closed on detected symlinks.
- Add regression tests for lexically safe object names that traverse existing symlinks under the destination directory.
