Skip to content

Fix tenant isolation, default credentials, and resource limits - #1

Merged
MarcosProWork merged 1 commit into
masterfrom
security/tenant-isolation-and-hardening
Aug 7, 2026
Merged

Fix tenant isolation, default credentials, and resource limits#1
MarcosProWork merged 1 commit into
masterfrom
security/tenant-isolation-and-hardening

Conversation

@MarcosProWork

@MarcosProWork MarcosProWork commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Hardening pass across the client portal, reseller scoping, and credential handling, with a regression test for each invariant and a load/resilience harness.

Maintainers: I've prepared a detailed writeup of the issues behind these changes and can send it privately — the repo has no SECURITY.md or private reporting enabled, so please let me know the best contact. Happy to coordinate an advisory and hold further detail until you've cut a release.

Client portal

The portal is reachable by hosting customers while the panel process runs as root, so the boundaries below are the ones separating a tenant from the rest of the box.

  • Path containment now resolves symlinks. Tenant-supplied paths went through a string-prefix check only, while the admin file manager already used assertSafeFileTarget. Since a tenant can write symlinks anywhere under their own docroot, the prefix check alone doesn't hold. Routed the file manager, uploads, error pages and the .htaccess editor through the same helper.
  • Account-namespaced names resolve against all accounts, longest prefix wins. Ownership was decided by asking whether a name started with one of the caller's usernames plus _. Usernames may contain underscores, so alice and alice_shop can belong to different customers and that test is ambiguous.
  • SSH key writes lstat each path component before writing authorized_keys, and reject multi-line keys.
  • Feature-list mapping is one-to-one. Eight catalog toggles (cron, ssh-keys, scripts, subdomains, redirects, error-pages, htpasswd, ssl-advanced) were all mapped to file-manager, which made them inert. Unmapped portal routes now fail closed instead of skipping the gate.
  • The feature gate no longer treats req.params.id as an account id. On portal routes :id is an invoice / spam-rule / autoresponder id, so the check was being evaluated against an unrelated account.

Reseller scoping

enforceResellerPrivilege answers which feature a reseller may use, never which accounts, so /api/accounts routes operated across the whole server. accounts.reseller_id already existed but was only read by the summary view.

  • Every /api/accounts route is scoped on reseller_id; out-of-scope ids answer 404 rather than confirming they exist.
  • Reseller-created accounts are stamped with the creator's id, and resellers.alloc_accounts is enforced.
  • A reseller with no reseller_privileges row defaults to a customer-facing feature set rather than every key.
  • Settings writes are admin-only regardless of feature grants — that table holds installation-wide Stripe/PayPal/SMTP credentials.

Credentials and limits

  • Removed the ADMIN_PASS_HASH || bcrypt.hashSync('changeme', 10) fallback from admin login, /change-password and security-extra, and the equivalent password defaults in WordPress installs and mailing lists. An explicit password is now required. The admin path also ran a blocking hash per request, which stalls the event loop under a login burst.
  • Production refuses to start without a configured admin, instead of warning and continuing.
  • Bind to loopback by default. install.sh:687 already states the process "binds to 127.0.0.1:3001 only", but listen(PORT) with no host binds all interfaces.
  • Trust a single proxy hop rather than any loopback peer, so a client-supplied X-Forwarded-For can't displace the peer address the rate limiter keys on.
  • Cap concurrent terminal sessions and per-message input — WebSocket upgrades never pass through the HTTP rate limiter. Dropped the ?token= fallback so JWTs stay out of access logs.

Tests, CI, and one unrelated fix

  • pm2 is now looked up on PATH instead of /usr/local/bin and /usr/bin, which reported "pm2 is not installed" on any box using nvm or /opt.
  • The suite wrote to /usr/local/bin, /var/backups and /var/lib, so five tests passed only on the CI runner and failed for contributors. Now hermetic.
Before After
Tests 174 pass, 5 fail 198 pass, 0 fail
npm audit prod / all exit 1 (6 / 10 advisories) 0 vulnerabilities
Stress scenarios 9/9

server/src/stress/ adds a load and resilience harness (npm run stress) with its own CI job. Each scenario asserts a threshold rather than printing numbers, so regressions fail the build.

Review notes

  • DEFAULT_RESELLER_FEATURES in feature-lists.ts is my best guess at what a reseller needs — worth confirming it matches how you actually sell.
  • Existing installs need a backfill. accounts.reseller_id is NULL on current rows, so resellers see nothing until it's populated. That migration is deliberately not in this PR.
  • Breaking for API clients relying on the old defaults. WordPress installs and mailing-list creation now return 400 without an explicit password rather than provisioning a known one. Generating a random password and returning it is a reasonable alternative if you'd prefer.

Client portal (reachable by hosting customers, panel runs as root):

- Resolve tenant-supplied file paths through assertSafeFileTarget so a
  symlink planted in a docroot cannot redirect a read or write outside it.
  Applies to the file manager, uploads, error pages and the .htaccess editor.
- Resolve account-namespaced names against every account with longest-prefix
  wins. Usernames may contain underscores, so `alice` and `alice_shop` can
  belong to different customers and prefix matching alone crossed tenants.
- lstat the home and .ssh directories before writing authorized_keys, and
  reject multi-line keys.
- Map each portal route to the feature-catalog key it belongs to instead of
  collapsing eight of them onto file-manager, and fail closed on unmapped
  routes. Stop treating req.params.id as an account id in the feature gate.

Reseller tenancy:

- Scope every /api/accounts route on accounts.reseller_id; out-of-scope ids
  answer 404. Stamp reseller-created accounts and enforce alloc_accounts.
- Default a reseller with no reseller_privileges row to a customer-facing
  feature set rather than every key, and restrict settings writes to admins.

Credentials and limits:

- Remove the built-in 'changeme' fallbacks from admin login, change-password,
  security-extra, WordPress installs and mailing lists; require an explicit
  password instead. The admin one also ran a blocking bcrypt hash per request,
  which stalled the event loop under an unauthenticated login flood.
- Refuse to start in production without a configured admin.
- Bind to loopback by default and trust a single proxy hop, so a local client
  cannot forge X-Forwarded-For past the login rate limit.
- Cap concurrent terminal sessions and per-message input; drop the ?token=
  query fallback that put a JWT in access logs.

Also: look pm2 up on PATH instead of two fixed directories, make the test
suite hermetic (no writes to /usr/local/bin, /var/backups or /var/lib), and
upgrade dependencies so both audit gates pass.

Adds regression tests for each boundary above and a stress harness
(npm run stress) wired into CI.
@MarcosProWork
MarcosProWork merged commit 814dd59 into master Aug 7, 2026
2 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.

1 participant