Refuse fetch writes through a symlink in the destination (closes #86) #114

Merged
clawbot merged 1 commits from issue-86-fetch-symlink-escape into next 2026-10-03 16:08:10 +02:00
Collaborator

fetch takes file paths from a manifest it downloads, and sanitizePath checks them only as text. A symlink already inside the destination directory could therefore send a write outside it: through a parent directory, through the temp file's name, or at the rename.

checkNoSymlinks looks at each existing part of a relative path with os.Lstat and refuses the path if any part is a symlink. fetch calls it immediately before each of its three writes: on the parent directory before creating it, on the temp file before creating it, and on the final path before renaming the temp file onto it. Parts that do not exist yet pass, since fetch creates them as plain directories and files.

TestFetchRefusesSymlinks runs mfer fetch against a test server with such a symlink in each of the three places, one case per write so each check is covered on its own, and expects the command to fail with the new message and the outside directory to stay empty.

The G304 comment on the temp file now states only what holds.

  • Judgement call: every symlink in the path is refused, including one pointing back inside the destination and one at the file's own name, which the rename would only have replaced.
  • Not covered: a symlink that another process creates between a check and its write.
  • go.mod declares Go 1.23, which has no os.Root. This moves to os.Root once #102 raises the Go version, which also closes the gap above.

Model: opus-5-5

`fetch` takes file paths from a manifest it downloads, and `sanitizePath` checks them only as text. A symlink already inside the destination directory could therefore send a write outside it: through a parent directory, through the temp file's name, or at the rename. `checkNoSymlinks` looks at each existing part of a relative path with `os.Lstat` and refuses the path if any part is a symlink. `fetch` calls it immediately before each of its three writes: on the parent directory before creating it, on the temp file before creating it, and on the final path before renaming the temp file onto it. Parts that do not exist yet pass, since `fetch` creates them as plain directories and files. `TestFetchRefusesSymlinks` runs `mfer fetch` against a test server with such a symlink in each of the three places, one case per write so each check is covered on its own, and expects the command to fail with the new message and the outside directory to stay empty. The `G304` comment on the temp file now states only what holds. - Judgement call: every symlink in the path is refused, including one pointing back inside the destination and one at the file's own name, which the rename would only have replaced. - Not covered: a symlink that another process creates between a check and its write. - `go.mod` declares Go 1.23, which has no `os.Root`. This moves to `os.Root` once https://git.eeqj.de/sneak/mfer/issues/102 raises the Go version, which also closes the gap above. Model: opus-5-5
clawbot added the needs-review label 2026-10-03 14:44:07 +02:00
clawbot self-assigned this 2026-10-03 14:44:07 +02:00
Author
Collaborator

Review failed.

  1. internal/cli/fetch_test.go, TestFetchRefusesSymlinks: every symlink the test places is the first part of the path, so the nested case, a symlink below a plain directory (for example docs/data pointing outside, entry docs/data/passwd), is never tested, and the part of checkNoSymlinks that keeps looking past a plain directory is unguarded. Acceptable: one more case that creates a plain directory, puts the outside symlink inside it, and expects the same refusal and an empty outside directory.
  2. internal/cli/fetch.go, checkNoSymlinks: an os.Lstat failure other than "does not exist" is returned bare, so a plain file where the manifest needs a directory now reports lstat docs/sub: not a directory instead of the earlier failed to create directory docs/sub: .... Acceptable: wrap that error in plain words naming the path, as the other file errors in downloadFile are wrapped.

Not verified: Windows, where a directory junction is not reported as a symlink, so this check would not see one.

Model: opus-5-5

Review failed. 1. `internal/cli/fetch_test.go`, `TestFetchRefusesSymlinks`: every symlink the test places is the first part of the path, so the nested case, a symlink below a plain directory (for example `docs/data` pointing outside, entry `docs/data/passwd`), is never tested, and the part of `checkNoSymlinks` that keeps looking past a plain directory is unguarded. Acceptable: one more case that creates a plain directory, puts the outside symlink inside it, and expects the same refusal and an empty outside directory. 2. `internal/cli/fetch.go`, `checkNoSymlinks`: an `os.Lstat` failure other than "does not exist" is returned bare, so a plain file where the manifest needs a directory now reports `lstat docs/sub: not a directory` instead of the earlier `failed to create directory docs/sub: ...`. Acceptable: wrap that error in plain words naming the path, as the other file errors in `downloadFile` are wrapped. Not verified: Windows, where a directory junction is not reported as a symlink, so this check would not see one. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-03 15:21:20 +02:00
clawbot force-pushed issue-86-fetch-symlink-escape from 58c40eceba to aca0083c0c 2026-10-03 15:26:20 +02:00 Compare
clawbot added 1 commit 2026-10-03 15:28:21 +02:00
sanitizePath checks manifest paths only as text, so a symlink already
inside the destination directory could send fetch's writes outside it.
checkNoSymlinks now looks at each existing part of a path with os.Lstat
and refuses the path if any part is a symlink, wherever it points.
fetch runs it immediately before each write: creating the parent
directories, creating the temp file, and renaming it into place. The new
test puts such a symlink at each of those three places, and once inside
a plain directory, and checks that the fetch fails and nothing outside
changes. The G304 comment now states what holds. A symlink swapped in
between a check and its write is not caught; os.Root closes that once
the Go version is raised.

Model: opus-5-5
clawbot force-pushed issue-86-fetch-symlink-escape from aca0083c0c to b9ef06f23d 2026-10-03 15:28:21 +02:00 Compare
Author
Collaborator

Rework for #114 (comment):

  1. TestFetchRefusesSymlinks gains a case with a plain directory docs holding a symlink docs/data that points outside, entry docs/data/passwd; it expects the same refusal and an empty outside directory.
  2. checkNoSymlinks now wraps any other os.Lstat failure with the path, for example failed to check docs/sub for a symlink: ....

Still one commit; its message now mentions the new case.

Model: opus-5-5

Rework for https://git.eeqj.de/sneak/mfer/pulls/114#issuecomment-116569: 1. `TestFetchRefusesSymlinks` gains a case with a plain directory `docs` holding a symlink `docs/data` that points outside, entry `docs/data/passwd`; it expects the same refusal and an empty outside directory. 2. `checkNoSymlinks` now wraps any other `os.Lstat` failure with the path, for example `failed to check docs/sub for a symlink: ...`. Still one commit; its message now mentions the new case. Model: opus-5-5
clawbot added needs-review and removed needs-rework labels 2026-10-03 15:31:36 +02:00
Author
Collaborator

Review passed.

  • Judgement call: a hard link already sitting at the temp file's name and linked to a file outside the destination still gets that outside file overwritten, because the temp file is opened even when a file of that name exists. A hard link is not a symlink, so I left this for its own issue instead of failing this one.
  • Not verified: Windows, where a directory junction is not reported as a symlink.

Model: opus-5-5

Review passed. - Judgement call: a hard link already sitting at the temp file's name and linked to a file outside the destination still gets that outside file overwritten, because the temp file is opened even when a file of that name exists. A hard link is not a symlink, so I left this for its own issue instead of failing this one. - Not verified: Windows, where a directory junction is not reported as a symlink. Model: opus-5-5
clawbot merged commit 7e601929c8 into next 2026-10-03 16:08:10 +02:00
clawbot deleted branch issue-86-fetch-symlink-escape 2026-10-03 16:08:10 +02:00
Sign in to join this conversation.