docker: host paths resolve absolute symlinks in the container's namespace, not the host's #71

Open
opened 2026-09-23 14:14:45 +02:00 by thisilike · 0 comments
Owner

From the #66 approval review.

hostPath(p) prefixes the host root (for example /host/opt/stack/compose.yaml), but symlinks under it are followed by the kernel inside OpsDeck's own mount namespace. If the compose file is an absolute symlink to /srv/stacks/compose.yaml, the target is looked up in the container rather than under /host. Two outcomes:

  • Write (pin_apply.ts replaceFile). Deno.realPath usually fails with NotFound. The code then falls back to the link path, and the rename replaces the operator's symlink with a regular file. The real file at /srv/stacks/compose.yaml goes stale. replaceFile is supposed to prevent exactly this.
  • Write, target exists. If a file does exist at that path inside the container, the write lands there. That bypasses the host-root guard.

This affects more than the write path. Every Deno.readTextFile(hostPath(p)) in mod.ts and actions.ts has the same problem. It is not a regression from #66. pin_apply_test.ts:458 uses an absolute symlink with no host-root prefix, so it cannot catch it.

Fix. Write a resolver that follows links one hop at a time with Deno.readLink. It should re-root absolute targets under the host root and re-check for escape after every hop. Then use it for both reads and writes. makeHostPath is currently sync and has many call sites, so the resolver needs either an async variant or a separate resolveHostPath used at the fs boundary.

Test. Add a host-root fixture containing an absolute symlink whose target exists only under that fixture root.

From the #66 approval review. `hostPath(p)` prefixes the host root (for example `/host/opt/stack/compose.yaml`), but symlinks under it are followed by the kernel inside OpsDeck's own mount namespace. If the compose file is an absolute symlink to `/srv/stacks/compose.yaml`, the target is looked up in the container rather than under `/host`. Two outcomes: - **Write (`pin_apply.ts` `replaceFile`).** `Deno.realPath` usually fails with NotFound. The code then falls back to the link path, and the rename replaces the operator's symlink with a regular file. The real file at `/srv/stacks/compose.yaml` goes stale. `replaceFile` is supposed to prevent exactly this. - **Write, target exists.** If a file does exist at that path inside the container, the write lands there. That bypasses the host-root guard. This affects more than the write path. Every `Deno.readTextFile(hostPath(p))` in `mod.ts` and `actions.ts` has the same problem. It is not a regression from #66. `pin_apply_test.ts:458` uses an absolute symlink with no host-root prefix, so it cannot catch it. **Fix.** Write a resolver that follows links one hop at a time with `Deno.readLink`. It should re-root absolute targets under the host root and re-check for escape after every hop. Then use it for both reads and writes. `makeHostPath` is currently sync and has many call sites, so the resolver needs either an async variant or a separate `resolveHostPath` used at the fs boundary. **Test.** Add a host-root fixture containing an absolute symlink whose target exists only under that fixture root.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/core#71
No description provided.