Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,28 @@
from truss.templates.control.control.helpers.custom_types import Action, Patch


def _resolve_within(base_dir: Path, relative_path: str) -> Path:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the contribution! Can you give me some background on how you came across this? Was this an issue you hit or just something you saw browsing open issues?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for taking a look! Just something I came across browsing the open issues — I wasn't hitting it in production. The security label on #2532 caught my eye, and since the fix was nicely scoped to a single sink (apply_code_patch) with a clear containment invariant to enforce, it seemed like a good self-contained hardening PR to pick up. Happy to adjust scope or approach however you'd prefer.

@cretz cretz Jul 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am a bit concerned without having tested this against code patching against the backend (i.e. truss push --watch/truss watch) any side effects it may have giving a qualified path here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — the concern was valid: the check previously handed the fully-resolved path to the file operations, which changes what gets written/logged if the app dir sits behind a symlink. Fixed in 2db3c64:

  • _validate_within now uses resolved copies only for the containment check and returns the plain relative_dir / patch.path join, so the path handed to mkdir/write/unlink (and the log lines) is byte-for-byte identical to pre-PR behavior for every legitimate patch.
  • Added a regression test that applies an UPDATE patch through a symlinked app dir and asserts it lands in the original location (and that traversal is still rejected through the symlink).

To verify against the actual live-reload flow, I ran test_control_truss_apply_patch (the Docker integration test that builds a control truss from the local templates, POSTs a real PatchRequest to /control/patch — the same server-side path truss push --watch/truss watch drives — and asserts the model's predict output changes): 1 passed in 3:46. Also green locally: the patch-applier unit tests (19), the control server endpoint tests (35), and the client-side dir-patch applier tests (68).

@cretz cretz Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are you a Baseten user and were you able to verify this against the Baseten server side platform by deploying/watching a model?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes — I'm a Baseten user, and I just verified this against the platform. I deployed a dev model from this branch's checkout (truss push --watch) and exercised the live-reload flow end to end:

  • UPDATE: edited model.pyCreated patch to update model code file: model/model.pypatched successfully → next predict returned the new output (no rebuild, same deployment)
  • ADD (nested new dir): created model/helpers/nested_util.py → patched successfully, and the model imported it on reload
  • REMOVE (incl. empty-dir cleanup): deleted the file and dir → patched successfully → predict reflected the revert

All three round-tripped cleanly through POST /control/patch on the deployed dev container.

One caveat for full transparency: the dev image's control server is built by the Baseten backend from released truss, so the platform run pins down the exact behavior the released applier has today. As of 2db3c64 the containment check returns the identical unresolved relative_dir / patch.path join for every accepted patch — so for the entire flow verified above, the patched code is behavior-identical by construction. The only behavioral delta is rejecting paths that escape the target dir, and that path is covered by the unit tests plus the local Docker integration test (test_control_truss_apply_patch), which does bake this branch's control server into the container image.

"""Resolve ``relative_path`` against ``base_dir`` and ensure the result
stays inside ``base_dir``.

The patch path comes from the request body of the control server's
``/control/patch`` endpoint, so a value such as ``../../../etc/cron.d/x``
(or an absolute path, which ``/`` join would leave unchanged) must not be
allowed to write or delete files outside the target directory.
"""
base_resolved = base_dir.resolve()
filepath = (base_dir / relative_path).resolve()
if not filepath.is_relative_to(base_resolved):
raise ValueError(
f"Invalid patch path {relative_path!r}: resolves outside the "
f"target directory {base_resolved}."
)
return filepath


def apply_code_patch(relative_dir: Path, patch: Patch, logger: logging.Logger):
logger.debug(f"Applying code patch {patch.to_dict()}")
filepath: Path = relative_dir / patch.path
filepath: Path = _resolve_within(relative_dir, patch.path)
action = patch.action

if action in [Action.ADD, Action.UPDATE]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,76 @@ def test_patch_applier_env_var_patch_remove(patch_applier: ModelContainerPatchAp
_ = env_var_dict["FOO"]


@pytest.mark.parametrize(
"patch_type, body_cls",
[(PatchType.MODEL_CODE, ModelCodePatch), (PatchType.PACKAGE, PackagePatch)],
)
@pytest.mark.parametrize("action", [Action.ADD, Action.UPDATE])
def test_patch_applier_rejects_path_traversal_write(
patch_applier: ModelContainerPatchApplier,
truss_container_fs,
patch_type,
body_cls,
action,
):
# A patch path escaping the target directory (e.g. via `..`) must be
# rejected instead of writing outside it (issue #2532).
outside_target = truss_container_fs / "escaped.py"
patch = Patch(
type=patch_type,
body=body_cls(action=action, path="../../escaped.py", content="pwned"),
)
with pytest.raises(ValueError):
patch_applier(patch, os.environ.copy())
assert not outside_target.exists()


def test_patch_applier_rejects_absolute_path_write(
patch_applier: ModelContainerPatchApplier, truss_container_fs, tmp_path
):
# Joining an absolute path onto the target dir collapses to the absolute
# path, another way to escape; it must be rejected too.
outside_target = tmp_path / "abs_escaped.py"
patch = Patch(
type=PatchType.MODEL_CODE,
body=ModelCodePatch(
action=Action.ADD, path=str(outside_target), content="pwned"
),
)
with pytest.raises(ValueError):
patch_applier(patch, os.environ.copy())
assert not outside_target.exists()


def test_patch_applier_rejects_path_traversal_remove(
patch_applier: ModelContainerPatchApplier, truss_container_fs
):
# A REMOVE patch must not be able to delete files outside the target dir.
victim = truss_container_fs / "app" / "config.yaml"
assert victim.exists()
patch = Patch(
type=PatchType.MODEL_CODE,
body=ModelCodePatch(action=Action.REMOVE, path="../config.yaml"),
)
with pytest.raises(ValueError):
patch_applier(patch, os.environ.copy())
assert victim.exists()


def test_patch_applier_allows_nested_subdirectory_path(
patch_applier: ModelContainerPatchApplier, truss_container_fs
):
# Legitimate nested paths within the target dir must still work.
patch = Patch(
type=PatchType.MODEL_CODE,
body=ModelCodePatch(action=Action.ADD, path="nested/dir/new.py", content="ok"),
)
patch_applier(patch, os.environ.copy())
assert (
truss_container_fs / "app" / "model" / "nested" / "dir" / "new.py"
).read_text() == "ok"


def test_patch_applier_external_data_patch_add(
patch_applier: ModelContainerPatchApplier, truss_container_fs
):
Expand Down
Loading