Skip to content

feat(remotefile): reject an unsafe server-chosen listing name (#1238) - #376

Merged
wshallwshall merged 5 commits into
mainfrom
claude/builder-2-1238-listing-name-containment
Aug 13, 2026
Merged

feat(remotefile): reject an unsafe server-chosen listing name (#1238)#376
wshallwshall merged 5 commits into
mainfrom
claude/builder-2-1238-listing-name-containment

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Builds #1238 -- refuses an unsafe server-chosen listing name rather than sanitizing it.
2 files, +127 -0.

The fix is the opposite of the obvious one, deliberately

posixpath.basename is the natural reach here and is actively harmful:

  • It mutates rather than refuses. A traversal name becomes one that rejoins to a real file in the
    poll directory
    -- handing a hostile server a retrieve/move/delete primitive against the partner's
    own drop directory. The "sanitized" name is more dangerous than the rejected one.
  • It is a no-op on Windows separators.
  • It makes any later containment check unreachable. Its output can never contain a slash, so a
    check placed after it always passes -- a guard that looks like hardening while being structurally
    incapable of firing.

All three reasons are in the helper's docstring and the commit message, so a reviewer meets them
before reaching for the simpler fix. That placement is the point: the next person to "simplify" this
reads the code, not the PR.

Mutation-verified twice

mutation result
remove the check both new tests fail
replace the check with basename the traversal test fails

One mutation only proves the test sees the null case. The second proves it discriminates against the
plausible near-miss
-- which is the mutation someone would actually make.

Verification

ruff, ruff format, mypy --strict clean. remotefile suite: 86 passed.

Checked independently: base is a real ancestor of main, branched fresh, one commit, merge-tree
CLEAN, carries zero #1237 commits, and does not touch the #369 branch.

Scope caveat, volunteered by the authoring session rather than discovered -- NOW SATISFIED. At
commit time the full suite was still running, so they explicitly did not claim it and neither did I.
It has since finished green on the exact tree at b254dddd: 12412 passed, 827 skipped, 0 failed
(37m51s). Recorded here rather than left standing, because a caveat that quietly evaporates is worse
than one never raised -- a later reader finds a PR body warning about a suite nobody confirmed and
cannot tell whether it was checked or forgotten. CI remains the gate; the local run does not substitute
for it.

…G #1238)

RemoteFileSource joined a filename from a remote SFTP/FTP server's own directory
listing straight onto the configured remote_dir with no containment check. The
only filter was fnmatch against the pattern, and fnmatch's '*' spans '/' unlike
glob -- so the default '*.hl7' matches '../../etc/passwd.hl7'.

Screened at the SOURCE: immediately after list_dir returns and before the
pattern filter, so it dominates every consumer. That placement is the point.
The raw name reaches at least four consumers -- the retrieve path, the
error/oversize move, the after_read disposition (move, delete or the leave-mode
dedup key) -- and three of _move's four callers are error paths not gated on
after_read, so no configuration avoids them. A per-consumer check would keep
missing one: the consumer set grew at every measurement pass and never shrank.

REJECT, NEVER REWRITE -- the owner's ruling, and the reason is recorded in the
helper because the obvious fix is actively harmful. posixpath.basename MUTATES
rather than refuses, so '../../etc/adt_20260812.hl7' becomes a name that
rejoins to a REAL file in the poll directory, handing a hostile server a
retrieve/move/delete primitive against the partner's own drop directory. It is
also a no-op on the Windows-separator form, and because its output can never
contain '/', any containment check placed after it is unreachable and always
passes.

Refused: non-str, empty, '.', '..', any '/' or '\', control characters, and the
drive-relative form ('C:x.hl7') which carries no separator at all and so slips
a separator-only check.

A refused entry is left in place and logged, NOT quarantined: moving it would
join the hostile name onto a directory, which is the operation being refused.
Logged without the name, since this source does not log filenames at INFO+.

MUTATION-VERIFIED, twice, because one mutation only proves the test sees the
null case: removing the check fails both new tests, and replacing it with
basename -- the plausible WRONG fix rather than the absent one -- fails the
traversal test. The check is proven to discriminate against the near miss.

Verified: ruff, ruff format, mypy strict (265 files) clean; the remotefile
suite 86 passed. The FULL suite was still running when this was committed and
is NOT part of this claim -- CI is the gate for it.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 13, 2026 16:34
@wshallwshall
wshallwshall merged commit f27509f into main Aug 13, 2026
33 of 34 checks passed
@wshallwshall
wshallwshall deleted the claude/builder-2-1238-listing-name-containment branch August 13, 2026 19:46
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.

1 participant