Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/backend-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ jobs:
# and ops/egress_firewall.py consume REDIS_URL, and the firewall
# self-test checks host:port reachability only, DB-agnostic).
echo "Validating full boot (root-init + read-only rootfs + /health/) on the new image (pre-cutover)..."
podman run --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,uid=1001,gid=1001 --tmpfs=/home/fingpt:rw,uid=1001,gid=1001 --tmpfs=/app/runtime:rw,size=512m --memory=1.7g --memory-swap=2g --network fingpt-net \
podman run --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,mode=0755 --tmpfs=/home/fingpt:rw,mode=0755 --tmpfs=/app/runtime:rw,size=512m --memory=1.7g --memory-swap=2g --network fingpt-net \
--env-file /home/deploy/fingpt/envs/.env.production \
--env REDIS_URL=redis://fingpt-redis:6379/15 \
--env BOOT_CHECK_ONLY=1 \
Expand Down Expand Up @@ -287,10 +287,16 @@ jobs:
# (--disable-dev-shm-usage), nft mktemp at root-init,
# /tmp/fingpt_cache; sized + world-writable-sticky;
# /app/staticfiles collectstatic writes it at boot (0 files today, kept
# writable as future-proofing); uid1001 tmpfs;
# writable as future-proofing); mode=0755 tmpfs,
# chowned to fingpt by root-init (see below);
# /home/fingpt fontconfig cache, edgartools ~/.edgar import-time
# marker, yfinance cache; MUST carry uid=1001,gid=1001
# or the MCP-child EACCES bug (#331 class) returns.
# marker, yfinance cache; MUST end up fingpt-owned or
# the MCP-child EACCES bug (#331 class) returns. podman
# 5.6.2 rejects tmpfs uid=/gid= mount options outright,
# so ownership is NOT set here -- entrypoint.sh root-init
# chowns both dirs while PID1 still holds CAP_CHOWN.
# mode=0755 (not the tmpfs default 1777) lands them
# owner-writable, not world-writable.
# /app/logs and /app/media are vestigial with ZERO writers -- deliberately
# NOT tmpfs, so a future stray write fails LOUDLY instead of vanishing
# into RAM. Playwright's DEPENDENCIES_VALIDATED marker is pre-baked at
Expand All @@ -307,7 +313,7 @@ jobs:
cat > "$OVERRIDE_DIR/override.conf" <<EOF
[Service]
ExecStart=
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,uid=1001,gid=1001 --tmpfs=/home/fingpt:rw,uid=1001,gid=1001 --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,mode=0755 --tmpfs=/home/fingpt:rw,mode=0755 --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
EOF

systemctl --user daemon-reload
Expand Down
9 changes: 9 additions & 0 deletions Main/backend/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ if [ "$(id -u)" = "0" ]; then
echo "SSRF egress firewall loaded and self-tested."
# :U chowned the runtime mount to root (PID1 is root); hand it to the app user.
chown -R fingpt:fingpt /app/runtime
# Under --read-only rootfs (#333), /home/fingpt and /app/staticfiles are fresh
# tmpfs mounts that MASK the image's build-time ownership and come up root-owned.
# The droplet's podman (5.6.2) has no tmpfs uid= option to fix that at mount time
# -- it rejects `--tmpfs=...:uid=1001` outright (see backend-deploy.yml and
# test_dockerfile_nonroot.test_tmpfs_options_pinned) -- so hand them to the app
# user HERE, while PID1 still holds CAP_CHOWN. Non-recursive: both are freshly
# mounted empty tmpfs. Without this, uid1001 (and every MCP stdio child writing
# $HOME) hits EACCES -- the #331 class the BOOT_CHECK_ONLY gate exists to catch.
chown fingpt:fingpt /home/fingpt /app/staticfiles
# Marker (env survives setpriv) so the app phase can refuse to serve if it was ever
# reached WITHOUT this root-init firewall load (e.g. a mistaken non-root PID1 start).
# A MISTAKE-GUARD ONLY, not a security boundary: trivially spoofable via -e/--env-file,
Expand Down
47 changes: 41 additions & 6 deletions Main/backend/tests/test_dockerfile_nonroot.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,31 @@ def test_runtime_user_has_writable_home(self):
chown = next(l for l in self.lines if "chown -R fingpt:fingpt" in l)
self.assertIn("/home/fingpt", chown)

def test_entrypoint_chowns_read_only_tmpfs_dirs(self):
# Under the #333 --read-only rootfs, /home/fingpt and /app/staticfiles are
# fresh tmpfs mounts that MASK the image's build-time ownership, so they
# come up root-owned. podman 5.6.2 has no tmpfs uid= option to fix that at
# mount time (test_tmpfs_options_pinned), so the root-init phase must chown
# them to fingpt itself -- before the setpriv drop, while it still holds
# CAP_CHOWN as root-in-userns. Without it uid1001 cannot write $HOME and the
# sec-edgar MCP child dies with EACCES (the #331 class the BOOT_CHECK_ONLY
# gate exists to catch).
lines = _read(ENTRYPOINT_SH).splitlines()
start = next(i for i, l in enumerate(lines) if "id -u" in l and "then" in l)
end = next(i for i in range(start + 1, len(lines)) if lines[i].strip() == "fi")
root_init_chowns = [
l for l in lines[start + 1:end]
if "chown" in l and not l.strip().startswith("#")
]
joined = " ".join(root_init_chowns)
for d in ("/home/fingpt", "/app/staticfiles"):
self.assertIn(d, joined, f"root-init must chown {d} (read-only tmpfs ownership)")
# Every chown must precede the setpriv drop, or it runs as uid1001 and EPERMs.
setpriv_idx = next(i for i, l in enumerate(lines) if l.lstrip().startswith("exec setpriv"))
for i, l in enumerate(lines):
if "chown" in l and not l.strip().startswith("#"):
self.assertLess(i, setpriv_idx, f"chown on line {i} must precede setpriv")

def test_playwright_dependencies_marker_prebaked_for_read_only_rootfs(self):
# Playwright writes a 0-byte DEPENDENCIES_VALIDATED marker next to the
# browser executable at FIRST launch (host-requirements validation cache).
Expand Down Expand Up @@ -461,14 +486,24 @@ def test_tmpfs_target_sets_frozen(self):

def test_tmpfs_options_pinned(self):
# /tmp must be sized (unbounded tmpfs is a memory-DoS surface) and
# world-writable-sticky (1777: Chromium/nft/cache all write it as
# uid1001); the uid1001 dirs must carry uid=1001,gid=1001 -- a bare tmpfs
# mounts root-owned and resurrects the EACCES class documented at the
# Dockerfile HOME comment.
# world-writable-sticky (1777: Chromium/nft/cache all write it as uid1001).
#
# The uid1001 dirs (/home/fingpt, /app/staticfiles) must NOT carry
# uid=/gid= tmpfs mount options: the droplet's podman (5.6.2) rejects them
# outright -- `Error: unknown mount option "uid=1001": invalid mount
# option` -- which fail-closed the #333 pre-cutover gate on the very first
# push-to-main deploy (build+test were green because nothing here ever runs
# podman). A tmpfs always mounts root-owned, so ownership is instead
# restored inside the container by the root-init chown
# (test_entrypoint_chowns_read_only_tmpfs_dirs); mode=0755 lands them
# owner-writable (not world-writable) once chowned to fingpt.
for line in (self._execstart_line(), self._gate_line()):
self.assertIn("--tmpfs=/tmp:rw,size=512m,mode=1777", line)
self.assertIn("--tmpfs=/app/staticfiles:rw,uid=1001,gid=1001", line)
self.assertIn("--tmpfs=/home/fingpt:rw,uid=1001,gid=1001", line)
self.assertIn("--tmpfs=/app/staticfiles:rw,mode=0755", line)
self.assertIn("--tmpfs=/home/fingpt:rw,mode=0755", line)
# podman 5.6.2 rejects tmpfs uid=/gid=; never reintroduce them.
self.assertNotIn("uid=1001", line)
self.assertNotIn("gid=1001", line)
self.assertIn("--tmpfs=/app/runtime:rw,size=512m", self._gate_line())

def test_execstart_read_only_rootfs(self):
Expand Down
Loading