Skip to content

1.1.77: no jail symlink followed on shares and mounts, own-jail container names, minimal jail env, shared display warns - #272

Merged
click0 merged 4 commits into
mainfrom
claude/analyze-test-coverage-nCOJW
Nov 14, 2026
Merged

click0 merged 4 commits into
mainfrom
claude/analyze-test-coverage-nCOJW

Conversation

@click0

@click0 click0 commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

This release closes several ways the jail could steer what the host does:

  • shares, mount targets and the socket proxy no longer follow a symlink the jail made (one race against a running jail stays in TODO §2);
  • under crate(1), a container name finds only the caller's own crate jails;
  • crate console, info and export no longer fall back to whatever jail matches loosely;
  • a command run in a jail gets an environment of its own instead of crate's;
  • the shared Wayland display (gui: auto / gui.mode: wayland) now warns that it is not a boundary between tenants, as x11 shared already did.

It closes these TODO items:

  • MED, left open by 1.1.76;
  • MED, findContainer;
  • MED, console/info fallback;
  • MED, root-path JailExec environment;
  • MED, crate import through a dangling symlink;
  • MED, crate logs: only its symlink/FIFO part. The item stays open, narrowed to "no writer of the console logs".

It adds three TODO §7 items it leaves open:

  • MED: the shared display plane is one trust domain; the real fix needs wp_security_context_manager_v1.
  • MED: the X servers crate starts have no access control, and websockify has no bind address.
  • LOW: services and hooks do not get the jail's display variables.

files/share (lib/run.cpp)

  • The share code linked, copied, unlinked and touched <tree>/<jail file> by path, as host root.
  • A symlink at the jail file sent the host's own file to the share's host path. A symlinked directory put the hard link or the mount target outside the tree.
  • The jail's side now goes through new FsBeneath helpers: touch, linkIn, linkOut and parentDevice.
  • linkOut links only a regular file. After linking it checks the inode again, and removes the link if the jail swapped the file in between.

Mount targets and nmount(2)

Files: lib/mount.cpp, lib/run.cpp, lib/run_gui.cpp, lib/mounts_pure.cpp, lib/runtime_paths_pure.cpp.

  • Targets are now made through FsBeneath.
  • On the root path, Mount refuses a mount point whose part inside the jail does not resolve to itself (realpath). The way to the jail root is the host's (RuntimePathsPure::crateJailRootOf), so a host with /var behind a symlink still mounts.
  • Mount points are normalised first (MountsPure::normalizeMountPoint), so a share with a trailing slash still mounts.
  • A share or tmpfs whose jail path runs through a symlink of the jail's own base (for example /etc/unbound) is refused now. run/user creates /home as a directory before pw useradd -m, which on FreeBSD 13 would make it a symlink.
  • The mount still goes by path after the check. That race, and a way to close it, is in TODO §2.

socket_proxy (lib/run_services.cpp)

  • socat now fchdir()s to the directory opened through FsBeneath and binds the bare socket name.

Container names

Files: lib/jail_query.cpp, lib/run_pure.cpp, lib/runtime_paths_pure.cpp, daemon/routes.cpp, daemon/control_socket.cpp.

  • findContainer now uses RunPure::chooseContainer:
    • only crate jails count; a bastille web could be stopped, restarted or updated before;
    • for crate(1), the container name matches only the caller's own jails (/var/run/crate/<euid>/…). Root's crate stop web used to hit another tenant's web_pidN;
    • a jid that names a different jail than the container name is ambiguous. Container 3 was shadowed by jid 3.
  • crated runs as root but answers for its peers, so its routes pass anyOwner: the container name matches among every tenant's jails, the pool ACL judges the jail that was found, and two tenants' web give 409.
  • HTTP and control-socket start check the container name of what they were given. Before, starting web_pid1 missed a running web_pid999.

console / info / export (lib/console.cpp, lib/info.cpp, lib/export.cpp)

  • The fallback after findContainer is removed. It matched:
    • the prefix jail-<name>;
    • any jid stoi() could read out of the name;
    • the hostname, which by default is the host's own, for every jail.
  • It also bypassed the ambiguity refusal: crate console web could open a root login in webapp.
  • crate export now takes its default file name from the container name, not from the hostname the jail can set (which may contain /).

Jail environment

Files: lib/jail_query.cpp, lib/passwd_pure.cpp, daemon/privops_handlers.cpp.

  • On the root path, a command run in the jail inherited what crate(1) runs with. That is not the operator's shell: cli/main.cpp rebuilds the environment from a short list at startup. But it held the host's DISPLAY, WAYLAND_DISPLAY, XAUTHORITY and XDG_RUNTIME_DIR, which are wrong inside the jail, and it differed from what rootless gives.
  • Both paths now build it with PasswdPure::jailExecEnv: PATH, HOME, SHELL, USER, LOGNAME, TERM, and on the root path LANG.
  • The command's own variables (CRATE, the jail's DISPLAY, …) still travel on its env(1) line. Services and hooks do not get those (TODO §7).

crate logs / crate import

  • crate logs reads the jail's /var/log/messages through FsBeneath, regular files only (lib/lifecycle.cpp).
  • crate import writes its output with the new FsBeneath::copyToNewFile: O_EXCL | O_NOFOLLOW, owner set through the descriptor, removed again on failure (lib/import.cpp).
  • New CRATE_PATH_SHA256.

Shared display plane (lib/run_gui.cpp, lib/run_gui_pure.{h,cpp})

  • gui: auto / gui.mode: wayland / x11 shared bind the host operator's own display socket into the jail. A client in such a jail reaches the whole host display session, other jails' windows included. It is one trust domain, not a tenant boundary.
  • The X11 path warned; the Wayland bind only logged a progress line, and the X11 warning also fired for a Wayland-only run, which binds no X11 socket.
  • RunGuiPure::sharedDisplayWarnings now decides. The X11 warning fires only when an X11 socket is bound; the Wayland one is printed once the socket is mounted. Each has its own ack variable (CRATE_X11_SHARED_ACK, new CRATE_WAYLAND_SHARED_ACK).
  • The real fix, a socket restricted through the compositor's wp_security_context_manager_v1, is TODO §7.

Docs

  • README / README_UK and crate.5: how targets are resolved; crate.5 also says a share's jail path must not run through a symlink.
  • docs/trust-model.md / trust-model.uk.md: a new "Shared display plane" section, with the caveat that headless / nested / gpu displays are separate but not sealed.
  • The lxqt-wayland example has a security note.
  • Runbook §4s.

Tests

  • make test-unit with -Werror: 1654/1654 passed.
  • New cases:
    • fs_beneath_test: touch_and_open_directory, share_links, copy_to_new_file;
    • mounts_pure_test: normalize_mount_point;
    • run_pure_test: choose_container (with anyOwner);
    • runtime_paths_pure_test: crate_jail_owner_uid, crate_jail_root_of;
    • passwd_pure_test: jail_exec_env (with LANG);
    • run_gui_pure_test: shared_display_warnings.
  • The mounts, the GUI socket plumbing, the warnings and the daemon lookups are not run on FreeBSD hardware; runbook §4s covers them.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X6t6tzVypHye5bDGLxzmZK

files/share linked, copied, unlinked and touched the jail file by path
as host root: a link at the jail file sent the host's own file to the
share's host path, a symlinked directory put the hard link or the mount
target into a host directory. The jail's side now goes through
FsBeneath (new touch / linkIn / linkOut / parentDevice; linkOut checks
the linked inode again and removes a swapped link).

Mount targets (dirs/share, tmpfs, thin jail, X11 / Wayland / Pulse /
seatd sockets) are made through FsBeneath, and Mount refuses a mount
point that does not resolve to itself on the root path, as crated does
for rootless mounts. Mount points are normalised (MountsPure::
normalizeMountPoint). socat binds the proxy socket relative to a
directory held open through FsBeneath.

The check-then-mount race against a running jail stays in TODO §2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6t6tzVypHye5bDGLxzmZK
findContainer goes through RunPure::chooseContainer: crate jails only
(a bastille `web` was stopped / restarted / updated), the container
name among the caller's own jails only (root's `crate stop web` hit
another tenant's web_pidN), a jid that names another jail than the
container name is ambiguous. HTTP / control-socket start check the
container name of what they were given.

console / info / export drop their fallback (jail- prefix, stoi, the
hostname of every jail), which opened a root login in `webapp` for
`crate console web`; export names its archive by the container name.

The root-path JailExec execs with PasswdPure::jailExecEnv (PATH, HOME,
SHELL, USER, LOGNAME, TERM), the set crated uses too, instead of
crate(1)'s whole environment.

crate logs reads the jail's /var/log/messages through FsBeneath;
crate import writes a new file (O_EXCL, O_NOFOLLOW) and fchowns it.
CRATE_PATH_SHA256.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6t6tzVypHye5bDGLxzmZK
@click0 click0 changed the title 1.1.77: shares, mount targets and socket proxy follow no jail symlink 1.1.77: no jail symlink followed on shares and mounts, own-jail container names, minimal jail env Nov 13, 2026
The shared GUI path binds the host operator's own display socket into
the jail: for X11 the host /tmp/.X11-unix, for Wayland (gui: auto on a
Wayland host, or gui.mode: wayland) the host compositor's
$WAYLAND_DISPLAY socket. X11 has no inter-client isolation, and the raw
Wayland socket carries the compositor's privileged protocols (virtual
keyboard/pointer, screencopy/export-dmabuf, data-control,
foreign-toplevel, session-lock, input-inhibit). A client in one such
jail can inject input into, capture and drive the GUI of every other
jail on the same display and of the host itself — the shared display
plane is a single trust domain, not a boundary between tenants.

The X11 path warned; the Wayland bind only logged a progress line, and
the X11 warning even misfired for a Wayland-only run (gui.mode: wayland
routes through the shared block but binds no X11 socket, so its "read
host keystrokes" wording was wrong). Move the decision into
RunGuiPure::sharedDisplayWarnings so each warning fires only when that
socket is actually bound, each with its own ack env var
(CRATE_X11_SHARED_ACK, new CRATE_WAYLAND_SHARED_ACK).

The real fix — binding a socket restricted through the compositor's
wp_security_context_manager_v1 so the privileged protocols are withheld
from jail clients — needs compositor support and is recorded as TODO §7.
docs/trust-model.md / .uk.md gain a "Shared display plane" section, the
lxqt-wayland example a security note, and the on-hardware runbook §4s a
check that the warnings fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6t6tzVypHye5bDGLxzmZK
@click0 click0 changed the title 1.1.77: no jail symlink followed on shares and mounts, own-jail container names, minimal jail env 1.1.77: no jail symlink followed on shares and mounts, own-jail container names, minimal jail env, shared display warns Nov 13, 2026
Findings of the review of this branch:

- crated resolved container names among root's jails only: findContainer
  passed its own euid, which in the daemon is 0, so stop / restart / GET
  by container name returned 404 for a tenant's rootless jail and start
  no longer answered 409. chooseContainer / findContainer take anyOwner;
  the HTTP and control-socket routes use it (the pool ACL judges the
  jail that was found), crate(1) keeps "the caller's own".
- jailExecEnv dropped LANG, which cli/main.cpp keeps on purpose; the
  root path passes a plain locale name again.
- The changelog and a comment said the jail had inherited the
  operator's whole environment. cli/main.cpp rebuilds environ from a
  short list at startup; what crossed was the host's DISPLAY,
  WAYLAND_DISPLAY, XAUTHORITY and XDG_RUNTIME_DIR. Text corrected.
- Mount's realpath rule covered the host's way to the jail root, so a
  host with /var or /var/run behind a symlink could not mount at all.
  Only the part inside the jail root has to resolve to itself
  (RuntimePathsPure::crateJailRootOf).
- run/user: /home is created as a directory before pw useradd -m, which
  on FreeBSD 13 makes it a symlink the share code then refuses.
- The Wayland warning is printed once the socket is mounted, not on
  intent.
- import: the copy loop is FsBeneath::copyToNewFile now (O_EXCL,
  O_NOFOLLOW, fchown through the descriptor), with a unit test.
- docs/trust-model: headless / nested / gpu displays are separate, not
  sealed (no X access control, sockets in the host's /tmp/.X11-unix,
  websockify without a bind address) — caveat added, TODO §7 item.
- TODO: two stale items rewritten, two "left open by 1.1.77" added;
  crate.5 says a share's jail path must not run through a symlink.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6t6tzVypHye5bDGLxzmZK
@click0
click0 merged commit 2daf6ea into main Nov 14, 2026
5 checks passed
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.

2 participants