ContentForge is self-hosted by design. The threat model and the controls follow.
The dominant risk is leakage of the user's Anthropic API key. The key is worth real money to an attacker — both as spend on the user's account and as exfiltration of capability.
Out of scope:
- An attacker with root on the user's box. (Game over for any self-hosted tool.)
- Side-channel attacks against the Postgres host process.
- Anthropic-side abuse of the key. ContentForge does not — and cannot — enforce policies on api.anthropic.com.
In scope:
- An attacker who obtains a Postgres dump or a backup of the data volume must not be able to recover the API key.
- The key must never appear in container logs, exception traces, or HTTP responses.
- Default deployment should bind only to
localhost; remote exposure is the operator's call and risk.
- Algorithm: AES-256-GCM, 96-bit IV, 128-bit auth tag.
- Key derivation: PBKDF2-HMAC-SHA256, 100,000 iterations, salt is
contentforge-v{key_version}. - Master key:
ENCRYPTION_KEYenv var, 64 hex characters (32 bytes), generated by the operator withopenssl rand -hex 32. - Storage layout:
BYTEAcolumn =IV (12) || ciphertext || tag (16). - Decryption: only happens in worker process memory at the moment of an Anthropic API call. Never logged, never returned in HTTP responses, never re-emitted.
The plaintext is held in memory for the lifetime of one outbound call. There is no persistence of the decrypted key beyond the heap of the calling Node process.
# In your .env
ENCRYPTION_KEY_OLD=<your current 64-hex value>
ENCRYPTION_KEY=<your new 64-hex value>
docker compose run --rm app node src/scripts/rotate-key.jsThe script re-encrypts every user_secrets row from old to new and bumps key_version. After it succeeds, remove ENCRYPTION_KEY_OLD from .env. The script is idempotent — safe to re-run if the previous run was interrupted.
- Algorithm: bcrypt, cost 12.
- Reset: there is no email reset path. Run:
docker compose run --rm app node src/scripts/reset-password.js <new-password>
httpOnlycookies;sameSite=lax;secureonly whenBEHIND_TLS=true.- Server-side session storage in Postgres (
connect-pg-simple), so adocker compose downdoes not log everyone out. - Default cookie name:
cf.sid. Default lifetime: 7 days.
- Streamed to disk via
busboy. Hard cap 2 GB. - Filename sanitization: extension whitelist (
.mp4,.mov,.mkv,.webm); on-disk filename is server-controlled (source<ext>), not user-controlled. - Source files live under
/data/uploads/<jobId>/; the directory is per-job and the job id is a server-generated UUID.
The only host ContentForge calls is api.anthropic.com. There is no telemetry. There is no phone-home. There is no analytics endpoint. If you put the deployment in an egress-controlled environment, allow only that host.
- The dashboard binds to
0.0.0.0:3000inside the container, mapped to127.0.0.1:${APP_PORT}on the host by the defaultdocker-compose.yml. - Exposure on a LAN is the operator's choice. Use a reverse proxy with TLS and access control if you do this. Set
BEHIND_TLS=trueso cookies are markedSecure.
All queries are parameterized via pg. There are no string-interpolated query bodies. Schema is version-controlled in db/migrations/ and applied at app boot.
- Both
appandworkerrun as the non-rootnodeuser. - The worker container additionally applies
nice -n 19 ionice -c2 -n7viaentrypoint.shto avoid starving other processes on shared boxes.
Outside readers are explicitly invited to audit the encryption surface. The relevant files:
app/src/lib/encryption.js— KDF + AEAD primitives.worker/src/lib/encryption.js— same logic, copy-pasted because the surface is small.app/src/routes/auth.js— wizard "test connection" + storage path.worker/src/lib/repo.js—getDecryptedAnthropicKeydecryption call site.app/test/encryption.test.js— round-trip + tamper tests.
If you find a problem, please open a private security advisory on GitHub rather than a public issue.
- No password reset by email. The
reset-password.jsscript requires shell access to the box. - No 2FA for the dashboard. Single-user, self-hosted; out of scope for v1.
- The
ENCRYPTION_KEYlives in the same.envasPOSTGRES_PASSWORD. An attacker who reads.envreads both. Hardening that means a separate KMS, which is v0.2 territory at best. - Cookies are
sameSite=lax, notstrict. We accept the small CSRF surface in exchange for working "click a link" flows.
Open a GitHub security advisory or email the maintainer. Please don't open public issues for security problems until a fix is available.