diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index ccddb5e..c7584d6 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -33,4 +33,15 @@ export const config = Env.parse({ ADMIN_NAME: process.env.ADMIN_NAME, }); +// INFRA-001: refuse to boot in production with the placeholder encryption key. +// The zero-key passes the min(64) length check but would render every stored +// secret effectively plaintext. +const ZERO_KEY = '0'.repeat(64); +if (config.NODE_ENV === 'production' && config.SECRETS_ENCRYPTION_KEY === ZERO_KEY) { + throw new Error( + 'SECRETS_ENCRYPTION_KEY is the all-zero placeholder. Set a real 32-byte hex key ' + + '(openssl rand -hex 32) before running in production.', + ); +} + export type Config = z.infer; diff --git a/apps/api/src/routes/oauth.ts b/apps/api/src/routes/oauth.ts index e383f5d..4e57dcb 100644 --- a/apps/api/src/routes/oauth.ts +++ b/apps/api/src/routes/oauth.ts @@ -6,6 +6,7 @@ import { createDb, eq, gt, + isNull, mcpServers, oauthClients, oauthCodes, @@ -21,8 +22,10 @@ function sha256(input: string): string { return crypto.createHash('sha256').update(input).digest('hex'); } +// OAUTH-002: S256 only. The 'plain' PKCE method is deprecated by OAuth 2.1 and +// our AS metadata already advertises S256 exclusively — accepting plain would be +// a downgrade the metadata says we don't allow. function pkceVerify(verifier: string, challenge: string, method: string): boolean { - if (method === 'plain') return verifier === challenge; if (method !== 'S256') return false; const computed = crypto.createHash('sha256').update(verifier).digest('base64url'); return computed === challenge; @@ -127,7 +130,7 @@ export async function oauthRoutes(app: FastifyInstance): Promise { client_id: z.string(), redirect_uri: z.string().url(), code_challenge: z.string(), - code_challenge_method: z.enum(['S256', 'plain']).default('S256'), + code_challenge_method: z.literal('S256').default('S256'), state: z.string().optional(), scope: z.string().optional(), resource: z.string().url(), @@ -210,7 +213,17 @@ export async function oauthRoutes(app: FastifyInstance): Promise { return reply.code(401).send({ error: 'invalid_client' }); } } - await db.update(oauthCodes).set({ consumedAt: new Date() }).where(eq(oauthCodes.id, row.code.id)); + // OAUTH-001: consume the code atomically. The UPDATE only succeeds while + // consumed_at is still NULL, so two requests racing the same code can + // never both mint a token — the loser gets zero rows back. + const consumed = await db + .update(oauthCodes) + .set({ consumedAt: new Date() }) + .where(and(eq(oauthCodes.id, row.code.id), isNull(oauthCodes.consumedAt))) + .returning({ id: oauthCodes.id }); + if (consumed.length === 0) { + return reply.code(400).send({ error: 'invalid_grant' }); + } const accessToken = await signAccessToken({ subject: row.code.userId ?? row.client.clientId, diff --git a/apps/api/src/routes/servers.ts b/apps/api/src/routes/servers.ts index e2c934b..596d7c4 100644 --- a/apps/api/src/routes/servers.ts +++ b/apps/api/src/routes/servers.ts @@ -1,6 +1,8 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { and, builds, buildLogs, createDb, desc, eq, mcpServers, secrets, sql, templates } from '@bmm/db'; +import { getSession } from '@bmm/auth'; +import { stopContainer } from '../lib/docker.js'; import { CreateServerInput, IterateServerInput, @@ -305,15 +307,30 @@ export async function serverRoutes(app: FastifyInstance): Promise { // WebSocket — live build stream app.get('/v1/builds/:id/stream', { websocket: true }, async (socket, req) => { + const fail = (message: string) => { + socket.send(JSON.stringify({ type: 'error', message, at: new Date().toISOString() })); + socket.close(); + }; + const Params = z.object({ id: z.string().uuid() }); const parsed = Params.safeParse(req.params); - if (!parsed.success) { - socket.send(JSON.stringify({ type: 'error', message: 'invalid_id', at: new Date().toISOString() })); - socket.close(); - return; - } + if (!parsed.success) return fail('invalid_id'); const buildId = parsed.data.id; + // API-SERVERS-002: the WS endpoint must authorize like its REST twin. + // Authenticate from the session cookie and confirm the build belongs to + // the caller's org before streaming anything. + const session = await getSession(req.cookies['bmm_session']); + if (!session) return fail('unauthorized'); + + const [owned] = await db + .select({ orgId: mcpServers.orgId }) + .from(builds) + .innerJoin(mcpServers, eq(mcpServers.id, builds.serverId)) + .where(eq(builds.id, buildId)) + .limit(1); + if (!owned || owned.orgId !== session.orgId) return fail('not_found'); + // Replay any persisted logs first const logs = await db .select() @@ -375,6 +392,16 @@ export async function serverRoutes(app: FastifyInstance): Promise { .where(and(eq(mcpServers.id, parsed.data.id), eq(mcpServers.orgId, user.orgId))) .limit(1); if (!server) return reply.code(404).send({ error: 'not_found' }); + // API-SERVERS-001: tear down the running container before deleting the row, + // otherwise it keeps serving traffic with the user's secrets baked in. + let containerStopped = false; + if (server.containerId) { + const result = await stopContainer(server.containerId); + containerStopped = result.ok; + if (!result.ok) { + app.log.warn({ containerId: server.containerId, detail: result.detail }, 'delete: stop failed'); + } + } await db.delete(mcpServers).where(eq(mcpServers.id, server.id)); await audit({ orgId: user.orgId, @@ -382,7 +409,7 @@ export async function serverRoutes(app: FastifyInstance): Promise { action: 'server.delete', resourceType: 'server', resourceId: server.id, - metadata: { slug: server.slug, name: server.name }, + metadata: { slug: server.slug, name: server.name, containerStopped }, ipAddress: req.ip, }); return reply.send({ ok: true });