close the anthropic proxy secret, and re-close it on every boot
data/sidecar/claude-state.json holds proxySecret, and that string IS the owner's Claude Max subscription: officer-anthropic-proxy accepts it as x-api-key, swaps it for the owner's OAuth bearer and forwards to api.anthropic.com. it listens on 127.0.0.1, which every local account can reach. it was 664 in a 775 directory. nothing had ever set a mode - mkdirSync and Bun.write at ubuntu's default umask of 002. confirmed by reading the live secret as 'nobody', an account with no relationship to the platform, and by confirming the proxy 401s without the key so the secret is the only control. this does NOT mean members shared the subscription. spawnClaudeAsMember runs their claude through setpriv --reset-env with a six-name allowlist, so ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY cannot cross into it and their turns go straight to anthropic on their own OAuth. that design is intact - it could just be walked around by anyone with a shell. fixed twice on purpose. state.ts sets 0600/0700 on every write, which is the authoritative fix, and on every write rather than at creation because writeFileSync leaves an existing file's mode alone. secureRuntimeSecrets() sweeps at boot because officer-anthropic-proxy is a separate pm2 process that can recreate the file hours after officer started, and because every existing install has the old mode until something repairs it. it repairs rather than refusing to boot, unlike assertSecretsClosed beside it. the line is who wrote the file: .env is the installer's and we must not guess at it; this is our own output at the machine's umask, so refusing to start would punish the user for our bug. the per-account copies are swept too. no live secret in them today, but they map session keys to transcript uuids for named accounts. tests verified to bite: removing the chmod while still reporting success fails four of them. a chmod does not undo exposure - the secret still needs rotating on the production host. TODO.md has the sequence, including that officer-claude-code caches it and must be restarted alongside the proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
||||
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
|
||||
/**
|
||||
@@ -47,9 +47,52 @@ export function initPaths(email?: string): void {
|
||||
// Default to proxy paths
|
||||
initPaths();
|
||||
|
||||
/*
|
||||
This file holds `proxySecret`, and that secret is the ONLY thing standing between a local account and the
|
||||
owner's Claude Max subscription.
|
||||
|
||||
`officer-anthropic-proxy` listens on 127.0.0.1, swaps an `x-api-key` matching this value for the owner's
|
||||
OAuth bearer, and forwards to api.anthropic.com. Any account on the box can reach a loopback port, so
|
||||
whoever can read this file can spend the owner's subscription — deliberately, and with no trace tying it
|
||||
to them.
|
||||
|
||||
The platform's whole per-member design is built so this CANNOT happen by accident: `spawnClaudeAsMember`
|
||||
runs a member's `claude` through `setpriv --reset-env` with a six-name allowlist, so `ANTHROPIC_BASE_URL`
|
||||
and `ANTHROPIC_API_KEY` cannot cross into it and their turns go straight to Anthropic on their own OAuth.
|
||||
None of that helps if the key is world-readable on disk — the isolation is intact and simply walked
|
||||
around.
|
||||
|
||||
It WAS world-readable. Ubuntu's default umask is 002, `mkdirSync`/`writeFileSync` take it, and nothing
|
||||
here ever set a mode: verified 2026-08-17 by reading the live secret as `nobody`, an account with no
|
||||
relationship to the platform at all. The same umask reasoning is already written down for the owner's
|
||||
home in `os-user.ts` — this file was simply not on anyone's list.
|
||||
|
||||
0600 on the file and 0700 on the directory. Applied on every write and not only at creation, because
|
||||
`writeFileSync` leaves an EXISTING file's mode alone, so creating it correctly would have fixed nothing
|
||||
on the installs that already have one.
|
||||
*/
|
||||
const SECRET_FILE_MODE = 0o600;
|
||||
const SECRET_DIR_MODE = 0o700;
|
||||
|
||||
function ensureDir() {
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
mkdirSync(stateDir, { recursive: true, mode: SECRET_DIR_MODE });
|
||||
}
|
||||
// Unconditional, for the directories that already exist at 0775.
|
||||
try {
|
||||
chmodSync(stateDir, SECRET_DIR_MODE);
|
||||
} catch {
|
||||
// Not ours to chmod. The boot sweep reports it; failing here would take the sidecar down over a
|
||||
// permission bit on a directory it may not own.
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp the state file to 0600. Safe to call when it does not exist yet. */
|
||||
function secureStateFile(): void {
|
||||
try {
|
||||
if (existsSync(stateFile)) chmodSync(stateFile, SECRET_FILE_MODE);
|
||||
} catch {
|
||||
// Same reasoning as ensureDir.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +142,9 @@ function migrateSessions(raw: Record<string, unknown>, ownerUserId?: number): Re
|
||||
export async function saveState(): Promise<void> {
|
||||
ensureDir();
|
||||
await Bun.write(stateFile, JSON.stringify(currentState, null, 2));
|
||||
// After the write, never before: `Bun.write` creates at the process umask, and this is the only moment
|
||||
// the file is guaranteed to exist.
|
||||
secureStateFile();
|
||||
}
|
||||
|
||||
export function getState(): PersistedState {
|
||||
|
||||
Reference in New Issue
Block a user