Conversation
- install_profile_service: record what install.sh put on the box (minimal/standard/full), recommended from detected hardware and overridable; thresholds mirrored by recommend_profile in install.sh - setup wizard: replace the hard ResourceGate/SetupStepTier with SetupStepCapacity + SetupStepSecurity - ResourceAdvisory: inline capacity warning at the point of action (WordPress/Docker/Services/Deployments), backed by headroom.fits - RequiresDocker component for docker-dependent surfaces - install.sh/update.sh: profile detection + tests (test_install.sh, test_update.sh, test_server_capacity.py)
…-rm3m-9mvw-68fh) The 1.7.68 RBAC fix enforced files.read on read endpoints, but the default viewer role legitimately has files.read=true, and on the documented install.sh layout the panel install dir (/opt/serverkit) sits under the allowed root /opt. A viewer could read the backend .env, steal JWT_SECRET_KEY / SERVERKIT_ENCRYPTION_KEY, and forge admin sessions. FileService now carries PROTECTED_ROOTS (the panel install dir and SERVERKIT_CONFIG_DIR), checked inside is_path_allowed before the ALLOWED_ROOTS test, so every file-manager operation (read, write, browse, download, upload, ...) refuses panel-internal paths for every role. Regression tests: protected paths rejected by is_path_allowed; viewer read/download of the backend .env returns 403; allowed roots keep working. Co-authored-by: CaptBoykin <CaptBoykin@users.noreply.github.com>
New builtin job security-feed (daily) pulls the normalized advisory feed from serverkit.ai/api/security-feed.json (a cached proxy of the repo's published GitHub Security Advisories) and: - notifies admins (security.alert, critical) when the running panel version falls inside an advisory's affected range, once per advisory; - fires a one-time post-fix reminder after an upgrade crosses a fix boundary when the advisory declares a "## Post-upgrade actions" section (e.g. rotate JWT_SECRET_KEY after GHSA-rm3m-9mvw-68fh). Dedupe keys, the last-good feed copy, and the previously-seen version live in SystemSettings, so restarts never re-notify and outbound failures serve the cached feed. Opt out via the security_feed.enabled setting. Privacy page on serverkit.ai updated to disclose the daily call.
There was a problem hiding this comment.
🟡 Not ready to approve
The file-manager security fix still has a path-calculation + allowed-root prefix issue that can leave panel-internal paths reachable.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR replaces the old “resource tier” hard-gating with install profiles + live capacity headroom, and ships two security-oriented improvements: file-manager path protection for panel-internal secrets and a daily GHSA advisory-feed check that notifies admins when the running version is affected.
Changes:
- Add installer/profile plumbing (
minimal/standard/full) and make updates/doctor behavior profile-aware (notably allowing updates on minimal without Docker). - Rework capacity reporting around live “headroom” (available memory) and convert frontend UX from blocking gates to inline advisories / Docker requirement explainers.
- Add security feed checking as a builtin job and expand file-manager restrictions to block panel-internal paths.
File summaries
| File | Description |
|---|---|
| VERSION | Bumps panel version to 1.7.77. |
| scripts/update.sh | Makes Docker a conditional preflight dependency based on install profile. |
| scripts/test/test_update.sh | Adds unit coverage for profile-driven Docker preflight logic. |
| scripts/test/test_install.sh | Adds unit coverage for installer profile recommendation + skip/provision behavior. |
| install.sh | Adds profile prompting/recommendation, Docker skipping for minimal, and hardening provisioning for full. |
| frontend/src/styles/pages/_wordpress.scss | Replaces blocked “resource gate” styling with inline “resource advisory” styling. |
| frontend/src/styles/pages/_setup-wizard.scss | Adds styles for new capacity + security (2FA) wizard steps. |
| frontend/src/styles/main.scss | Imports the new requires-docker component stylesheet. |
| frontend/src/styles/components/_requires-docker.scss | Adds styling for Docker-missing explainer view. |
| frontend/src/pages/WordPress.jsx | Removes hard gate and adds inline capacity advisory at point of action. |
| frontend/src/pages/Setup.jsx | Expands wizard to 5 steps (adds Capacity + Security steps). |
| frontend/src/pages/Services.jsx | Wraps page with RequiresDocker to explain Dockerless installs. |
| frontend/src/pages/Docker.jsx | Wraps page with RequiresDocker to explain Dockerless installs. |
| frontend/src/pages/Deployments.jsx | Wraps page with RequiresDocker to explain Dockerless installs. |
| frontend/src/contexts/ResourceTierContext.jsx | Switches to admin-only /system/capacity and exposes headroom/profile/capabilities fields. |
| frontend/src/components/setup/SetupStepTier.jsx | Removes the old tier-based wizard step. |
| frontend/src/components/setup/SetupStepSummary.jsx | Updates summary to report profile + headroom + 2FA state. |
| frontend/src/components/setup/SetupStepSecurity.jsx | Adds optional 2FA enrollment flow with backup codes during setup. |
| frontend/src/components/setup/SetupStepCapacity.jsx | Adds capacity/profile confirmation step driven by backend profile descriptions + headroom. |
| frontend/src/components/ResourceGate.jsx | Deletes the old blocking resource gate component. |
| frontend/src/components/ResourceAdvisory.jsx | Adds a non-blocking inline capacity warning component. |
| frontend/src/components/RequiresDocker.jsx | Adds a Docker-missing explainer wrapper for container-dependent pages. |
| docs/INSTALLATION.md | Documents install profiles, env knobs, and minimal-install behavior changes. |
| backend/tests/test_server_capacity.py | Adds backend test coverage for headroom, advisory flags, profiles, endpoints, and doctor behavior. |
| backend/tests/test_security_feed.py | Adds backend test coverage for advisory feed range matching + notifications + dedupe. |
| backend/tests/test_jobs.py | Updates builtin schedule expectations to include the security feed job. |
| backend/tests/test_files_rbac.py | Adds regression tests ensuring panel-internal paths are blocked via file manager endpoints. |
| backend/app/services/security_feed_service.py | Implements GHSA advisory feed fetch/caching + affected/post-fix notification logic. |
| backend/app/services/resource_tier_service.py | Reworks tier service to include live headroom + advisory-only feature flags. |
| backend/app/services/install_profile_service.py | Adds install profile resolution, recommendation, capabilities probe, and drift reporting. |
| backend/app/services/fleet_doctor_service.py | Documents why fleet doctor remains profile-unaware. |
| backend/app/services/file_service.py | Adds PROTECTED_ROOTS exclusion to prevent panel-internal file access. |
| backend/app/services/doctor_service.py | Skips Docker checks only when minimal profile + Docker absent; otherwise probes as drift. |
| backend/app/jobs/builtin_handlers.py | Registers and runs the daily security feed check as a builtin job. |
| backend/app/api/system.py | Adds admin-only /system/capacity and /system/capacity/profile endpoints. |
Review details
Suppressed comments (1)
backend/app/services/file_service.py:72
- The allowed-root check uses
real_path.startswith(root), which is vulnerable to prefix collisions (e.g./optmalicious/...will match the allowed root/opt). Since this method gates file manager access, it should use the samereal_path == root or real_path.startswith(root + os.sep)pattern used forPROTECTED_ROOTS.
real_path = os.path.realpath(path)
if any(real_path == root or real_path.startswith(root + os.sep)
for root in cls.PROTECTED_ROOTS):
return False
return any(real_path.startswith(root) for root in cls.ALLOWED_ROOTS)
- Files reviewed: 35/35 changed files
- Comments generated: 1
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| _BACKEND_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) | ||
| _INSTALL_DIR = os.path.realpath(os.path.join(_BACKEND_DIR, '..')) | ||
| PROTECTED_ROOTS = [ |
Copilot review on the GHSA-rm3m-9mvw-68fh patch caught two issues: - _BACKEND_DIR/_INSTALL_DIR were one level too shallow (backend/app and backend/ instead of backend/ and the install root), which left <install>/.env and frontend/dist reachable on the deployed layout — the exact PoC path from the advisory. _BACKEND_DIR stays in PROTECTED_ROOTS so flat layouts (code at /app) are covered too. - The ALLOWED_ROOTS check used a bare startswith, so e.g. /optfoo matched the /opt root. Both checks now require an exact match or a path-boundary prefix. New tests pin the level math (the existing cases computed the expected layout independently and passed on Windows for the wrong reason) and simulate the deployed layout with the install dir inside an allowed root.
The old resource tier looked at a 1-core VPS and told its owner they weren't allowed to create a WordPress site — a strange thing for a self-hosted panel to say to someone about their own hardware. This PR retires that gate and splits it into two questions worth answering separately: what did the installer actually put on this box (the install profile), and how much room is left on it right now (live headroom computed from available memory, not installed memory).
install.shnow measures the machine and offersminimal/standard/full— minimal skips Docker entirely, so 512MB boxes and unprivileged LXC guests get a panel that works instead of one that installs cleanly and then OOMs on the first deploy — and the container-dependent pages explain the absence instead of failing at click time. Two security changes ride along: the file manager now refuses panel-internal paths, because on the documented/optlayout a default viewer role holdingfiles.readcould walk into the backend.envand leave withJWT_SECRET_KEY(GHSA-rm3m-9mvw-68fh); and a new daily job checks the published advisory feed and tells admins when the running version sits inside a known affected range. Thetierlabel survives for callers that display it, but nothing is gated on it any more — every feature flag is advisory now, since a hard block is wrong the moment the VPS gets resized, and it reads like a paywall on an OSS panel besides.Contributors
Highlights
curl | bashinstall takes the suggestion immediately.sudo serverkit updateno longer demands a Docker that was never installed.JWT_SECRET_KEY).Technical changes
Security: file manager path protection (GHSA-rm3m-9mvw-68fh)
FileService.PROTECTED_ROOTSresolves the panel install dir (two levels up fromapp/services/) andpaths.SERVERKIT_CONFIG_DIR, and is checked insideis_path_allowedbefore theALLOWED_ROOTStest — so every operation routed through it (read, write, browse, download, upload) refuses panel-internal paths regardless of role.real_path == root or real_path.startswith(root + os.sep), so a sibling directory sharing a name prefix isn't caught by accident.files.readon read endpoints, but the default viewer role legitimately holdsfiles.read, and on the documentedinstall.shlayout/opt/serverkitsits under the already-allowed root/opt.Security: advisory feed check
app/services/security_feed_service.pypullshttps://serverkit.ai/api/security-feed.json(a cached proxy of the repo's published GitHub Security Advisories) with an 8s timeout.version_in_range()parses GHSA-style ranges (< 1.7.68,>= 1.2.0, < 1.5) with comma-separated clauses ANDed together; an empty or unparseable range matches nothing, so the failure mode is silence rather than a false alarm.{ghsa}:affectedwhen the running version is inside an advisory range, and{ghsa}:postfixwhen the previously-seen version was affected, the current one isn't, and the advisory declares apost_fix_action.SystemSettings, so restarts never re-notify and an outbound failure falls back to the cached feed instead of going quiet.NotificationBusService.send('security.alert', to='admins')with an action path into Settings → System.security_feed.enabledopts out of the outbound call entirely.builtin.security_feedin_BUILTINS(86400s interval, 600s offset);run_security_feed_check()swallows exceptions so a feed problem can't fail the job runner.Install profiles
app/services/install_profile_service.py:recommend_profile()(LXC/OpenVZ → minimal,<1.5GBRAM → minimal,<5GBfree disk → minimal,≥4GB/≥4cores/≥20GB→ full, else standard),get_profile()(settings override →SERVERKIT_PROFILEenv →standard, unrecognised values logged and ignored rather than raised),set_profile(), andget_profile_info()which reports drift between intent and reality in both directions.get_capabilities()probes live and caches for 60s:_docker_usable()runsdocker info --format '{{.ServerVersion}}'instead of trustingshutil.which, because inside LXC the client is frequently present while the daemon has never started.install.shgainsdetect_container()(/.dockerenv,/proc/vzwithout/proc/bc,systemd-detect-virt --container,container=in/proc/1/environ) andrecommend_profile()mirroring the Python thresholds in integer shell arithmetic — the comment on both sides says change one, change the other.prompt_for_profile()runs before any provisioning, honours an explicitSERVERKIT_PROFILE, auto-accepts the recommendation when stdin isn't a tty (withSERVERKIT_FORCE_PROMPT=1as the test hook, matchingprompt_for_domain), and warns when an operator overrides a Minimal recommendation upward.provision_docker()andensure_compose_plugin()early-return on minimal;SERVERKIT_PROFILEis written to.envon fresh installs and rewritten viasedon re-runs, alongsideSERVERKIT_SSL_MODE.provision_hardening()is what makesfullmore thanstandard: installs and enablesfail2ban(without which the panel's jail management manages nothing) andcertbot. Every step is warn-and-continue, and it runs beforeconfigure_nginxso the HTTPS attempt finds certbot already there.scripts/update.sh:install_profile()readsSERVERKIT_PROFILEfrom the active slot's.envand reportsstandardfor anything unrecognised, which is exactly what every pre-profile install is.update_needs_docker()dropsdockeranddocker composefrom the preflight requirement list on minimal installs — unless it's a Docker deployment, or Docker is present anyway.have_docker()is split out as its own function so tests can redefine it; restrictingPATHto hide Docker would also hide the coreutilsinstall_profile()parses with.Capacity reporting
ResourceTierServicereworked aroundget_headroom(), computed from psutil's available memory minusOS_RESERVE_MB(256MB for page cache, sshd, nginx, journald), returning afitsmap againstWORKLOAD_FOOTPRINTS_MBplus warnings for LXC/OpenVZ hosts, low disk, and low-RAM boxes with no swap._describe_headroom()renders the figure as the largest workload that fits, so small boxes degrade to "enough for static sites only" instead of claiming "0 sites"._get_system_specs()now also returnstotal_memory_gb,swap_mb,disk_free_gb(measured on/var/lib/serverkit→/var/lib/docker→/) andcontainer. The wizard had been readingtotal_memory_gband rendering a blank RAM figure for as long as onlyram_gbexisted._get_features_for_tier()is now entirely permissive:wordpress_createis alwaysTrueandwordpress_create_advisedcarries the recommendation.can_create_wordpress()returnsTruefor compatibility; newis_wordpress_advised()exposes the advice.GET /api/v1/system/capacity(tier info + profile info +recommended_profile) andPUT /api/v1/system/capacity/profile./system/resource-tieris left in place for callers that only want the label.DoctorService._expected_services()drops the Docker probe only when it's both unexpected (minimal profile) and absent, so an operator who added Docker later — or a standard install that lost it — still gets probed for real drift; skipped services get an explanatoryokrow, and any exception during profile resolution falls back to probing.fleet_doctor_service.DOCTOR_UNITSis deliberately left profile-unaware, with a comment explaining why: an install profile describes the panel host, and telling a Dockerless agent apart from a stopped-Docker agent needs the agent to report "unit not installed", which is a change in the separateserverkit-agentrepo.Frontend
ResourceGate.jsxdeleted.ResourceAdvisory.jsxreplaces it as an inline warning driven by the backend'sheadroom.fitsmap, rendering nothing when the workload fits and never blocking anything.RequiresDocker.jsxwraps Docker, Services and Deployments. It fails open — when capacity hasn't loaded or the viewer isn't an admin,canHostAppsistrueand children render normally, so a capability probe can never hide a working page.SetupStepTier→SetupStepCapacity: seeded from the installed profile rather than the recommendation, renders profile cards from the backend's ownPROFILE_DESCRIPTIONSso installer and panel can't disagree, and only writes on an actual change (a failed write is swallowed rather than stranding someone mid-wizard).SetupStepSecuritywith offer → enrol → backup-codes stages, plus analreadystage keyed offget2FAStatus()so stepping back and forward doesn't re-offer an enrolment that already landed server-side. Wizard is 4 steps → 5;SetupStepSummarynow reports profile, headroom summary and 2FA state instead of a tier name.ResourceTierContextreads/system/capacityand exposesheadroom,profile,profiles,capabilities,recommendedProfile,profileDrift,canHostAppsandisWordPressAdvised, replacingcanCreateWordPress.components/_requires-docker.scssimported inmain.scss; the.resource-gate*blocks inpages/_wordpress.scssare replaced by the much smaller.resource-advisory;pages/_setup-wizard.scssgains capacity and security step styles.Docs
docs/INSTALLATION.mddocuments the profile table,SERVERKIT_PROFILE/SERVERKIT_PROFILE_TIMEOUT, the "a profile is a starting point, not a licence tier" framing, and what a minimal install changes about updates, Docker-dependent pages and the health doctor.