Skip to content
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ RUN pnpm prune --prod --ignore-scripts
FROM node:20-slim AS runtime
ENV NODE_ENV="production"
ENV PORT="5173"
# Bind all interfaces so published ports work; the bind guard in
# instrument.server.mjs then requires BOLT_AUTH_TOKEN to actually start,
# because the runtime API grants shell execution and file access.
ENV HOST="0.0.0.0"
WORKDIR /app

# git: needed by api.git-info.ts (execSync('git ...'))
Expand All @@ -53,11 +57,14 @@ COPY --from=build /app/build ./build
# Copy package.json (needed by @react-router/serve)
COPY --from=build /app/package.json ./

# Copy the server instrumentation / bind-policy guard loaded via --import
COPY --from=build /app/instrument.server.mjs ./

# Non-root user for security
RUN groupadd --system --gid 1001 appgroup && \
useradd --system --uid 1001 --gid appgroup --create-home appuser && \
chown -R appuser:appgroup /app
USER appuser

EXPOSE 5173
CMD ["node", "node_modules/@react-router/serve/dist/cli.js", "./build/server/index.js"]
CMD ["node", "--import", "./instrument.server.mjs", "node_modules/@react-router/serve/dist/cli.js", "./build/server/index.js"]
29 changes: 18 additions & 11 deletions app/lib/api/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,26 @@ export function setDecryptor(fn: DecryptFn): void {
* Attempt to decrypt a cookie value. Values prefixed with "enc:" are treated
* as encrypted; all others pass through unchanged (plaintext migration).
*
* On decryption failure (key rotation, corruption), falls back to returning
* the raw ciphertext without the prefix — never throws.
* On decryption failure (missing decryptor, key rotation, corruption) returns
* `null` so the caller can drop the value. Returning the raw ciphertext would
* hand the encrypted blob downstream as if it were a real API key, producing
* confusing auth failures against providers. Never throws.
*/
function decryptCookieValue(value: string): string {
function decryptCookieValue(value: string): string | null {
if (!value.startsWith(ENC_PREFIX)) {
return value;
}

const ciphertext = value.slice(ENC_PREFIX.length);

if (!_decryptor) {
logger.warn('Encrypted cookie value found but no decryptor registered, returning raw ciphertext');
return ciphertext;
logger.warn('Encrypted cookie value found but no decryptor registered; dropping value');
return null;
}

try {
return _decryptor(ciphertext);
return _decryptor(value.slice(ENC_PREFIX.length));
} catch (error) {
logger.warn('Failed to decrypt cookie value, falling back to raw ciphertext:', error);
return ciphertext;
logger.warn('Failed to decrypt cookie value; dropping value:', error);
return null;
}
}

Expand Down Expand Up @@ -84,7 +84,14 @@ export function getApiKeysFromCookie(cookieHeader: string | null): Record<string

for (const [provider, value] of Object.entries(keys)) {
if (typeof value === 'string' && value.startsWith(ENC_PREFIX)) {
keys[provider] = decryptCookieValue(value);
const decrypted = decryptCookieValue(value);

// Drop keys we can't decrypt rather than leaking ciphertext downstream.
if (decrypted === null) {
delete keys[provider];
} else {
keys[provider] = decrypted;
}
}
}

Expand Down
18 changes: 13 additions & 5 deletions app/lib/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,23 @@ export function checkRateLimit(request: Request, endpoint: string): { allowed: b
}

/**
* Get client IP address from request
* Get client IP address from request.
*
* Forwarding headers (`x-forwarded-for`, `x-real-ip`, `cf-connecting-ip`) are
* client-controlled and trivially spoofed, so an attacker could bypass rate
* limiting by rotating them. Only trust them when the deployment sits behind a
* known proxy and opts in via `TRUST_PROXY_HEADERS=true`. Otherwise fall back to
* a single shared bucket, which cannot be spoofed apart.
*/
function getClientIP(request: Request): string {
// Try various headers that might contain the real IP
const forwardedFor = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip');
if (process.env.TRUST_PROXY_HEADERS !== 'true') {
return 'local';
}

const cfConnectingIP = request.headers.get('cf-connecting-ip');
const realIP = request.headers.get('x-real-ip');
const forwardedFor = request.headers.get('x-forwarded-for');

// Return the first available IP or a fallback
return cfConnectingIP || realIP || forwardedFor?.split(',')[0]?.trim() || 'unknown';
}

Expand Down
34 changes: 32 additions & 2 deletions app/routes/api.runtime.terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
start(controller) {
const encoder = new TextEncoder();

// Track listeners registered on sessions so we can remove them when

Check failure on line 79 in app/routes/api.runtime.terminal.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test

Expected a block comment instead of consecutive line comments
// the client disconnects — otherwise every reconnect leaks a listener
// that keeps enqueuing on a closed controller.
const disposers: Array<() => void> = [];

// Search all runtimes for the session
for (const projectId of manager.listProjects()) {
// We need to use an async IIFE to manage the Promise-based getRuntime
Expand All @@ -89,14 +94,35 @@
}

// Register data listener for this session
session.dataListeners.push((data: string) => {
const onData = (data: string) => {
try {
const payload = JSON.stringify({ type: 'data', data });
controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
} catch {
// Stream may have been closed
}
});
};
session.dataListeners.push(onData);

const dispose = () => {
const idx = session.dataListeners.indexOf(onData);

if (idx !== -1) {
session.dataListeners.splice(idx, 1);
}
};

/*
* If the client already disconnected while we were resolving the
* runtime, remove the listener immediately — the abort handler
* ran before this disposer was registered.
*/
if (request.signal.aborted) {
dispose();
return;
}

disposers.push(dispose);

// Listen for process exit
session.exitPromise
Expand Down Expand Up @@ -147,6 +173,10 @@
request.signal.addEventListener('abort', () => {
clearInterval(heartbeat);

for (const dispose of disposers) {
dispose();
}

try {
controller.close();
} catch {
Expand Down
22 changes: 22 additions & 0 deletions instrument.server.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
/*
* Enforce a safe bind policy before the server starts. The app exposes a
* runtime API that executes arbitrary shell commands and reads/writes files, so
* it must never listen on a non-loopback interface without authentication.
* Default to loopback; require BOLT_AUTH_TOKEN to bind anything else. This runs
* for every entrypoint that loads this file via `node --import`.
*/
{
const host = process.env.HOST;
const isLoopback = !host || host === '127.0.0.1' || host === '::1' || host === 'localhost';

if (!host) {
process.env.HOST = '127.0.0.1';
} else if (!isLoopback && !process.env.BOLT_AUTH_TOKEN) {
console.error(
`[Bolt] Refusing to bind ${host} without authentication: the runtime API grants shell execution ` +
`and file access. Set BOLT_AUTH_TOKEN to expose the server beyond localhost, or unset HOST to bind 127.0.0.1.`,
);
process.exit(1);
}
}

import * as Sentry from '@sentry/node';

const dsn = process.env.SENTRY_DSN;
Expand Down
28 changes: 25 additions & 3 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ import { handleUpgrade } from './app/lib/.server/ws/ws-server';

const PORT = Number(process.env.PORT) || 5173;

/*
* Bind to loopback by default. The app exposes a runtime API that executes
* arbitrary shell commands and reads/writes files, so a non-loopback bind
* (e.g. HOST=0.0.0.0 for Docker port publishing) is only allowed when
* BOLT_AUTH_TOKEN is configured — otherwise anyone who can reach the port gets
* host command execution. Set HOST + BOLT_AUTH_TOKEN to expose it deliberately.
*/
const HOST = process.env.HOST || '127.0.0.1';

function isLoopbackHost(host: string): boolean {
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
}

function log(level: string, ...args: unknown[]) {
const timestamp = new Date().toISOString();

Expand Down Expand Up @@ -159,9 +172,18 @@ server.on('upgrade', (req, socket, head) => {
socket.destroy();
});

server.listen(PORT, () => {
log('info', `Bolt server listening on http://localhost:${PORT}`);
log('info', `WebSocket endpoint: ws://localhost:${PORT}/ws`);
if (!isLoopbackHost(HOST) && !process.env.BOLT_AUTH_TOKEN) {
log(
'error',
`Refusing to bind ${HOST} without authentication: the runtime API grants shell execution and file access. ` +
`Set BOLT_AUTH_TOKEN to expose the server beyond localhost, or unset HOST to bind 127.0.0.1.`,
);
process.exit(1);
}

server.listen(PORT, HOST, () => {
log('info', `Bolt server listening on http://${HOST}:${PORT}`);
log('info', `WebSocket endpoint: ws://${HOST}:${PORT}/ws`);
});

/*
Expand Down
Binary file removed test-01-loaded.png
Binary file not shown.
Binary file removed test-04-failedload.png
Binary file not shown.
Loading