wasi_path (wasm v0.1.0)

View Source

Capability-scoped path resolution.

This is the part of WASI that decides whether a sandbox is real, so read it before you trust one. A module names a path relative to a preopened directory, and this module decides which host path, if any, that is allowed to mean.

Four escape routes have to be closed, and all four are tested in wasi_SUITE:

  • absolute paths (/etc/passwd)
  • parent traversal (../../etc/passwd)
  • traversal that only escapes partway through (sub/../../outside)
  • symlinks pointing out of the sandbox, including absolute ones and symlinked directories used as a prefix

filelib:safe_relative_path/2 already handles all four, including the symlink cases, so it is used rather than reimplemented: a hand-rolled path sanitiser is exactly the kind of code that looks right and is not.

Residual risk you carry. There is a time-of-check to time-of-use window between resolving a path and opening it: someone with write access to the sandbox directory can replace a component with a symlink in between. Re-verifying the opened path afterwards narrows the window without closing it, because Erlang's file module exposes neither openat nor O_NOFOLLOW. Do not point a preopen at a directory an untrusted party can write to concurrently.

Summary

Functions

Resolve a guest-supplied path against a preopened host directory.

As resolve/2. MustExist additionally requires the target to be present, which distinguishes "you may not name that" from "it is not there".

As resolve/3, saying whether the last component may be resolved.

Re-check that a path is still inside the root.

Functions

resolve(HostRoot, GuestPath)

-spec resolve(file:filename_all(), binary()) -> {ok, file:filename_all()} | {error, non_neg_integer()}.

Resolve a guest-supplied path against a preopened host directory.

resolve/3

-spec resolve(file:filename_all(), binary(), boolean()) ->
                 {ok, file:filename_all()} | {error, non_neg_integer()}.

As resolve/2. MustExist additionally requires the target to be present, which distinguishes "you may not name that" from "it is not there".

resolve/4

-spec resolve(file:filename_all(), binary(), boolean(), follow | nofollow) ->
                 {ok, file:filename_all()} | {error, non_neg_integer()}.

As resolve/3, saying whether the last component may be resolved.

nofollow is what lets this backend name a symlink. filelib:safe_relative_path/2 resolves links in the course of deciding a path is safe, which is what makes it safe without openat, and it means the resolved path is the link's target: a readlink through it answers EINVAL about a regular file, and an unlink removes the wrong thing.

So for nofollow only the parent is resolved, and the last component is appended by name. Containment still holds and holds for the same reason: the parent is inside the root because safe_relative_path/2 said so, and a bare name with no separator in it cannot leave the directory it is in.

. and .. are resolved whole. They name directories, so there is no final component to decline to follow, and appending them by name is exactly the traversal this must not do by hand.

verify_within(HostRoot, Path)

-spec verify_within(file:filename_all(), file:filename_all()) -> ok | {error, non_neg_integer()}.

Re-check that a path is still inside the root.

Called after opening, to narrow the time-of-check to time-of-use window described above. It cannot eliminate it.