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.
- Reporting a vulnerability
- Supported versions
- Job sandbox
- Worker hardening
- Authentication & authorization
- Identity persistence
- Token hygiene
- Upload hardening
- Rate limiting & login lockout
- Audit trail
- Transport security
- Secrets
- Known trade-offs
- Operator checklist
Do not open a public issue for security vulnerabilities.
Contact the maintainer (@aakri0) directly or open a private security advisory on GitHub. Include:
- A clear description of the issue and its potential impact.
- Steps to reproduce, including any proof-of-concept payloads.
- The version or commit SHA where you observed it.
- 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.
Security fixes go to the latest commit on main. There are no long-term-support branches. Run the most recent version.
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.
Several controls outside the job container itself reduce blast radius:
HOST_DATA_DIRvalidation — 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 andos.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 RedisWATCH/MULTIto 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 inJOBS_INDEX, leaking memory. - vCPU stamping for accounting — the worker writes
cpu_nanosonto the job hash on start so cost accounting uses the exact allocation the container ran with, not a guess.
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.
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.
- PAT secrets are stored as SHA-256 hashes only. The plaintext is returned exactly once at creation.
- Comparison uses
hmac.compare_digestto prevent timing-oracle attacks. - Deleting a user cascades to immediate revocation of all their PATs.
- Users are capped at 20 active PATs.
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_FILESfiles (default 2,000)
Single-file uploads are capped at SCP_MAX_FILE_BYTES (default 50 MB).
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.
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}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.
| 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.
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_KEYis 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_URLat a managed Postgres and disable the bundledpostgresservice (or accept it as a primary and replicate downstream). - No schema migration tool yet —
api/db.pyuses SQLAlchemymetadata.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_PASSWORDand AOF persistence in any deployment that runs real workloads.
Before exposing an instance to untrusted users or networks:
- TLS enabled —
./start.sh --tlswith a CA-signed cert innginx/certs/. -
SCP_JWT_SECRETset and persisted in.env. -
SCP_AUDIT_SIGNING_KEYset to a stable ≥ 32-char value. -
POSTGRES_PASSWORDset to a strong random value (≥ 24 chars).start.shdoes this automatically on first run; verify the value in.env. -
REDIS_PASSWORDset — never leave Redis unauthenticated on a networked host. -
SCP_ADMIN_PASSWORDis a strong, randomly generated value (≥ 12 chars). No leading/trailing spaces. -
HOST_DATA_DIRpoints 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_SIGNUPreviewed — set tofalsefor invite-only deployments. -
JOB_NETWORK_MODEleft asnoneunless jobs explicitly need outbound access. -
auto_installnot enabled by default on shared instances. - Prometheus and Grafana access restricted — set
GRAFANA_ANON=falsein production. - Audit log rotation configured — the on-disk log is append-only and unbounded.
-
pg_dumpbackups scheduled. The bind-mount survives every Docker cleanup but a host disk failure orrm -rfstill 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 default127.0.0.1:5432is safe. - Alerts in
monitoring/alerts.ymlwired to an on-call channel.