Skip to content

Security: jqueguiner/openrunner

Security

SECURITY.md

Security

Reporting a vulnerability

Please report security issues privately to the maintainers at security@gladia.io (placeholder — confirm address-of-record before publishing). Do not file public GitHub issues for vulnerabilities.

We aim to acknowledge reports within 3 business days and ship a fix or mitigation within 14 days for confirmed issues.

Disclosed defects

SECRET_KEY insecure-default boot — fixed

Affected versions: all commits before this fix landed (every release of OpenRunner up to and including the one immediately preceding the guardrail commit).

The bundled docker-compose.yml substituted an empty string when no SECRET_KEY was provided, and .env.example shipped with the placeholder change-me-in-production. The API server still booted and signed JWTs with that value, which made authentication tokens trivially forgeable against any account on a self-hosted instance that used the bundled defaults unmodified.

Fix. A boot-time guardrail in app/core/config.py (validate_production_security) now refuses to start the API server when SECRET_KEY is empty or equals change-me-in-production and DEBUG=false. Local development with DEBUG=true is unaffected. Operators who shipped the bundled defaults must rotate SECRET_KEY to a strong random value (python -c "import secrets; print(secrets.token_urlsafe(48))") and invalidate any active sessions issued before the rotation.

SECRET_KEY first-boot auto-generation

To keep the README quickstart (git clone && docker compose up -d) zero-friction without weakening the guard above (GLA-55), the api container's entrypoint (src/api/scripts/entrypoint.sh) detects an empty or placeholder SECRET_KEY while DEBUG=false and generates a strong random key via secrets.token_urlsafe(48). The key is written to /var/lib/openrunner/secret_key inside the container, which is bind-mounted to ./.data/api-secrets on the host so it survives docker compose down/up cycles and keeps issued JWTs valid across restarts. The container prints a loud stderr banner the first time it generates a key.

Production deployments must still set SECRET_KEY explicitly (e.g. via your secret manager or .env) rather than relying on the auto-generated file: an explicit key is rotatable on a controlled cadence, replicable across multi-replica deployments, and audit-traceable. The auto-gen path exists so a self-hoster's first docker compose up -d boots without manual file edits — it is not an endorsement of running production off the default volume.

In particular, multi-replica deployments must not rely on auto-gen: each replica running the entrypoint without a shared SECRET_KEY would either (a) race to write the file on a shared mount, or (b) generate independent keys on independent volumes, and JWTs minted by one replica would be unverifiable by the others. The single-node Docker Compose quickstart that auto-gen targets has exactly one api replica, so the race is not reachable there. For Helm / k8s, set SECRET_KEY from a real Secret object — see the helm/openrunner/README.md deployment guide.

docker compose down -v deletes ./.data/api-secrets along with the other data volumes, which forces a new key on the next boot and invalidates every previously issued JWT. That is intentional: down -v is a fresh-state operation.

Threat-model wave 1 — JWT/SECRET_KEY hardening (R-01, R-02, R-07)

Source: GLA-95 threat model wave 1, tracked as GLA-109. Three config-layer fixes that close the largest blast-radius default-credential holes the bundled self-host topology shipped with.

R-01 — strong-secret validators on SECRET_KEY, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET. Settings construction now refuses to load when any of these is empty, set to a change-me* / generate-a-real-secret-key placeholder, or shorter than 32 characters. The lifespan startup hook re-validates and exits non-zero with a clear stderr message naming the offending field. The container entrypoint auto-generates and persists strong values to /var/lib/openrunner/ on first boot when the env still carries the documented placeholders, so git clone && docker compose up -d keeps working for fresh-clone operators.

R-02 — full HMAC + constant-time compare on storage-proxy download tokens. app/api/v1/media.py previously truncated the SHA-256 digest to its first 32 hex chars (hexdigest()[:32]), halving the brute-force search space. Tokens now use the full 64-char hex digest and verify with hmac.compare_digest. The matching token generator in app/services/media.py calls the same helper so signed and verified tokens stay in lockstep.

R-07 — refresh-cookie Secure decoupled from DEBUG. The refresh cookie's Secure flag is now driven by a dedicated COOKIE_SECURE config knob (default true), not by DEBUG. Operators may turn it off only for local-loopback dev — any inbound request whose Host header is not localhost / 127.0.0.1 clamps Secure=true regardless. The cookie also moves to SameSite=strict (it is consumed exclusively by POST /api/v1/auth/refresh). A new ENVIRONMENT setting (default development) refuses boot when combined with DEBUG=true and ENVIRONMENT=production so debug mode cannot leak into a real deployment.

Operator action.

  1. Generate strong values for SECRET_KEY, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET (e.g. openssl rand -hex 32) and set them in .env. Existing sessions issued under prior weak keys are invalidated on rotation by design.
  2. Set ENVIRONMENT=production for production deployments.
  3. Leave COOKIE_SECURE=true unless serving the SPA exclusively over http://localhost.

Secret-leakage detection

Tracked under GLA-1105 / shipped as GLA-1111. Two-layer guardrail with one shared rule set (.gitleaks.toml):

Layer 1 — local pre-commit hook. .pre-commit-config.yaml runs on every git commit and combines:

  • gitleaks (staged-diff mode) — upstream default ruleset plus the OpenRunner-specific patterns below.
  • detect-private-key — catches BEGIN RSA/EC/OPENSSH/DSA/PGP PRIVATE KEY blocks.
  • check-added-large-files — blocks blobs > 1 MB (typical secret-dump shape).
  • forbid-new-submodules.
  • local hook → python scripts/check_secret_leakage.py --staged — pure-stdlib mirror of the OpenRunner rules so contributors who do not have gitleaks installed still get coverage.

Install once after cloning:

pip install pre-commit && pre-commit install
# or
make precommit-install

Layer 2 — CI backstop. .github/workflows/ci.yml defines a secret-scan job that runs on every PR and on push to main. It uses the same .gitleaks.toml so local and CI verdicts match. PRs scan the diff against the base SHA via gitleaks/gitleaks-action@v2; pushes scan the last 50 commits with the gitleaks binary directly. The job also re-runs scripts/check_secret_leakage.py and the tests/security/test_precommit_catches_leak.sh self-test that asserts a synthetic leak is blocked.

OpenRunner-specific rules. Beyond the gitleaks defaults (AWS, Stripe, Slack, GitHub, generic high-entropy keys) the config flags:

  • SECRET_KEY= / JWT_ACCESS_SECRET= / JWT_REFRESH_SECRET= set to a real-shaped value (≥ 32 chars, not the documented change-me-* placeholders) outside .env.example, SECURITY.md, docs/, and in-repo test fixtures.
  • MINIO_(SECRET|ACCESS)_KEY=minioadmin outside .env.example, docker-compose*.yml, and helm/.
  • RESEND_API_KEY=re_… (with the published Resend prefix) anywhere except documented placeholder strings.
  • Authorization: Bearer <real-looking-token> outside tests/, examples/, docs/, and the SPA-rendered curl examples.
  • Any path matching ^\.env$ | ^\.env\.local$ | ^\.env\.production$ — real env files must never land in a commit.

Self-test.

make test-security
# or
bash tests/security/test_precommit_catches_leak.sh

The harness creates a fake SECRET_KEY=$(openssl rand -hex 32) file, stages it, runs pre-commit run --files <file>, asserts non-zero exit, and cleans up the fixture regardless of outcome.

Escape hatch. git commit --no-verify skips local hooks and is documented for emergency-debug use only. CI's secret-scan job is not skippable — every PR is re-scanned regardless of --no-verify. The diff itself records that a hook was bypassed, so this is an audit-trail event, not a free pass.

Updating the rules. Any new rule, allowlist, or pattern change must ship in both .gitleaks.toml (the source of truth used by gitleaks and by CI) and scripts/check_secret_leakage.py (the stdlib mirror used by the local pre-commit hook), plus a one-line note in this section explaining why. False positives go in the path/value allowlist; never add # nosec to live config or vendor a real secret.

Out of scope (see GLA-1105 plan doc). Push-side server hooks, history rewrite for any pre-existing leak, dependency-CVE / SBOM scanning, and the GitHub branch-protection toggle are tracked as separate follow-ups.

There aren't any published security advisories