Skip to content

Close the paths that let strangers run code on the home VM - #5

Merged
dhamsey3 merged 1 commit into
mainfrom
security-hardening
Oct 15, 2026
Merged

dhamsey3 merged 1 commit into
mainfrom
security-hardening

Conversation

@dhamsey3

@dhamsey3 dhamsey3 commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Why

Three defaults combined into one serious hole. Public registration was on. Any registered user could deploy any image. deploy-application.yaml ran that image on the self-hosted runner with --restart unless-stopped and no resource limits. Anyone who could reach the API could therefore run arbitrary containers on the home network.

This PR also fixes a token-forgery path, sandbox containers that were never removed, and cluster-wide Kubernetes access for every logged-in user.

Changes

Who can deploy what

  • Registration is off by default. Create accounts with python -m app.admin create, which now also creates the tables on a fresh database (it used to fail with no such table: users).
  • Only admins deploy images outside the catalog. The catalog moved to services/catalog.py. The policy is checked when an application is registered, again when it's deployed (so applications registered before this change can't slip through), and on the Kubernetes /deployments path. A bad image there now returns 400 or 403 instead of a 500.
  • Every container the deploy workflow starts gets resource limits. That's the application and its PostgreSQL, each with memory, CPU and process limits plus no-new-privileges. Defaults are 512m, 1 CPU and 512 processes, adjustable with the repository variables CONTAINER_MEMORY_LIMIT, CONTAINER_CPU_LIMIT and CONTAINER_PIDS_LIMIT.
  • Kubernetes operations, cluster health and pod logs are admin-only. Before, any logged-in user could create namespaces, services, autoscalers and ingresses anywhere, and read logs from any namespace, including kube-system.

Login tokens

  • Tokens are never signed with a key published in this repo. Outside production, prod and staging, including the default local, an unset SECRET_KEY meant tokens were signed with change-me-in-production. Anyone could then forge a token for user 1. Unset or placeholder keys are now replaced by a random per-process key, so sessions end when the app restarts until a real key is set. Production-like environments still refuse to start without a strong key, and now also reject the .env.example placeholder.

Sandboxes

  • Expired sandboxes are actually removed now. The sweeper ran docker rm inside the API container, which has no Docker CLI or socket. It then marked each sandbox expired anyway, so the containers kept running on the VM. When dispatch is configured, the sweeper now triggers the new Teardown Sandbox workflow on the runner. If that request fails, the sandbox stays active and the next sweep retries.
  • The anonymous demo is capped at MAX_ACTIVE_SANDBOXES. The default is 3, and 0 disables the demo. When all slots are full it answers 429 with Retry-After. A sandbox whose dispatch fails is marked failed, so it doesn't hold a slot.

Workflow input validation

  • Checks written as [[ regex ]] && (( range )) were skipped whenever the regex failed: bash -e ignores a failure that isn't the last command in an && list. For example, HOST_PORT="80; rm -rf x" passed validation. Each check now sits on its own line, in both deploy-application.yaml and sandbox-deploy.yml.

After merging

  1. Set a real SECRET_KEY in the APP_ENV secret (openssl rand -hex 32). If it still contains the .env.example placeholder and ENVIRONMENT is production-like, the new image fails its health check and the deploy job rolls back to the previous container. Otherwise, users are logged out on every restart.
  2. Remove sandbox containers left by older versions: Actions → Teardown Sandbox → Run workflow with deployment_id = all. This also stops any sandbox running at that moment.
  3. If someone needs to deploy a custom image, an admin deploys it, or the image is added to the catalog in services/catalog.py.

Testing

  • 82 tests pass, up from 56; 23 are new in tests/unit/test_security_hardening.py. flake8 is clean using the same command as CI.
  • I reverted the sweeper fix to confirm its new test fails, then restored it.
  • I ran the app over HTTP from a fresh database, with no .env and no SECRET_KEY:
    • Registration returned 403.
    • app.admin create worked.
    • A regular user got 403 for a custom image and 201 for a catalog image; the admin got 201 for a custom image.
    • Kubernetes namespace creation returned 403 for the regular user and 200 for the admin.
    • Pod logs from kube-system returned 403 for the regular user.
  • I ran the workflows' validation blocks as bash -e scripts: they accept valid input and reject HOST_PORT='80;x', a bad memory limit and a JSON-injecting TTL.
  • I ran the teardown steps against a stand-in docker. They remove only that deployment's labelled container, remove every sandbox for all on a manual run, handle "nothing to remove", and refuse all from repository_dispatch as well as a malformed ID.
  • Not tested: the workflows on the real runner. The first real teardown will happen when a sandbox expires after this is deployed.

Next PRs

  • PR 2: a rate limiter that works behind Cloudflare and doesn't block the dashboard, one place that decodes tokens, and read-only GET endpoints.
  • PR 3: one Terraform execution path, a slimmer image, and an honest README without the dead tunnel links.
  • PR 4: Pydantic v2 / FastAPI upgrade, Alembic migrations, timezone-aware datetimes.

Public registration was on by default, any registered user could deploy
any image, and the deploy workflow ran it on the self-hosted runner with
no resource limits. Together, anyone who could reach the API could run
arbitrary containers on the home network.

- Registration is off by default. Accounts are created with
  python -m app.admin create, which now also creates the tables on a
  fresh database.
- Only admins deploy images outside the platform catalog. The catalog
  moved to services/catalog.py; the policy is checked when an
  application is registered, when it is deployed (covering applications
  registered before this change) and on the Kubernetes deployments path,
  which now returns 400/403 instead of a 500 for a bad image.
- Every container the deploy workflow starts gets memory, CPU and
  process limits (repository variables, defaults 512m / 1 / 512) and
  no-new-privileges.
- Tokens are never signed with a key published in this repository.
  Outside production/prod/staging, including the default "local", an
  unset or placeholder SECRET_KEY used to sign tokens with
  "change-me-in-production"; it is now replaced by a random
  per-process key. Production-like environments refuse to start.
- Expired sandboxes were never removed: the sweeper ran docker rm from
  an API container that has no Docker, then marked them expired anyway.
  With dispatch configured it now triggers the new Teardown Sandbox
  workflow on the runner, and a failed request leaves the sandbox
  active so the next sweep retries. A manual run with
  deployment_id=all removes containers leaked by older versions.
- The anonymous sandbox demo is capped at MAX_ACTIVE_SANDBOXES (default
  3, 0 disables it) and answers 429 with Retry-After when full. A
  sandbox whose dispatch fails is marked failed so it frees its slot.
- Kubernetes operations and cluster health/pod logs are admin only.
- Workflow input checks written as "[[ regex ]] && (( range ))" were
  skipped when the regex failed, because bash -e ignores failures
  inside an && list. Each check is now its own line.
@dhamsey3
dhamsey3 merged commit 1a992ec into main Oct 15, 2026
4 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