diff --git a/cli/src/commands/config-cmd.ts b/cli/src/commands/config-cmd.ts index 4cf5c58..cbc234e 100644 --- a/cli/src/commands/config-cmd.ts +++ b/cli/src/commands/config-cmd.ts @@ -1,5 +1,5 @@ import { existsSync } from 'fs'; -import { confirm, input, select } from '@inquirer/prompts'; +import { confirm, input, password, select } from '@inquirer/prompts'; import { loadConfig, envGet, envSet } from '../config.js'; import { composeRestart } from '../docker.js'; import { applyLlmProviderConfig, getConfiguredLlmProvider, getLlmProviderPreset, LLM_PROVIDER_PRESETS } from '../llm.js'; @@ -73,14 +73,14 @@ function renderConfig(envFile: string) { d.header('Settings'); const apiKey = envGet('SUBSTRATE_API_KEY', envFile); const llm = getConfiguredLlmProvider(envFile); - d.label('API Key', apiKey ? `${apiKey.substring(0, 8)}...` : d.RED('not set')); + d.label('API Key', apiKey ? `set (${apiKey.length} chars)` : d.RED('not set')); d.label('Docker GID', envGet('DOCKER_GID', envFile) || d.DIM('not set')); if (llm.providerName) { d.label('LLM Active', `${llm.providerName}${llm.model ? ` (${llm.model})` : ''}`); } for (const s of SETTINGS) { const value = envGet(s.key, envFile); - d.label(s.label, value || d.DIM('not set')); + d.label(s.label, s.secret && value ? `set (${value.length} chars)` : (value || d.DIM('not set'))); } } @@ -246,10 +246,18 @@ export async function configCommand(opts: { if (settingKey !== 'cancel') { const setting = SETTINGS.find(s => s.key === settingKey)!; const currentValue = envGet(settingKey, config.envFile); - const newValue = await input({ - message: `${setting.label}`, - default: currentValue || setting.placeholder || '', - }); + const newValue = setting.secret + ? await password({ + message: `${setting.label}${currentValue ? ' (leave blank to keep current)' : ''}`, + mask: '*', + }) + : await input({ + message: `${setting.label}`, + default: currentValue || setting.placeholder || '', + }); + if (setting.secret && !newValue && currentValue) { + break; + } if (newValue !== currentValue) { envSet(settingKey, newValue, config.envFile); d.success(`${setting.label} updated`); @@ -290,10 +298,12 @@ export async function configCommand(opts: { let apiKey: string | undefined; if (preset.apiKeyRequired) { - apiKey = await input({ - message: `${preset.name} API key`, - default: envGet('LLM_API_KEY', config.envFile), + const existingApiKey = envGet('LLM_API_KEY', config.envFile); + const enteredApiKey = await password({ + message: `${preset.name} API key${existingApiKey ? ' (leave blank to keep current)' : ''}`, + mask: '*', }); + apiKey = enteredApiKey || (existingApiKey ? undefined : ''); } applyLlmProviderConfig(config.envFile, { diff --git a/cli/src/commands/menu.ts b/cli/src/commands/menu.ts index 30d7c5e..1936072 100644 --- a/cli/src/commands/menu.ts +++ b/cli/src/commands/menu.ts @@ -11,6 +11,7 @@ import { shellCommand } from './shell.js'; import { containersCommand } from './containers.js'; import { securityCommand } from './security.js'; import { configCommand } from './config-cmd.js'; +import { rotateKeyCommand } from './rotate-key.js'; import * as d from '../utils/display.js'; export async function menuCommand(): Promise { @@ -30,6 +31,7 @@ export async function menuCommand(): Promise { { name: 'Remote Access', value: 'remote', description: 'Cloudflare and Tailscale workflows' }, { name: 'Containers', value: 'containers', description: 'Inspect substrate-managed containers' }, { name: 'Security', value: 'security', description: 'Run the security audit' }, + { name: 'Rotate API Key', value: 'rotate-key', description: 'Generate a new substrate shared secret' }, { name: 'Config', value: 'config', description: 'Toggle features and edit settings' }, { name: 'Exit', value: 'exit', description: 'Leave the interactive CLI' }, ], @@ -64,6 +66,10 @@ export async function menuCommand(): Promise { await securityCommand({}); await pause(); break; + case 'rotate-key': + await rotateKeyCommand({}); + await pause(); + break; case 'config': await configCommand({}); await pause(); diff --git a/cli/src/commands/rotate-key.ts b/cli/src/commands/rotate-key.ts new file mode 100644 index 0000000..e200c27 --- /dev/null +++ b/cli/src/commands/rotate-key.ts @@ -0,0 +1,121 @@ +import { createHash, randomBytes } from 'crypto'; +import { existsSync } from 'fs'; +import { resolve } from 'path'; +import { confirm } from '@inquirer/prompts'; +import { loadConfig, envGet, envSet } from '../config.js'; +import { composeRestart } from '../docker.js'; +import * as d from '../utils/display.js'; + +function keyFingerprint(key: string): string { + return createHash('sha256').update(key, 'utf8').digest('hex').slice(0, 12); +} + +function parseByteLength(raw: string | undefined): number { + if (!raw) return 32; + const value = Number.parseInt(raw, 10); + if (!Number.isInteger(value) || value < 32 || value > 128) { + throw new Error('--bytes must be an integer from 32 to 128'); + } + return value; +} + +function generateKey(bytes: number): string { + return randomBytes(bytes).toString('hex'); +} + +export async function rotateKeyCommand(opts: { + bytes?: string; + cloudflare?: boolean; + frontendEnv?: string; + restart?: boolean; + runtime?: string; + show?: boolean; + yes?: boolean; +}) { + const config = loadConfig({ runtime: opts.runtime }); + if (!existsSync(config.envFile)) { + d.error('.env file not found; run `aether-substrate init` first'); + d.line(); + process.exitCode = 1; + return; + } + + let bytes: number; + try { + bytes = parseByteLength(opts.bytes); + } catch (error: any) { + d.error(error.message); + d.line(); + process.exitCode = 1; + return; + } + const currentKey = envGet('SUBSTRATE_API_KEY', config.envFile); + + d.header('Rotate API Key'); + d.label('Env File', config.envFile); + d.label( + 'Current Key', + currentKey ? `set (${currentKey.length} chars, fingerprint ${keyFingerprint(currentKey)})` : d.RED('not set'), + ); + + if (!opts.yes) { + d.warn('Substrate keeps accepting the old key until the stack is restarted.'); + d.warn('Rotating SUBSTRATE_API_KEY also invalidates encrypted LLM provider keys stored in /data; re-enter hosted provider keys after restart if needed.'); + + if (!process.stdin.isTTY) { + d.error('Refusing to rotate non-interactively without --yes'); + d.line(); + process.exitCode = 1; + return; + } + + const ok = await confirm({ + message: 'Generate and write a new SUBSTRATE_API_KEY now?', + default: false, + }); + if (!ok) { + d.info('No changes made.'); + d.line(); + return; + } + } + + const nextKey = generateKey(bytes); + envSet('SUBSTRATE_API_KEY', nextKey, config.envFile); + + d.success(`Wrote new key (${nextKey.length} chars, fingerprint ${keyFingerprint(nextKey)})`); + + if (opts.frontendEnv) { + const frontendEnv = resolve(opts.frontendEnv); + envSet('SUBSTRATE_API_KEY', nextKey, frontendEnv); + d.success(`Updated frontend env file: ${frontendEnv}`); + } else { + const commonFrontendEnv = resolve(config.substrateDir, '..', '.env.local'); + if (existsSync(commonFrontendEnv)) { + d.info(`Frontend env not updated. To sync it, rerun with --frontend-env ${commonFrontendEnv}`); + } else { + d.info('Frontend server-side tools also need SUBSTRATE_API_KEY if they call Substrate directly.'); + } + } + + d.info('Browser-stored Substrate settings must be updated manually in Aether OS Settings after rotation.'); + + if (opts.show) { + d.line(); + d.info('New key, shown because --show was passed:'); + d.command(`SUBSTRATE_API_KEY=${nextKey}`); + } else { + d.info('New key is stored in substrate/.env. Pass --show only when you need to copy it into another secret store.'); + } + + if (opts.restart) { + d.line(); + d.info('Restarting substrate to apply the new key...'); + composeRestart(config, { cloudflare: opts.cloudflare }); + d.success('Substrate restarted'); + } else { + d.warn('Restart required to apply: aether-substrate rotate-key --restart --yes'); + } + + d.line(); +} diff --git a/cli/src/commands/security.ts b/cli/src/commands/security.ts index bb54a80..53999f2 100644 --- a/cli/src/commands/security.ts +++ b/cli/src/commands/security.ts @@ -19,6 +19,17 @@ export async function securityCommand(opts: { key?: string; url?: string }) { function warning(msg: string) { d.warn(msg); warnings++; } function issue(msg: string) { d.error(msg); issues++; } + async function probe(path: string, init: RequestInit = {}): Promise { + try { + return await fetch(`${config.apiUrl}${path}`, { + ...init, + signal: AbortSignal.timeout(5000), + }); + } catch { + return null; + } + } + // 1. API key check const apiKey = envGet('SUBSTRATE_API_KEY', config.envFile); if (!apiKey) { @@ -31,17 +42,21 @@ export async function securityCommand(opts: { key?: string; url?: string }) { // 2. Check .env permissions if (existsSync(config.envFile)) { - try { - const { statSync } = await import('fs'); - const stat = statSync(config.envFile); - const mode = (stat.mode & 0o777).toString(8); - if (stat.mode & 0o044) { - warning(`.env is world/group-readable (${mode}) — consider chmod 600`); - } else { - pass(`.env permissions OK (${mode})`); + if (process.platform === 'win32') { + warning('.env ACLs were not checked on Windows — restrict this file to your user account before launch'); + } else { + try { + const { statSync } = await import('fs'); + const stat = statSync(config.envFile); + const mode = (stat.mode & 0o777).toString(8); + if (stat.mode & 0o044) { + warning(`.env is world/group-readable (${mode}) — consider chmod 600`); + } else { + pass(`.env permissions OK (${mode})`); + } + } catch { + warning('Could not check .env permissions'); } - } catch { - warning('Could not check .env permissions'); } } else { issue('.env file not found'); @@ -58,6 +73,15 @@ export async function securityCommand(opts: { key?: string; url?: string }) { } else { pass('Port bindings look OK'); } + if (compose.includes('workspace-data:/workspace:ro')) { + pass('Workspace volume is read-only in the substrate API container'); + } else if (compose.includes('workspace-data:/workspace')) { + warning('Workspace volume is writable in the substrate API container'); + } + const latestImages = Array.from(compose.matchAll(/image:\s+([^\s#]+:latest)\b/g)).map(m => m[1]); + if (latestImages.length > 0) { + warning(`Mutable latest image tags in compose: ${latestImages.join(', ')}`); + } } // 4. Caddyfile security headers @@ -92,6 +116,22 @@ export async function securityCommand(opts: { key?: string; url?: string }) { if (health.ok) { pass('API is reachable and healthy'); + const unauth = await probe('/services'); + if (unauth?.status === 401) { + pass('Authenticated routes reject missing API key'); + } else if (unauth) { + issue(`Authenticated route without key returned HTTP ${unauth.status}`); + } + + const badKey = await probe('/services', { + headers: { 'x-substrate-key': 'invalid-key' }, + }); + if (badKey?.status === 401) { + pass('Authenticated routes reject invalid API key'); + } else if (badKey) { + issue(`Authenticated route with invalid key returned HTTP ${badKey.status}`); + } + // Check rate limiting const svc = await client.services(); if (svc.ok) { @@ -113,6 +153,19 @@ export async function securityCommand(opts: { key?: string; url?: string }) { if (running.length > 0) { d.info(` ${running.length} container(s) running`); } + + const images = await probe('/containers/images/allowed', { + headers: { 'x-substrate-key': config.apiKey }, + }); + if (images?.ok) { + const body = await images.json().catch(() => ({ images: [] })) as { images?: string[] }; + const mutable = (body.images || []).filter(image => image.endsWith(':latest')); + if (mutable.length > 0) { + warning(`Mutable latest image tags in container allowlist: ${mutable.join(', ')}`); + } else { + pass('Container image allowlist avoids :latest tags'); + } + } } else if (containers.error.includes('403')) { pass('Container orchestration disabled (secure default)'); } diff --git a/cli/src/commands/setup.ts b/cli/src/commands/setup.ts index f051bdd..6b1b83f 100644 --- a/cli/src/commands/setup.ts +++ b/cli/src/commands/setup.ts @@ -1,5 +1,6 @@ import { execSync } from 'child_process'; -import { confirm, input, select } from '@inquirer/prompts'; +import { randomBytes } from 'crypto'; +import { confirm, input, password, select } from '@inquirer/prompts'; import ora from 'ora'; import { loadConfig, envGet, envSet, ensureEnvFile } from '../config.js'; import { runtimeCheck, composeCheck, composeBuild, composeUp, trustCaddyCert } from '../docker.js'; @@ -8,7 +9,7 @@ import { setupCloudflareTunnel, setupTailscaleTunnel } from '../remote.js'; import * as d from '../utils/display.js'; function generateKey(): string { - return execSync('openssl rand -hex 32', { encoding: 'utf-8' }).trim(); + return randomBytes(32).toString('hex'); } // The substrate container needs to bind-mount /var/run/docker.sock and have a group that @@ -78,18 +79,18 @@ export async function setupCommand(opts: { const currentKey = envGet('SUBSTRATE_API_KEY', config.envFile); if (currentKey) { - d.success(`API key set (${currentKey.substring(0, 8)}...)`); + d.success(`API key set (${currentKey.length} chars)`); const regen = await confirm({ message: 'Generate a new API key?', default: false }); if (regen) { const key = generateKey(); envSet('SUBSTRATE_API_KEY', key, config.envFile); - d.success(`New key: ${key.substring(0, 8)}...`); + d.success(`New API key generated (${key.length} chars)`); d.warn('Update SUBSTRATE_API_KEY in your frontend .env.local to match'); } } else { const key = generateKey(); envSet('SUBSTRATE_API_KEY', key, config.envFile); - d.success(`API key generated: ${key.substring(0, 8)}...`); + d.success(`API key generated (${key.length} chars)`); } const gid = detectDockerGid(config.runtime.cmd); @@ -143,10 +144,12 @@ export async function setupCommand(opts: { let apiKey: string | undefined; if (preset.apiKeyRequired) { - apiKey = await input({ - message: `${preset.name} API key`, - default: envGet('LLM_API_KEY', config.envFile), + const existingApiKey = envGet('LLM_API_KEY', config.envFile); + const enteredApiKey = await password({ + message: `${preset.name} API key${existingApiKey ? ' (leave blank to keep current)' : ''}`, + mask: '*', }); + apiKey = enteredApiKey || (existingApiKey ? undefined : ''); } applyLlmProviderConfig(config.envFile, { diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts index 71e7478..e19ffa5 100644 --- a/cli/src/commands/status.ts +++ b/cli/src/commands/status.ts @@ -72,7 +72,7 @@ export async function statusCommand(opts: { banner?: boolean; key?: string; url? // Configuration d.header('Configuration'); const key = envGet('SUBSTRATE_API_KEY', config.envFile); - d.label('API Key', key ? `${key.substring(0, 8)}...` : d.RED('not set')); + d.label('API Key', key ? `set (${key.length} chars)` : d.RED('not set')); d.label('Docker GID', envGet('DOCKER_GID', config.envFile) || 'not set'); const features = [ diff --git a/cli/src/index.ts b/cli/src/index.ts index a81db2a..ca6d888 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -12,6 +12,7 @@ import { trustCommand } from './commands/trust.js'; import { containersCommand } from './commands/containers.js'; import { securityCommand } from './commands/security.js'; import { configCommand } from './commands/config-cmd.js'; +import { rotateKeyCommand } from './commands/rotate-key.js'; import { loadConfig } from './config.js'; import { setupCloudflareTunnel, setupTailscaleTunnel } from './remote.js'; import * as d from './utils/display.js'; @@ -33,6 +34,7 @@ Examples: aether-substrate init aether-substrate init --runtime podman aether-substrate doctor + aether-substrate rotate-key --restart aether-substrate config --enable embeddings --restart aether-substrate up --cloudflare aether-substrate tunnel cloudflare --hostname substrate.example.com @@ -124,6 +126,19 @@ program .option('--url ', 'Substrate URL') .action(securityCommand); +program + .command('rotate-key') + .alias('key:rotate') + .description('Generate a new SUBSTRATE_API_KEY, update .env, and optionally restart') + .option('--bytes ', 'Random byte length before hex encoding (32-128)', '32') + .option('--frontend-env ', 'Also update a frontend .env/.env.local file with SUBSTRATE_API_KEY') + .option('--restart', 'Restart substrate after writing the new key') + .option('--cloudflare', 'Include the Cloudflare compose overlay when restarting') + .option('--runtime ', 'Override the container runtime for restart') + .option('--show', 'Print the new key once for copying into another secret store') + .option('--yes', 'Skip confirmation prompts') + .action(rotateKeyCommand); + program .command('config') .description('Toggle features and edit substrate settings') diff --git a/cli/src/remote.ts b/cli/src/remote.ts index f31dfa0..b3cf31b 100644 --- a/cli/src/remote.ts +++ b/cli/src/remote.ts @@ -69,7 +69,7 @@ export function getCloudflareSummary(config: CliConfig): CloudflareSummary { configSource: detected.source, credentialsDir, disableChunkedEncoding: /^\s*disableChunkedEncoding:\s*true\s*$/m.test(cfConfig), - running: cloudflaredProcessRunning(), + running: cloudflaredProcessRunning(config), }; } @@ -585,7 +585,22 @@ function findCloudflareConfigPath(config: CliConfig): { return { path: '', source: 'none' }; } -function cloudflaredProcessRunning(): boolean { +function cloudflaredProcessRunning(config: CliConfig): boolean { + try { + const container = tryReadCommand(config.runtime.cmd, [ + 'ps', + '--filter', + 'name=aether-cloudflared', + '--format', + '{{.Names}}', + ]) || ''; + if (container.split('\n').some(line => line.trim() === 'aether-cloudflared')) { + return true; + } + } catch { + // Fall through to host process checks. + } + if (process.platform === 'win32') { const output = tryReadCommand('tasklist', ['/FI', 'IMAGENAME eq cloudflared.exe']) || ''; return output diff --git a/cloudflared-config.yml.local-run.yml b/cloudflared-config.yml.local-run.yml new file mode 100644 index 0000000..b068828 --- /dev/null +++ b/cloudflared-config.yml.local-run.yml @@ -0,0 +1,16 @@ +# Cloudflare Tunnel config for Aether Substrate +# Generated by aether-substrate + +tunnel: abf3fa50-6209-48b9-9194-8d56fdcb3e53 +credentials-file: C:\Users\Pieter\.cloudflared\abf3fa50-6209-48b9-9194-8d56fdcb3e53.json + +ingress: + - hostname: aether-substrate.montoulieu.dev + service: http://substrate:3100 + originRequest: + connectTimeout: 30s + keepAliveTimeout: 90s + disableChunkedEncoding: true + - service: http_status:404 + + diff --git a/docker-compose.yml b/docker-compose.yml index 7388121..0e7ca25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock # Shared with the on-demand workspace container (source of truth for writes); # substrate only reads and fs.watches from this side. - - workspace-data:/workspace + - workspace-data:/workspace:ro # Docker-in-Docker requires root to access the Docker socket on macOS. # The container is already sandboxed by Docker — root inside != root on host. user: root diff --git a/substrate-api/__tests__/app-security.test.ts b/substrate-api/__tests__/app-security.test.ts index 880e58c..4e77b84 100644 --- a/substrate-api/__tests__/app-security.test.ts +++ b/substrate-api/__tests__/app-security.test.ts @@ -79,11 +79,14 @@ describe('substrate app security boundaries', () => { it('rejects unsafe system file reads through the route layer', async () => { const tempDir = await fs.mkdtemp(path.join('/tmp', 'substrate-system-')); + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'substrate-system-outside-')); const blockedFile = path.join(tempDir, '.env'); + const outsideFile = path.join(outsideDir, 'hosts'); const escapeLink = path.join(tempDir, 'hosts-link'); await fs.writeFile(blockedFile, 'SECRET=1\n', 'utf8'); - await fs.symlink('/etc/hosts', escapeLink); + await fs.writeFile(outsideFile, 'outside\n', 'utf8'); + await fs.symlink(outsideFile, escapeLink); const blockedResponse = await app.fetch(new Request( `http://substrate.local/fs/read?path=${encodeURIComponent(blockedFile)}`, diff --git a/substrate-api/__tests__/background-job-status.test.ts b/substrate-api/__tests__/background-job-status.test.ts index c926965..ac279bd 100644 --- a/substrate-api/__tests__/background-job-status.test.ts +++ b/substrate-api/__tests__/background-job-status.test.ts @@ -10,6 +10,18 @@ import { describe, expect, it } from 'vitest'; // parses successfully under `sh -n`. import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +function hasSh(): boolean { + try { + execFileSync('sh', ['-c', 'true'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} function buildStatusScript(jobDir: string): string { // Mirror the fixed join('\n') pattern. @@ -25,7 +37,9 @@ function buildStatusScript(jobDir: string): string { ].join('\n'); } -describe('background job status script', () => { +const describeIfSh = hasSh() ? describe : describe.skip; + +describeIfSh('background job status script', () => { it('parses cleanly under `sh -n`', () => { const script = buildStatusScript('/tmp/foo'); // sh -n exits non-zero on a syntax error. execFileSync throws on non-zero. @@ -49,7 +63,7 @@ describe('background job status script', () => { }); it('returns "running" when neither sentinel exists', () => { - const tmp = execFileSync('mktemp', ['-d']).toString().trim(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aether-job-')); const script = buildStatusScript(tmp); const out = execFileSync('sh', ['-c', script], { stdio: ['ignore', 'pipe', 'pipe'] }) .toString().trim(); @@ -57,8 +71,8 @@ describe('background job status script', () => { }); it('returns "done" when $JOB_DIR/done exists', () => { - const tmp = execFileSync('mktemp', ['-d']).toString().trim(); - execFileSync('touch', [`${tmp}/done`]); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aether-job-')); + fs.writeFileSync(path.join(tmp, 'done'), ''); const script = buildStatusScript(tmp); const out = execFileSync('sh', ['-c', script], { stdio: ['ignore', 'pipe', 'pipe'] }) .toString().trim(); @@ -66,9 +80,9 @@ describe('background job status script', () => { }); it('returns "cancelled" when cancelledAt exists, overriding done', () => { - const tmp = execFileSync('mktemp', ['-d']).toString().trim(); - execFileSync('touch', [`${tmp}/done`]); - execFileSync('touch', [`${tmp}/cancelledAt`]); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aether-job-')); + fs.writeFileSync(path.join(tmp, 'done'), ''); + fs.writeFileSync(path.join(tmp, 'cancelledAt'), ''); const script = buildStatusScript(tmp); const out = execFileSync('sh', ['-c', script], { stdio: ['ignore', 'pipe', 'pipe'] }) .toString().trim(); diff --git a/substrate-api/__tests__/container-exec-hardening.test.ts b/substrate-api/__tests__/container-exec-hardening.test.ts index 28cd533..2ace079 100644 --- a/substrate-api/__tests__/container-exec-hardening.test.ts +++ b/substrate-api/__tests__/container-exec-hardening.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import http from 'http'; +import { AddressInfo } from 'net'; import { ContainerManager } from '../src/services/container-manager'; describe('container exec hardening', () => { @@ -23,4 +25,40 @@ describe('container exec hardening', () => { expect(inspect).toHaveBeenCalled(); expect(dockerRequest).toHaveBeenCalledTimes(3); }); + + it('strips browser credentials from proxied container requests', async () => { + const manager = new ContainerManager(); + let receivedHeaders: http.IncomingHttpHeaders = {}; + const server = http.createServer((req, res) => { + receivedHeaders = req.headers; + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('ok'); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const port = (server.address() as AddressInfo).port; + const response = await manager.proxyRequest( + '127.0.0.1', + port, + 'GET', + '/preview', + { + cookie: 'sid=secret', + authorization: 'Bearer secret', + 'x-substrate-key': 'substrate-secret', + 'content-type': 'text/plain', + }, + ); + response.resume(); + await new Promise((resolve) => response.on('end', resolve)); + + expect(receivedHeaders.cookie).toBeUndefined(); + expect(receivedHeaders.authorization).toBeUndefined(); + expect(receivedHeaders['x-substrate-key']).toBeUndefined(); + expect(receivedHeaders['content-type']).toBe('text/plain'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); }); diff --git a/substrate-api/__tests__/route-integration.test.ts b/substrate-api/__tests__/route-integration.test.ts index 6f41fb6..2871245 100644 --- a/substrate-api/__tests__/route-integration.test.ts +++ b/substrate-api/__tests__/route-integration.test.ts @@ -51,7 +51,12 @@ describe('substrate route integration', () => { headers?: Record; }; body.statusCode = 200; - body.headers = { 'content-type': 'text/plain' }; + body.headers = { + 'content-type': 'text/plain', + 'set-cookie': 'preview_session=evil; Domain=substrate.local', + 'clear-site-data': '"cookies"', + 'strict-transport-security': 'max-age=0', + }; return body as any; }, } as any); @@ -78,6 +83,9 @@ describe('substrate route integration', () => { )); expect(allowed.status).toBe(200); expect(await allowed.text()).toBe('proxied ok'); + expect(allowed.headers.has('set-cookie')).toBe(false); + expect(allowed.headers.has('clear-site-data')).toBe(false); + expect(allowed.headers.has('strict-transport-security')).toBe(false); expect(proxyCalls).toEqual([{ path: '/assets/app.js?v=42', method: 'GET' }]); }); diff --git a/substrate-api/src/routes/containers.ts b/substrate-api/src/routes/containers.ts index 27e70a7..437751b 100644 --- a/substrate-api/src/routes/containers.ts +++ b/substrate-api/src/routes/containers.ts @@ -512,9 +512,16 @@ app.all('/:id/proxy/:port{[0-9]+}/*', async (c) => { // Build response headers — strip hop-by-hop and add security headers const hopByHop = new Set(['connection', 'keep-alive', 'transfer-encoding', 'trailer', 'upgrade']); + const blockedResponseHeaders = new Set([ + 'set-cookie', + 'set-cookie2', + 'clear-site-data', + 'strict-transport-security', + ]); const responseHeaders = new Headers(); for (const [key, val] of Object.entries(proxyRes.headers)) { - if (val && !hopByHop.has(key.toLowerCase())) { + const lowerKey = key.toLowerCase(); + if (val && !hopByHop.has(lowerKey) && !blockedResponseHeaders.has(lowerKey)) { const values = Array.isArray(val) ? val : [val]; for (const v of values) { responseHeaders.append(key, v); diff --git a/substrate-api/src/routes/workspace.ts b/substrate-api/src/routes/workspace.ts index 83aad21..b1bde83 100644 --- a/substrate-api/src/routes/workspace.ts +++ b/substrate-api/src/routes/workspace.ts @@ -6,6 +6,7 @@ import { errMessage } from '../services/errors'; import { resolveExistingPathWithinRoot } from '../utils/path-security'; import { commandText, inferMode, parseExecCommand } from '../utils/exec-command'; import { getMimeType } from '../utils/mime'; +import { releaseExecSlot, reserveExecSlot } from '../utils/exec-rate-limit'; const app = new Hono(); @@ -133,6 +134,11 @@ app.post('/files/write', async (c) => { // POST /workspace/exec — Execute command (runs inside isolated workspace container) app.post('/exec', async (c) => { + const slot = reserveExecSlot('workspace'); + if (!slot.ok) { + if ('retryAfterS' in slot && slot.retryAfterS) c.header('Retry-After', String(slot.retryAfterS)); + return c.json({ error: slot.reason }, 429); + } try { const { command, mode: rawMode, workdir, background, timeoutMs } = await c.req.json(); if (!command) { @@ -173,11 +179,18 @@ app.post('/exec', async (c) => { console.error('[Workspace] Exec error:', errMessage(error)); const status = error.statusCode || 500; return c.json({ error: status < 500 ? errMessage(error) : 'Failed to execute command' }, status); + } finally { + releaseExecSlot('workspace'); } }); // POST /workspace/install — Install packages app.post('/install', async (c) => { + const slot = reserveExecSlot('workspace'); + if (!slot.ok) { + if ('retryAfterS' in slot && slot.retryAfterS) c.header('Retry-After', String(slot.retryAfterS)); + return c.json({ error: slot.reason }, 429); + } try { const { packages, manager, background } = await c.req.json(); if (!packages || !Array.isArray(packages) || packages.length === 0) { @@ -244,6 +257,8 @@ app.post('/install', async (c) => { console.error('[Workspace] Install error:', errMessage(error)); const status = error.statusCode || 500; return c.json({ error: status < 500 ? errMessage(error) : 'Failed to install packages' }, status); + } finally { + releaseExecSlot('workspace'); } }); diff --git a/substrate-api/src/services/container-manager.ts b/substrate-api/src/services/container-manager.ts index b2cf021..4189b58 100644 --- a/substrate-api/src/services/container-manager.ts +++ b/substrate-api/src/services/container-manager.ts @@ -1198,9 +1198,19 @@ export class ContainerManager implements IService { 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'x-substrate-key', // Don't leak the API key to the container + 'authorization', + 'cookie', ]); const forwardHeaders: Record = {}; - for (const [key, val] of Object.entries(headers)) { + const maybeHeaders = headers as unknown as { + entries?: () => IterableIterator<[string, string]>; + forEach?: (callback: (value: string, key: string) => void) => void; + }; + const headerEntries = + typeof maybeHeaders.forEach === 'function' && typeof maybeHeaders.entries === 'function' + ? Array.from(maybeHeaders.entries()) + : Object.entries(headers); + for (const [key, val] of headerEntries) { if (val !== undefined && !hopByHop.has(key.toLowerCase())) { forwardHeaders[key] = val; }