Summary
Production Redis runs with no password on docsplus-network. The review counted 23 containers on that network. They include internet-facing third-party apps (Grafana, GlitchTip, Uptime Kuma) and the host app tiny.docs.plus. If any of them is compromised, it can read and write Redis, which holds the BullMQ queues and the Yjs sync pub/sub.
- Severity: Medium. It needs a neighbour compromise first, but the impact after that is high. A compromised neighbour can do this:
- A forged
store-documents job makes the worker write any content into any document. It skips the Read-only and Private checks.
- A forged
generic email job sends any mail through the docs.plus SMTP account.
- A neighbour can inject sync updates and
doc:{id}:access events, and wipe rate-limit keys.
- Redis 7 defaults block the old file-write route to a shell. So the risk is data and queue integrity, not container takeover.
- Area:
docker-compose.prod.yml, docker-compose.observability.yml, hocuspocus Redis config
- Source: security review of 2026-10-06, finding M6
Where
- Redis service, no
--requirepass: docker-compose.prod.yml:104-134. Its healthcheck is redis-cli ping (:130).
- Env schema has no password key:
apps/hocuspocus.server/src/config/env.schema.ts:61-69. The redis block in apps/hocuspocus.server/src/config/env.ts:45-55 mirrors it.
buildRedisConfig in apps/hocuspocus.server/src/lib/redis.ts:39-97 sets no password. Every app client goes through it: the sync client, the subscriber, the publisher, and every BullMQ connection through createRedisConnection (:261-279).
RedisExtension builds its own ioredis options with no password: apps/hocuspocus.server/src/config/hocuspocus.config.ts:125-142. The comment there warns that a password set only here breaks every queue with NOAUTH.
redis-exporter connects with no password: docker-compose.observability.yml:233-238.
- Staging uses the same
docker-compose.prod.yml with /opt/projects/stage.docs.plus/.env (.github/workflows/stage.docs.plus.yml:46-48).
Fix plan
Ship in two deploys. Old replicas send no password. If requirepass ships with the client change, sync and queues fail until every replica has rolled.
Deploy 1: clients send the password. ioredis only logs a warning when Redis has no password set. So this deploy is safe on its own.
- Add
REDIS_PASSWORD: z.string().optional() to env.schema.ts, and password: env.REDIS_PASSWORD to the redis block in env.ts.
- In
buildRedisConfig, set password: config.redis.password. This covers every client that lib/redis.ts builds.
- In
hocuspocus.config.ts, add password: config.redis.password to the RedisExtension options. Update the comment above it.
- Add an empty
REDIS_PASSWORD= line with a one-line comment to the Redis section of .env.example.
- Add a strong
REDIS_PASSWORD to /opt/projects/prod.docs.plus/.env and /opt/projects/stage.docs.plus/.env. Production app services read it through env_file: .env.production, which the deploy copies from the host .env (.github/workflows/prod.docs.plus.yml:537). The staging deploy writes .env.staging instead (.github/workflows/stage.docs.plus.yml:230). Confirm which file the staging containers load before Deploy 2.
Deploy 2: Redis requires it.
- In
docker-compose.prod.yml, add --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required} to the Redis command. The :? form fails the deploy if the key is missing.
- Add
REDISCLI_AUTH: ${REDIS_PASSWORD} under the Redis service environment. Then the healthcheck and the runbook's redis-cli commands keep working unchanged.
- In
docker-compose.observability.yml, add REDIS_PASSWORD: ${REDIS_PASSWORD} to redis-exporter. Add the key to /opt/projects/prod.docs.plus/.env.observability, and to the required-key loop in .github/workflows/observability.docs.plus.yml:63.
- Roll to staging first, then production.
Leave dev and local compose files alone. The key is optional, so they keep running with no password.
Out of scope
- Moving Grafana, GlitchTip and Uptime Kuma to their own network. A password closes the open write. A network split also needs Traefik on both networks and a
traefik.docker.network label on each moved container (scripts/traefik/traefik.yml:82). File it separately if wanted.
- A Redis ACL user per client, and moving the password out of the
command line.
Acceptance criteria
Verify
cd apps/hocuspocus.server && bun run typecheck && bun test.
- Locally, run
make dev-local. Set a password with docker exec docsy-redis-local redis-cli CONFIG SET requirepass <password>, and put the same value in REDIS_PASSWORD in .env.local. Restart the server, edit a document, and check that the save lands. Undo with CONFIG SET requirepass "".
RedisExtension runs only when NODE_ENV is production, so only staging proves cross-replica sync. Open one document in two browsers on staging and check that edits appear in both.
- After each deploy, run the acceptance checks on the host with
docker exec.
Related
Generated by Claude Code
Summary
Production Redis runs with no password on
docsplus-network. The review counted 23 containers on that network. They include internet-facing third-party apps (Grafana, GlitchTip, Uptime Kuma) and the host apptiny.docs.plus. If any of them is compromised, it can read and write Redis, which holds the BullMQ queues and the Yjs sync pub/sub.store-documentsjob makes the worker write any content into any document. It skips the Read-only and Private checks.genericemail job sends any mail through the docs.plus SMTP account.doc:{id}:accessevents, and wipe rate-limit keys.docker-compose.prod.yml,docker-compose.observability.yml, hocuspocus Redis configWhere
--requirepass:docker-compose.prod.yml:104-134. Its healthcheck isredis-cli ping(:130).apps/hocuspocus.server/src/config/env.schema.ts:61-69. Theredisblock inapps/hocuspocus.server/src/config/env.ts:45-55mirrors it.buildRedisConfiginapps/hocuspocus.server/src/lib/redis.ts:39-97sets no password. Every app client goes through it: the sync client, the subscriber, the publisher, and every BullMQ connection throughcreateRedisConnection(:261-279).RedisExtensionbuilds its own ioredis options with no password:apps/hocuspocus.server/src/config/hocuspocus.config.ts:125-142. The comment there warns that a password set only here breaks every queue withNOAUTH.redis-exporterconnects with no password:docker-compose.observability.yml:233-238.docker-compose.prod.ymlwith/opt/projects/stage.docs.plus/.env(.github/workflows/stage.docs.plus.yml:46-48).Fix plan
Ship in two deploys. Old replicas send no password. If
requirepassships with the client change, sync and queues fail until every replica has rolled.Deploy 1: clients send the password. ioredis only logs a warning when Redis has no password set. So this deploy is safe on its own.
REDIS_PASSWORD: z.string().optional()toenv.schema.ts, andpassword: env.REDIS_PASSWORDto theredisblock inenv.ts.buildRedisConfig, setpassword: config.redis.password. This covers every client thatlib/redis.tsbuilds.hocuspocus.config.ts, addpassword: config.redis.passwordto theRedisExtensionoptions. Update the comment above it.REDIS_PASSWORD=line with a one-line comment to the Redis section of.env.example.REDIS_PASSWORDto/opt/projects/prod.docs.plus/.envand/opt/projects/stage.docs.plus/.env. Production app services read it throughenv_file: .env.production, which the deploy copies from the host.env(.github/workflows/prod.docs.plus.yml:537). The staging deploy writes.env.staginginstead (.github/workflows/stage.docs.plus.yml:230). Confirm which file the staging containers load before Deploy 2.Deploy 2: Redis requires it.
docker-compose.prod.yml, add--requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required}to the Rediscommand. The:?form fails the deploy if the key is missing.REDISCLI_AUTH: ${REDIS_PASSWORD}under the Redis serviceenvironment. Then the healthcheck and the runbook'sredis-clicommands keep working unchanged.docker-compose.observability.yml, addREDIS_PASSWORD: ${REDIS_PASSWORD}toredis-exporter. Add the key to/opt/projects/prod.docs.plus/.env.observability, and to the required-key loop in.github/workflows/observability.docs.plus.yml:63.Leave dev and local compose files alone. The key is optional, so they keep running with no password.
Out of scope
traefik.docker.networklabel on each moved container (scripts/traefik/traefik.yml:82). File it separately if wanted.commandline.Acceptance criteria
docker run --rm --network docsplus-network redis:7-alpine redis-cli -h docsplus-redis pingreturnsNOAUTH.redis-cli CLIENT LIST | grep -c bzpopmininside the Redis container returns 6. That is three blocking clients (document, email, push) for each of the 2hocuspocus-workerreplicas.healthy, andredis-exporterreportsredis_up 1.REDIS_PASSWORDstill starts.Verify
cd apps/hocuspocus.server && bun run typecheck && bun test.make dev-local. Set a password withdocker exec docsy-redis-local redis-cli CONFIG SET requirepass <password>, and put the same value inREDIS_PASSWORDin.env.local. Restart the server, edit a document, and check that the save lands. Undo withCONFIG SET requirepass "".RedisExtensionruns only whenNODE_ENVisproduction, so only staging proves cross-replica sync. Open one document in two browsers on staging and check that edits appear in both.docker exec.Related
Generated by Claude Code