Skip to content

Security: aakri0/ScaleFn

Security

SECURITY.md

Security Policy

This document covers how to report a vulnerability and how the platform's security controls are arranged. If something here is wrong or incomplete, open a PR.

Table of Contents

Reporting a vulnerability

Do not open a public issue for security vulnerabilities.

Contact the maintainer (@aakri0) directly or open a private security advisory on GitHub. Include:

  1. A clear description of the issue and its potential impact.
  2. Steps to reproduce, including any proof-of-concept payloads.
  3. The version or commit SHA where you observed it.
  4. Any suggested mitigations if you have them.

We'll acknowledge within 48 hours and provide an initial assessment within 5 business days. We'll coordinate a disclosure timeline with you before publishing any fix. Responsible disclosures are credited in release notes unless you prefer to stay anonymous.

Supported versions

Security fixes go to the latest commit on main. There are no long-term-support branches. Run the most recent version.

Job sandbox

Every job runs in an isolated, ephemeral Docker container:

Control Setting
Linux capabilities cap_drop: ALL
Privilege escalation no-new-privileges: true
Root filesystem Read-only; writes only under /workspace
Network Disabled by default (network_mode: none)
User Non-root
Memory Capped via mem_limit + memswap_limit
CPU Throttled via nano_cpus
Process count Capped via pids_limit
Stdout Truncated at JOB_MAX_OUTPUT_BYTES (default 512 KB)
Timeout Hard wall-clock limit via JOB_TIMEOUT_SECONDS (default 30 s)

Containers are destroyed on completion regardless of outcome.

auto_install exception: when a Python job is submitted with auto_install: true, network access is temporarily enabled during the pip install phase. All other controls stay in place. This feature is opt-in per submission — don't enable it by default on shared deployments.

Worker hardening

Several controls outside the job container itself reduce blast radius:

  • HOST_DATA_DIR validation — the worker validates the bind-mount path at startup (api/worker.py::_validate_host_data_dir) and refuses to start if it is empty, relative, or resolves to a system root (/, /etc, /usr, /var, /proc, /sys, /dev, …). Path normalization is applied first so /var/.. cannot smuggle / past the check. A misconfigured mount that exposes the host filesystem fails closed loudly, not silently.
  • Atomic workspace staging — per-job workspaces are built into a sibling {job_id}.tmp-<uuid> directory and os.replace'd into the final path only after every copy/write succeeds. A copy that fails mid-way leaves no partial tree for a retry to read. Stale workspaces from previous attempts are wiped on the atomic swap.
  • Write-after-delete protection — every worker write to a job hash goes through _hset_if_alive(), which uses Redis WATCH/MULTI to skip the write if the API has already deleted the key. Without this, a heartbeat or final-status write racing against a delete could resurrect an orphaned hash that was no longer in JOBS_INDEX, leaking memory.
  • vCPU stamping for accounting — the worker writes cpu_nanos onto the job hash on start so cost accounting uses the exact allocation the container ran with, not a guess.

Authentication & authorization

Three auth mechanisms are supported:

Type Header Scope
JWT Authorization: Bearer <jwt> User-scoped, 24 h TTL
Personal Access Token (PAT) X-API-Key: scp_pat_... or Authorization: Bearer scp_pat_... User-scoped, no TTL, self-serve revoke
Service API key X-API-Key: <key> Admin-level, set out-of-band via SCP_API_KEYS

Role-based access separates user and admin. List endpoints (GET /jobs, GET /files, GET /codebases) are always caller-scoped — admins cannot enumerate other users' data via the list endpoints; per-ID access is preserved for moderation.

A last-admin guard prevents any operation that would leave the platform with zero active admins.

Identity persistence

User accounts and personal access tokens are stored in Postgres, not Redis. The data directory is a host bind-mount at ${PG_DATA_DIR:-./data/postgres} rather than a Docker-managed volume.

Cleanup command Postgres data
docker compose down preserved
docker compose down -v preserved (named-volume removal does not touch bind-mounts)
docker volume rm scp_* preserved
docker volume prune / docker system prune --volumes preserved
rm -rf data/postgres destroyed (the only thing that does)

This is deliberate: the typical "blow it all away and start fresh" workflow used to silently delete every user account along with the queue. Identity now lives outside Docker's volume system. Take regular pg_dump backups for true durability and disaster recovery.

The Postgres container requires POSTGRES_PASSWORD. Compose refuses to start without it; start.sh auto-generates a 32-char random password on first run. The DB port is bound to 127.0.0.1 by default — remove the prefix in .env only if you need cross-host access.

A one-shot migration script (scripts/migrate_redis_to_postgres.py) copies pre-v1.3 user/PAT data out of Redis into the new schema. It is idempotent and never deletes the source rows.

Token hygiene

  • PAT secrets are stored as SHA-256 hashes only. The plaintext is returned exactly once at creation.
  • Comparison uses hmac.compare_digest to prevent timing-oracle attacks.
  • Deleting a user cascades to immediate revocation of all their PATs.
  • Users are capped at 20 active PATs.

Upload hardening

The zip extractor blocks:

  • Path traversal (../ or absolute paths)
  • Symbolic links
  • __MACOSX/ metadata entries
  • Archives exceeding SCP_MAX_CODEBASE_ZIP_BYTES (default 100 MB)
  • Archives that extract beyond SCP_MAX_CODEBASE_EXTRACTED_BYTES (default 300 MB) — zip bomb protection
  • Codebases with more than SCP_MAX_CODEBASE_FILES files (default 2,000)

Single-file uploads are capped at SCP_MAX_FILE_BYTES (default 50 MB).

Rate limiting & login lockout

Per-IP rate limits (slowapi):

Endpoint group Default
/submit 60 / minute
File and codebase uploads 30 / minute
/auth/signup, /auth/login 20 / minute

Tune via SCP_RATE_LIMIT_SUBMIT, SCP_RATE_LIMIT_UPLOAD, SCP_RATE_LIMIT_AUTH.

Per-username lockout stacks on top: 6 failed logins within 300 s → 15-minute lock, 429 with Retry-After. A successful login resets the counter. Tune via SCP_LOGIN_LOCKOUT_THRESHOLD, SCP_LOGIN_LOCKOUT_WINDOW, SCP_LOGIN_LOCKOUT_DURATION. Lockout events increment login_lockouts_total and can be alerted on.

Audit trail

Every admin-side user or token change is appended as a signed, hash-chained entry to an append-only JSONL file ({SCP_DATA_DIR}/audit.log). Each entry's HMAC is computed over its fields plus the previous entry's HMAC, so modifying any past entry breaks its signature and every subsequent entry in the chain. Deleting a row leaves a sequence gap; reordering rows breaks both the sequence and the chain link.

Set SCP_AUDIT_SIGNING_KEY to a stable random string of ≥ 32 characters. If unset, an ephemeral key is generated — verification will fail across restarts.

SCP_AUDIT_SIGNING_KEY=$(openssl rand -hex 32)

Verify the chain at any time:

curl -s http://localhost:8000/auth/audit/verify -H "X-API-Key: $ADMIN_KEY"
# => {"chain_ok": true, "entries": 1234, "first_bad_seq": null, "reason": null}

Transport security

The --tls profile starts nginx with TLS 1.2+, strong ciphers, and HSTS. A self-signed cert is generated automatically for local use — replace nginx/certs/ with a CA-signed certificate for any production or shared deployment. Without --tls, traffic is unencrypted; don't expose an HTTP-only instance to untrusted networks.

Secrets

Secret Guidance
SCP_JWT_SECRET ≥ 32 chars. Generated by start.sh on first run. Persist it — rotating invalidates all active JWTs.
SCP_AUDIT_SIGNING_KEY ≥ 32 chars. Rotating invalidates all previously signed audit entries.
SCP_API_KEYS High-privilege. Prefer SCP_API_KEYS_FILE over an environment variable for better secret hygiene.
POSTGRES_PASSWORD Required. Compose refuses to start without it. start.sh auto-generates one (32 chars, base64) on first run.
REDIS_PASSWORD Always set in non-local deployments.
SCP_ADMIN_PASSWORD ≥ 8 chars. Use a strong, randomly generated value in production. Whitespace is stripped before use; the API logs a warning if the env value had leading/trailing spaces.

Never commit .env to version control. The repository's .gitignore excludes it (and data/postgres/) by default.

Known trade-offs

  • auto_install — enables PyPI network egress and installs arbitrary third-party packages. Don't enable it by default on shared multi-tenant deployments.
  • JOB_NETWORK_MODE=bridge — grants jobs outbound internet access. All other sandbox controls remain, but SSRF and data exfiltration risk increases.
  • Self-signed TLS — suitable for local development only. Replace with a CA-signed cert before exposing to users.
  • Ephemeral audit signing key — if SCP_AUDIT_SIGNING_KEY is not persisted, the audit log cannot be verified across restarts.
  • Single-host Postgres — the bundled DB runs as a single container with a host bind-mount. Suitable for small deployments and dev/staging. For HA / point-in-time recovery, point SCP_DB_URL at a managed Postgres and disable the bundled postgres service (or accept it as a primary and replicate downstream).
  • No schema migration tool yetapi/db.py uses SQLAlchemy metadata.create_all() which is idempotent for additive changes only. Non-additive schema changes need Alembic added before they ship.
  • Redis-backed queue and audit cache — the job queue, lockout counters, and audit cache still live in Redis. Loss of Redis loses queued work but not user identity. Enable REDIS_PASSWORD and AOF persistence in any deployment that runs real workloads.

Operator checklist

Before exposing an instance to untrusted users or networks:

  • TLS enabled — ./start.sh --tls with a CA-signed cert in nginx/certs/.
  • SCP_JWT_SECRET set and persisted in .env.
  • SCP_AUDIT_SIGNING_KEY set to a stable ≥ 32-char value.
  • POSTGRES_PASSWORD set to a strong random value (≥ 24 chars). start.sh does this automatically on first run; verify the value in .env.
  • REDIS_PASSWORD set — never leave Redis unauthenticated on a networked host.
  • SCP_ADMIN_PASSWORD is a strong, randomly generated value (≥ 12 chars). No leading/trailing spaces.
  • HOST_DATA_DIR points at a dedicated path (e.g. /var/lib/scp/data), not a system root. The worker validates this at startup but operators should pick a deliberate path.
  • SCP_ALLOW_SIGNUP reviewed — set to false for invite-only deployments.
  • JOB_NETWORK_MODE left as none unless jobs explicitly need outbound access.
  • auto_install not enabled by default on shared instances.
  • Prometheus and Grafana access restricted — set GRAFANA_ANON=false in production.
  • Audit log rotation configured — the on-disk log is append-only and unbounded.
  • pg_dump backups scheduled. The bind-mount survives every Docker cleanup but a host disk failure or rm -rf still loses everything. Backup to off-host storage at least daily for any real deployment.
  • Redis volume included in backups (queue + lockouts + audit cache).
  • Postgres port (POSTGRES_PORT) bound to localhost or the internal network only — the default 127.0.0.1:5432 is safe.
  • Alerts in monitoring/alerts.yml wired to an on-call channel.

There aren't any published security advisories