Confine every non-admin account by default - #57
Merged
Conversation
These are multi-tenant boxes, and the root VPSes we sell hand a customer
an account rather than the root password. Until now an account on one
could read every other home, watch every command line on the machine,
take the box down with one build, and relay out over ssh under our IP.
Adds a sandbox with three tiers, where the boundary is group membership:
root is untouched always, anyone in sudo/admin is exempt, and everybody
else is confined with nothing to remember.
home 0700 + an execute-only ACL for www-data, so nginx can still walk
through to ~/public_html without the path being open to every account
/proc hidepid, so ps shows you your own processes
systemd slice caps (MemoryMax/TasksMax) and pam limits (nproc/nofile)
sshd policy: ssh -L yes, ssh -R no
kernel floors for kptr/dmesg/perf/protected_*/suid_dumpable
Every cap is lifted again for uid 0 and each admin uid explicitly --
user-.slice.d is a prefix drop-in that otherwise applies to root's own
slice, and the account you fix a wedged box with must not be subject to
the cap that is wedging it.
It deliberately does NOT demote an account that already has sudo:
doing that unattended is how you lose a box. `sandbox` names them.
Three things this got wrong first and now handles:
* the kernel settings are floors, not values. Ubuntu ships
perf_event_paranoid=4 and unprivileged_bpf_disabled=2 already
stricter than the target, so writing them unconditionally would
loosen the box in the name of hardening it.
* fs.suid_dumpable does not run in one direction -- 0 is safest, 1 is
the dangerous one, 2 sits in between -- so it cannot be a floor, and
treating it as one left a box on 2 and called it hardened.
* the fstab /proc line is written only after a live remount has proved
the kernel takes the option; an entry it rejects fails the mount at
boot, which is the worst place to find out.
DEFAULT_GROUPS now defaults to users rather than sudo,admin while the
sandbox is on -- an unattended run never prompts, so the old default
handed each new tenant the way straight back out. An explicit setting
still wins.
New subcommand: `root-ubuntu.sh sandbox [status|apply]`. status reads
only and needs no root.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8y84poK1amqfctipMbjJm
ThreatCrush Security Scan12 finding(s) HIGH/CRITICAL: 4 | MEDIUM: 4 | LOW: 4
Snippets are redacted; ThreatCrush never prints matched credential material. |
master has since grown a feature that already owns the word: `--sandbox` puts a tenant in their own incus container or VM. That is isolation by virtualisation. This is isolation by policy on a shared box -- home modes, hidepid, a systemd slice, limits, sshd -- and every identifier here collided with it: SANDBOX, SANDBOX_GROUP, cmd_sandbox, sandbox_usage. "Confine" is the better word for it anyway, and this branch was already using it in prose: it is what AppArmor and SELinux call exactly this, and the table in the PR was headed "confined?" from the start. SANDBOX* -> CONFINE* sandbox -> confine (subcommand, functions) sandboxed -> confined (the group, and the adjective) configure_user_sandbox -> configure_confinement The two features are layers, not rivals: a tenant with an instance is also a non-admin account out here, so confinement applies to them too. No behaviour change. 791 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y32FRASN3UZhUgwFdbEAzb
master merged #56, which puts a tenant in their own incus container or VM under the name `sandbox`. This branch confines accounts in place. They are layers rather than rivals -- a tenant with an instance is still a non-admin account out here -- so both survive the merge intact. The rename in the previous commit did most of the work; what was left: * both features had inserted a section at the same point, and git had folded the shared "esac }" tail of cmd_confine and cmd_sandbox together. cmd_confine's case is closed explicitly and both sections are kept * one subcommand list, one dispatch arm each * a comment about ssh-agent lingering that #56 orphaned above the sandbox stage is back with the stage it describes Checked rather than assumed, since git auto-merged several regions both branches had touched: * fix_home_permissions keeps BOTH sides -- the _confine_home_mode branch and the per-app publishing walk * add_user_spec keeps both the tenant-groups shortcut and ask_groups * DEFAULT_GROUPS still moves to `users` under confinement * stage order is right: incus up, accounts created, THEN confinement, which is what makes group membership the thing that decides a tier Tenants are excluded from _confine_humans (nologin), which is correct: they have no host processes to cap. Their home mode is still set, because that runs per-home out of fix_home_permissions rather than off that list. Three defects fixed on the way through: * the accounts table hand-padded "admin" to the width of "sandboxed", so the rename to the shorter "confined" misaligned the column. It pads in the format string now * _confine_sysctl_floor's `want` local collided with an array of the same name elsewhere; the linter does not scope locals, so it cost two false warnings. Renamed to `floor` * two tests pinned the whole subcommand alternation and broke when `sandbox` joined it. They match the verb inside the list now, so the next subcommand does not break them 791 tests pass; shellcheck clean apart from one SC2088 that predates both branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y32FRASN3UZhUgwFdbEAzb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
These are multi-tenant boxes, and the root VPSes we sell hand a customer an account, never the root password. Measured on
devbefore this change: 2,437 world-readable files under/home/anthonyalone — including session tokens in~/.config— readable by each of the seven non-sudo accounts on the box.Renamed, and rebased on master
This branch was called "the user sandbox". master has since merged #56, which already owns that word:
--sandboxputs a tenant in their own incus container or VM. That is isolation by virtualisation; this is isolation by policy on a shared box. Every identifier collided —SANDBOX,SANDBOX_GROUP,cmd_sandbox,sandbox_usage.So this is now confinement, which is the better word regardless: it is what AppArmor and SELinux call exactly this, and the table below was headed "confined?" from the first draft.
The two features are layers, not rivals — a tenant with their own instance is still a non-admin account out here, so confinement applies to them too. Tenants are excluded from the resource caps (they have
nologinand no host processes to cap), but their home mode is still set, because that runs per-home rather than off that list.The model
Three tiers, and the boundary is group membership:
sudo,adminMembership is recomputed every run, so a promotion releases someone and a demotion confines them again without anyone remembering which flag was passed when the account was made.
0751, walk in and read0700ps -efMemoryMax=50%,TasksMax=4096nprocssh -Rssh -Lstill works)kptr/dmesg/perfreadablenginx has to walk through a home to reach
~/public_html, and the old answer —chmod o+x— opens that path to every account. This grantswww-dataan execute-only ACL instead, and only where something is actually published.Never root
user-.slice.dis a prefix drop-in — it applies touser-0.slicetoo. So the caps are lifted again for uid 0 and each admin uid explicitly, by uid. The account you fix a wedged box with must not be subject to the cap that is wedging it.It also will not demote an account that already has sudo. Doing that unattended is how you lose a box: it might be a colleague, the only other admin, or the account your automation logs in as.
confinenames them and stops.Three things this got wrong first
perf_event_paranoid=4andunprivileged_bpf_disabled=2already stricter than the target, so writing them unconditionally would have loosened the box in the name of hardening it — silently, on the distro we run everywhere.fs.suid_dumpabledoes not run in one direction. 0 is safest, 1 is the dangerous one, 2 sits in between. Treating it as a floor left a box on 2 and called it hardened. Caught by dumping the generated files rather than by reading the code./procline is written only after a live remount proves the kernel takes the option. An entry it rejects fails the mount at boot, which is the worst place to find out.I also claimed in a comment that the drop-in's closing
Match allwas load-bearing — that sshd would otherwise swallow the directives after theInclude. Measured against OpenSSH 10.2p1 and it is not true: aMatchis scoped to the file it appears in, and the effective config is byte-identical either way. The line stays (it costs nothing and the file may move), but the comment and test now say what was actually measured.Three more found while merging
git auto-merged several regions both branches had touched, so those were read rather than trusted —
fix_home_permissions,add_user_specand the stage ordering all keep both sides. On the way through:"admin "to the width of"sandboxed". Renaming to the shorterconfinedmisaligned the column by one. It pads in the format string now, so neither word's length is load-bearing._confine_sysctl_floor'swantlocal collided with an array of the same name elsewhere in the file. The linter does not scope locals, so the name cost two false SC2178/SC2128 warnings — renamed tofloor.sandboxjoined it. They match the verb inside the list now, so the next subcommand added does not break them.One more, self-inflicted and worth knowing: a comment that opens with the linter's own name is parsed as a directive, and the parse error aborts the rest of the scan — so the file looked clean for the wrong reason until the comment was reworded.
Verification
pnpm test— 791 passed, 29 files, including 33 tests for thisbash -nclean;shellcheck -S warningclean apart from one SC2088 at line 1116 that predates both branches (verified against master)/etcfile into a temp tree and read it; the second run is silent (idempotent)sshd -tandsshd -Tagainst a synthesized Ubuntu-shaped configconfine status,sandbox, andgroups --helpall run side by side after the merge;confine statusagainst this box correctly reports it as currently wide opentest/torrent.test.ts > computes the info hash WebTorrent computesfailed once in five runs and passed the other four. It is untouched by this branch — flaky, not a regression, and not chased here.pnpm typecheckfails insrc/free-names.ts, which is pre-existing and untouched (verified identical tomaster).Not applied anywhere yet
This is the capability, not a rollout.
dev, vienna and the DO droplets all still report wide open; applying it changes home modes and sshd for real users mid-session, so that wants a deliberate run.CONFINE=0opts out entirely.🤖 Generated with Claude Code
https://claude.ai/code/session_01Y32FRASN3UZhUgwFdbEAzb