mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 01:01:12 -07:00
forgefirm-users: an operator account reaches its home
On the bench reference /data/forgefirm is 0700, and the operator account
could not enter its own home under it (su scott -c "cd ~": permission
denied). forgefirm-users.init makes the directory of homes 0755 on purpose
("a login traverses it to reach its home") and said nothing about the data
directory above it. No script in the tree makes that directory 0700:
settings.c makes it 0755, forgefirm-logging makes it under rcS's umask, and
this script's own mkdir -p under umask 077 never makes it, because the
record it waits for lives inside it (forgectrl 35684ca's message names
that mkdir as a possible author, and it cannot be). The bench reference's
directory dates from 2026-09-11. The render does not depend on who made it.
replay() now puts the search bit for group and others on the data directory
at every render (boot, and every reload forgectrl asks for), whoever made
it and under whatever umask. Nothing is taken away and nothing in it
becomes listable; what is private there is closed file by file. The same
bit is what lets an extension package's account walk to its files.
setup.account-login sets the data directory to 0700 before it makes its
temporary account, as a strict umask leaves it, and then tries the account
from the inside (a child that becomes it): the render that made the account
left the directory at 0711, the home can be entered and written, the data
directory cannot be listed, and the account record cannot be read. Its
put-back (the found mode, the replay, the temporary home) now runs on every
exit path; before, a failure inside the account's lifetime left the
temporary home and its passwd line behind. image.health asks for the search
bit on a machine with an account record.
Proven. On the bench reference, image 20260921022220, the suite files
mounted over the image's: with the image's own init script
setup.account-login FAILS (cd EACCES, write EACCES, the directory still
0700), as it should; with this script mounted over /etc/init.d it PASSES
(0700 to 0711 at the account's render, cd ok, write ok, the listing and the
record EACCES), image.health PASSES reading mode 711, and the operator
account enters /data/forgefirm/home/scott and is refused the listing of
/data/forgefirm. The mounts were taken away and /tmp cleared; the
directory stays at 0711, which is the repair. The unit suite passes (451
tests, 0 undefined names).
Also on image 20260921022220 as flashed, before any of this was mounted:
image.health (its extension host section on a machine for the first time),
exthost.platform, setup.extensions-consent, exthost.service (a killed
host's service gone in 0.05 s, the host back after 5.7 s),
update.product-gate, and exthost.armed-freeze (the window open 38.6 s,
frozen 0.21 s after it opened and 6.02 s before the latch unlocked for the
run, in all 177 samples; thawed 0.41 s after the close) all PASS, and the
host logs under its own logger after a plain boot.
Acceptance. setup.account-login is the regression test, and image.health
proves the boot-time render on every campaign's first test. The init
script is layer content, in the platform identity of every fingerprint.
This commit is contained in:
@@ -122,7 +122,7 @@ def fds_of(pid):
|
|||||||
"the daemon ownership, "
|
"the daemon ownership, "
|
||||||
"the init ordering, the extension host as one process that starts after forgectrl and "
|
"the init ordering, the extension host as one process that starts after forgectrl and "
|
||||||
"stops before it on a machine with no package installed and nothing running under a "
|
"stops before it on a machine with no package installed and nothing running under a "
|
||||||
"pool account, the file modes the release depends on, and the mounts: the "
|
"pool account, the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
|
||||||
"rootfs read-only, /data writable, the account files and the banner rendered "
|
"rootfs read-only, /data writable, the account files and the banner rendered "
|
||||||
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
|
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
|
||||||
def image_health(ctx):
|
def image_health(ctx):
|
||||||
@@ -309,6 +309,14 @@ def image_health(ctx):
|
|||||||
ev[path] = "%o" % m
|
ev[path] = "%o" % m
|
||||||
ctx.log("%s mode %o", path, m)
|
ctx.log("%s mode %o", path, m)
|
||||||
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
|
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
|
||||||
|
# an operator account walks through the data directory to its home, and a
|
||||||
|
# package's account to its files: forgefirm-users opens it for search at
|
||||||
|
# every render, whoever made it and under whatever umask
|
||||||
|
if os.path.exists("/data/forgefirm/users"):
|
||||||
|
m = stat.S_IMODE(os.stat("/data/forgefirm").st_mode)
|
||||||
|
ev["/data/forgefirm"] = "%o" % m
|
||||||
|
ctx.log("/data/forgefirm mode %o", m)
|
||||||
|
ctx.check(m & 0o011 == 0o011, "/data/forgefirm mode %o: no account can walk through it to its home", m)
|
||||||
if os.path.isdir("/data"):
|
if os.path.isdir("/data"):
|
||||||
s = os.statvfs("/data")
|
s = os.statvfs("/data")
|
||||||
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
|
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
|
||||||
|
|||||||
@@ -593,7 +593,11 @@ def advisories_rehash(ctx):
|
|||||||
description="The test makes its own account: the account record is moved aside under a "
|
description="The test makes its own account: the account record is moved aside under a "
|
||||||
"forgectrl restart, the account route creates a temporary one with a password "
|
"forgectrl restart, the account route creates a temporary one with a password "
|
||||||
"only the test knows, and the real record comes back under another restart at "
|
"only the test knows, and the real record comes back under another restart at "
|
||||||
"the end, the system accounts replayed and the temporary home removed. Over "
|
"the end, the system accounts replayed and the temporary home removed. The data "
|
||||||
|
"directory is set to 0700 first, as a strict umask leaves it: the render that makes "
|
||||||
|
"the account opens it for search and nothing else (0711), and tried as the account, "
|
||||||
|
"its home can be entered and written while the data directory cannot be listed and "
|
||||||
|
"the account record cannot be read. Over "
|
||||||
"HTTPS (self-signed, so unverified): GET /wiz reports no session and the "
|
"HTTPS (self-signed, so unverified): GET /wiz reports no session and the "
|
||||||
"certificate fingerprint; POST /login with a wrong password is refused (401); "
|
"certificate fingerprint; POST /login with a wrong password is refused (401); "
|
||||||
"five failures lock the address (429, wait at most 30 s) and the right password "
|
"five failures lock the address (429, wait at most 30 s) and the right password "
|
||||||
@@ -608,27 +612,88 @@ def account_login(ctx):
|
|||||||
before = wiz(fc)
|
before = wiz(fc)
|
||||||
ev["account_before"] = (before.get("users") or {}).get("name")
|
ev["account_before"] = (before.get("users") or {}).get("name")
|
||||||
homes = "/data/forgefirm/home"
|
homes = "/data/forgefirm/home"
|
||||||
with installed(ctx, {users_path(): None}):
|
dir_mode = os.stat(data_dir()).st_mode & 0o7777
|
||||||
w = wiz(fc)
|
ev["data_dir_mode_found"] = "%04o" % dir_mode
|
||||||
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
|
try:
|
||||||
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
|
with installed(ctx, {users_path(): None}):
|
||||||
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
|
w = wiz(fc)
|
||||||
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
|
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
|
||||||
w = wiz(fc)
|
# the data directory as a strict umask leaves it: the account's render has to open it
|
||||||
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
|
os.chmod(data_dir(), 0o700)
|
||||||
login_checks(ctx, name, pw)
|
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
|
||||||
rc, out = hw.initd("forgefirm-users", "reload")
|
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
|
||||||
ev["users_reload_rc"] = rc
|
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
|
||||||
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
w = wiz(fc)
|
||||||
if name != ev["account_before"]:
|
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
|
||||||
import shutil
|
reach = home_reach(name)
|
||||||
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
|
ev["home_reach"] = reach
|
||||||
|
ev["data_dir_mode_after_render"] = "%04o" % (os.stat(data_dir()).st_mode & 0o7777)
|
||||||
|
ctx.log("as %s, from a data directory found 0700 (now %s): %s", name, ev["data_dir_mode_after_render"], reach)
|
||||||
|
ctx.check(reach.get("cd") == "ok" and reach.get("write") == "ok",
|
||||||
|
"the account cannot use its own home %s: %s", reach.get("home"), reach)
|
||||||
|
ctx.check(ev["data_dir_mode_after_render"] == "0711", "the render left the data directory at %s, expected "
|
||||||
|
"0711 (the search bit and nothing else)", ev["data_dir_mode_after_render"])
|
||||||
|
ctx.check(reach.get("list_data_dir") == "EACCES" and reach.get("read_record") == "EACCES",
|
||||||
|
"the account reads what is not its own: %s", reach)
|
||||||
|
login_checks(ctx, name, pw)
|
||||||
|
finally:
|
||||||
|
# as found; the replay below is the product's own say on the mode
|
||||||
|
os.chmod(data_dir(), dir_mode)
|
||||||
|
rc, out = hw.initd("forgefirm-users", "reload")
|
||||||
|
ev["users_reload_rc"] = rc
|
||||||
|
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
||||||
|
if name != ev["account_before"]:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
|
||||||
after = wiz(fc)
|
after = wiz(fc)
|
||||||
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
|
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
|
||||||
"the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"),
|
"the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"),
|
||||||
ev["account_before"])
|
ev["account_before"])
|
||||||
|
|
||||||
|
|
||||||
|
# Run as root: becomes the account and tries its own home, and what is
|
||||||
|
# not its own. One JSON line.
|
||||||
|
HOME_REACH = r'''
|
||||||
|
import errno, json, os, pwd, sys
|
||||||
|
p = pwd.getpwnam(sys.argv[1])
|
||||||
|
os.setgroups([])
|
||||||
|
os.setgid(p.pw_gid)
|
||||||
|
os.setuid(p.pw_uid)
|
||||||
|
|
||||||
|
|
||||||
|
def tried(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
return "ok"
|
||||||
|
except OSError as e:
|
||||||
|
return errno.errorcode.get(e.errno, str(e.errno))
|
||||||
|
|
||||||
|
|
||||||
|
def write():
|
||||||
|
path = os.path.join(p.pw_dir, ".forgetest-reach")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
print(json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)), "write": tried(write),
|
||||||
|
"list_data_dir": tried(lambda: os.listdir(sys.argv[2])),
|
||||||
|
"read_record": tried(lambda: open(os.path.join(sys.argv[2], "users")).read())}))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def home_reach(name):
|
||||||
|
"""What the account can do with its own home and with the data
|
||||||
|
directory around it, tried as the account."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
p = subprocess.run([sys.executable, "-c", HOME_REACH, name, data_dir()], capture_output=True, text=True, timeout=30)
|
||||||
|
try:
|
||||||
|
return json.loads(p.stdout)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": (p.stderr or p.stdout).strip()[-200:]}
|
||||||
|
|
||||||
|
|
||||||
def login_checks(ctx, name, pw):
|
def login_checks(ctx, name, pw):
|
||||||
"""The login rules against an account whose password the test knows."""
|
"""The login rules against an account whose password the test knows."""
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
|
|||||||
@@ -44,8 +44,9 @@
|
|||||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||||
umask 077
|
umask 077
|
||||||
|
|
||||||
RECORD=/data/forgefirm/users
|
DATA_DIR=/data/forgefirm
|
||||||
HOMES=/data/forgefirm/home
|
RECORD=$DATA_DIR/users
|
||||||
|
HOMES=$DATA_DIR/home
|
||||||
STATE=/run/forgefirm/accounts
|
STATE=/run/forgefirm/accounts
|
||||||
ACCOUNT_FILES="passwd shadow group gshadow"
|
ACCOUNT_FILES="passwd shadow group gshadow"
|
||||||
LOGIN_SHELL=/bin/sh
|
LOGIN_SHELL=/bin/sh
|
||||||
@@ -140,6 +141,12 @@ replay () {
|
|||||||
# 0755: a login traverses it to reach its home (the umask above is for
|
# 0755: a login traverses it to reach its home (the umask above is for
|
||||||
# the tmpfs state).
|
# the tmpfs state).
|
||||||
[ -d "$HOMES" ] || { mkdir -p "$HOMES" && chmod 0755 "$HOMES"; }
|
[ -d "$HOMES" ] || { mkdir -p "$HOMES" && chmod 0755 "$HOMES"; }
|
||||||
|
# And it walks through the data directory to get there. A data directory
|
||||||
|
# that was made under a strict umask is 0700, and the account cannot
|
||||||
|
# reach its own home. The search bit is put on it whoever made it:
|
||||||
|
# nothing is taken away and nothing in it becomes listable, and what is
|
||||||
|
# private in it is closed file by file.
|
||||||
|
chmod go+x "$DATA_DIR" || log "cannot open $DATA_DIR for search: no account reaches its home"
|
||||||
ACCOUNTS=""
|
ACCOUNTS=""
|
||||||
KEEP=""
|
KEEP=""
|
||||||
while IFS=: read -r name hash uid rest; do
|
while IFS=: read -r name hash uid rest; do
|
||||||
|
|||||||
Reference in New Issue
Block a user