From 7f0fd1281b83ca6eec27493d25ca1a576a5766b2 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 16 May 2026 04:35:59 +0000 Subject: [PATCH] security: scope /opt mount to /opt/projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the previous /opt:/opt:rw bind into two mounts to narrow the writable scope of the container: - /opt:/opt:ro — read-only mount for legacy/existing project add-existing flow. resolveProjectPath still uses PROJECT_ROOT_WHITELIST (/opt by default) so existing projects under /opt/ (analytics, boolab, boocode itself) continue to resolve and serve their file-tree via the read-only tools. - /opt/projects:/opt/projects:rw — writable mount targeted at the create-new-project bootstrap path. Picked Option B from the spec (simpler than two scan roots): PROJECT_ROOT_WHITELIST stays /opt, new BOOTSTRAP_ROOT env var defaults to /opt/projects and is used by project_bootstrap.ts as the mkdir target. Bootstrap path-escape check now compares against BOOTSTRAP_ROOT. Prereq: host must `mkdir -p /opt/projects` before next container restart. Documented in CLAUDE.md and .env.example. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 1 + CLAUDE.md | 2 +- apps/server/src/config.ts | 1 + apps/server/src/services/project_bootstrap.ts | 12 +++++++----- docker-compose.yml | 6 +++++- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index a792e87..bd32787 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,6 @@ PORT=3000 DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode LLAMA_SWAP_URL=http://100.101.41.16:8401 PROJECT_ROOT_WHITELIST=/opt +BOOTSTRAP_ROOT=/opt/projects DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4 POSTGRES_PASSWORD=CHANGE_ME diff --git a/CLAUDE.md b/CLAUDE.md index 6f0fd5c..6fc5049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ Position-shift pattern for panes (legacy `session_panes` table): negate-and-rest ## Environment -Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt), `DEFAULT_MODEL`, `LOG_LEVEL`. +Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`. ## Workflow diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index bc30e48..5350741 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -7,6 +7,7 @@ const ConfigSchema = z.object({ DATABASE_URL: z.string().url(), LLAMA_SWAP_URL: z.string().url(), PROJECT_ROOT_WHITELIST: z.string().default('/opt'), + BOOTSTRAP_ROOT: z.string().default('/opt/projects'), DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'), LOG_LEVEL: z.string().default('info'), GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'), diff --git a/apps/server/src/services/project_bootstrap.ts b/apps/server/src/services/project_bootstrap.ts index 7762ce3..c080549 100644 --- a/apps/server/src/services/project_bootstrap.ts +++ b/apps/server/src/services/project_bootstrap.ts @@ -82,11 +82,13 @@ export async function bootstrapProject( throw new BootstrapNameError(`invalid name after sanitization: "${folder}"`); } - // Whitelist resolution - const whitelistReal = await realpath(config.PROJECT_ROOT_WHITELIST); - const fullPath = resolve(whitelistReal, folder); - if (!fullPath.startsWith(whitelistReal + sep)) { - throw new BootstrapPathError('path escapes whitelist'); + // Bootstrap target resolution. Uses BOOTSTRAP_ROOT (writable), not + // PROJECT_ROOT_WHITELIST (which may be a wider read-only scope for + // add-existing flow). + const bootstrapReal = await realpath(config.BOOTSTRAP_ROOT); + const fullPath = resolve(bootstrapReal, folder); + if (!fullPath.startsWith(bootstrapReal + sep)) { + throw new BootstrapPathError('path escapes bootstrap root'); } if (existsSync(fullPath)) { throw new BootstrapCollisionError(`path already exists: ${fullPath}`); diff --git a/docker-compose.yml b/docker-compose.yml index 2ca91e7..45dd681 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,11 @@ services: environment: DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode volumes: - - /opt:/opt:rw + # Read-only mount for legacy/existing project add-existing flow. + - /opt:/opt:ro + # Writable mount only for the create-new-project bootstrap target. + # Host must `mkdir -p /opt/projects` before container start. + - /opt/projects:/opt/projects:rw depends_on: - boocode_db networks: