Skip to content

Commit baa1ea6

Browse files
nichocharclaude
andcommitted
fix(security): reject non-local origins and bind loopback by default
Srcbook has no authentication and can execute arbitrary code, so the only thing separating the user's own tab from any other page they have open is the request origin. Nothing was checking it. Verified before this change, against a running instance: any web page could read every provider API key from GET /api/settings, read arbitrary files from disk via POST /api/file, and — because the websocket accepted any origin — open a socket and drive cell:update + cell:exec to run code as the user. The server also bound 0.0.0.0, so all of it was reachable from the whole network. Changes: - New server/security.mts owns the origin allowlist. Local origins on any port are allowed, plus anything in SRCBOOK_ALLOWED_ORIGINS. Requests with no Origin header are allowed, since browsers always set it on cross-origin requests and non-browser clients (the CLI, curl) legitimately omit it. - HTTP: the 41 per-route cors() calls become one cors + verifyOrigin pair on the router. Disallowed origins get a 403 rather than just a missing CORS header — CORS alone stops an attacker reading a response, but the request still executes, which is all you need for POST /api/settings. - WebSocket: origin checked via verifyClient, so rejection happens at the handshake. Checking in the connection handler is too late; the client observes an open socket that is only torn down afterwards. - Bind 127.0.0.1 unless HOST says otherwise, and warn when it doesn't. This makes docker-compose's HOST variable real — it was previously read by nothing. Compose now sets HOST=0.0.0.0 because Docker forwards published ports to the container IP; host-side exposure stays controlled by HOST_BIND, which already defaulted to loopback. - Guard process.send in the CLI server. It only exists under IPC, so running the server directly crashed it right after printing "running at ..." — which is how this was found while testing the bind change. 12 tests for the allowlist, covering the substring cases that make this kind of check go wrong (localhost.evil.com, example.com.evil.com). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b0af817 commit baa1ea6

9 files changed

Lines changed: 302 additions & 57 deletions

File tree

‎docker-compose.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ services:
1414
create_host_path: true
1515
environment:
1616
- NODE_ENV=production
17-
- HOST=${HOST_BIND:-127.0.0.1}
17+
# Bind every interface *inside* the container — Docker forwards published ports
18+
# to the container's IP, so binding loopback here would make the port mapping
19+
# unreachable. Exposure is controlled by HOST_BIND on the ports line above,
20+
# which defaults to loopback on the host.
21+
- HOST=0.0.0.0
1822
- SRCBOOK_INSTALL_DEPS=true
1923
command: ['pnpm', 'start']

‎packages/api/dev-server.mts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@ import { WebSocketServer as WsWebSocketServer } from 'ws';
33

44
import app from './server/http.mjs';
55
import webSocketServer from './server/ws.mjs';
6+
import { bindHost, webSocketServerOptions } from './server/security.mjs';
67

78
export { SRCBOOK_DIR } from './constants.mjs';
89

910
const server = http.createServer(app);
1011

11-
const wss = new WsWebSocketServer({ server });
12+
const wss = new WsWebSocketServer({ server, ...webSocketServerOptions });
1213
wss.on('connection', webSocketServer.onConnection);
1314

1415
const port = process.env.PORT || 2150;
15-
server.listen(port, () => {
16-
console.log(`Server is running at http://localhost:${port}`);
16+
const host = bindHost();
17+
server.listen(Number(port), host, () => {
18+
console.log(`Server is running at http://${host}:${port}`);
1719
});
1820

1921
process.on('SIGINT', async function () {

‎packages/api/index.mts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ import app from './server/http.mjs';
22
import wss from './server/ws.mjs';
33
import { SRCBOOKS_DIR } from './constants.mjs';
44
import { posthog } from './posthog-client.mjs';
5+
import { bindHost, webSocketServerOptions } from './server/security.mjs';
56

6-
export { app, wss, SRCBOOKS_DIR, posthog };
7+
export { app, wss, SRCBOOKS_DIR, posthog, bindHost, webSocketServerOptions };

‎packages/api/server/http.mts‎

Lines changed: 28 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,19 @@ import { readdir } from '../fs-utils.mjs';
3535
import { EXAMPLE_SRCBOOKS } from '../srcbook/examples.mjs';
3636
import { pathToSrcbook } from '../srcbook/path.mjs';
3737
import { isSrcmdPath } from '../srcmd/paths.mjs';
38+
import { corsOptions, verifyOrigin } from './security.mjs';
3839

3940
const app: Application = express();
4041

4142
const router = express.Router();
4243

44+
// Order matters: cors answers preflight requests, verifyOrigin rejects everything
45+
// from an origin we don't trust, and only then do we parse a body.
46+
router.use(cors(corsOptions));
47+
router.use(verifyOrigin);
4348
router.use(express.json());
4449

45-
router.options('/file', cors());
46-
47-
router.post('/file', cors(), async (req, res) => {
50+
router.post('/file', async (req, res) => {
4851
const { file } = req.body as {
4952
file: string;
5053
};
@@ -69,14 +72,12 @@ router.post('/file', cors(), async (req, res) => {
6972
}
7073
});
7174

72-
router.options('/examples', cors());
73-
router.get('/examples', cors(), (_, res) => {
75+
router.get('/examples', (_, res) => {
7476
return res.json({ result: EXAMPLE_SRCBOOKS });
7577
});
7678

7779
// Create a new srcbook
78-
router.options('/srcbooks', cors());
79-
router.post('/srcbooks', cors(), async (req, res) => {
80+
router.post('/srcbooks', async (req, res) => {
8081
const { name, language } = req.body;
8182

8283
// TODO: Zod
@@ -102,8 +103,7 @@ router.post('/srcbooks', cors(), async (req, res) => {
102103
}
103104
});
104105

105-
router.options('/srcbooks/:id', cors());
106-
router.delete('/srcbooks/:id', cors(), async (req, res) => {
106+
router.delete('/srcbooks/:id', async (req, res) => {
107107
const { id } = req.params;
108108
const srcbookDir = pathToSrcbook(id);
109109
removeSrcbook(srcbookDir);
@@ -113,8 +113,7 @@ router.delete('/srcbooks/:id', cors(), async (req, res) => {
113113
});
114114

115115
// Import a srcbook from a .src.md file or srcmd text.
116-
router.options('/import', cors());
117-
router.post('/import', cors(), async (req, res) => {
116+
router.post('/import', async (req, res) => {
118117
const { path, text, url } = req.body;
119118

120119
if (typeof path === 'string' && !isSrcmdPath(path)) {
@@ -143,8 +142,7 @@ router.post('/import', cors(), async (req, res) => {
143142
});
144143

145144
// Generate a srcbook using AI from a simple string query
146-
router.options('/generate', cors());
147-
router.post('/generate', cors(), async (req, res) => {
145+
router.post('/generate', async (req, res) => {
148146
const { query } = req.body;
149147

150148
try {
@@ -160,8 +158,7 @@ router.post('/generate', cors(), async (req, res) => {
160158
});
161159

162160
// Generate a cell using AI from a query string
163-
router.options('/sessions/:id/generate_cells', cors());
164-
router.post('/sessions/:id/generate_cells', cors(), async (req, res) => {
161+
router.post('/sessions/:id/generate_cells', async (req, res) => {
165162
// @TODO: zod
166163
const { insertIdx, query } = req.body;
167164

@@ -179,8 +176,7 @@ router.post('/sessions/:id/generate_cells', cors(), async (req, res) => {
179176
});
180177

181178
// Test that the AI generation is working with the current configuration
182-
router.options('/ai/healthcheck', cors());
183-
router.get('/ai/healthcheck', cors(), async (_req, res) => {
179+
router.get('/ai/healthcheck', async (_req, res) => {
184180
try {
185181
const result = await healthcheck();
186182
return res.json({ error: false, result });
@@ -192,8 +188,7 @@ router.get('/ai/healthcheck', cors(), async (_req, res) => {
192188
});
193189

194190
// Open an existing srcbook by passing a path to the srcbook's directory
195-
router.options('/sessions', cors());
196-
router.post('/sessions', cors(), async (req, res) => {
191+
router.post('/sessions', async (req, res) => {
197192
const { path } = req.body;
198193

199194
posthog.capture({ event: 'user opened srcbook' });
@@ -213,14 +208,12 @@ router.post('/sessions', cors(), async (req, res) => {
213208
}
214209
});
215210

216-
router.get('/sessions', cors(), async (_req, res) => {
211+
router.get('/sessions', async (_req, res) => {
217212
const sessions = await listSessions();
218213
return res.json({ error: false, result: Object.values(sessions).map(sessionToResponse) });
219214
});
220215

221-
router.options('/sessions/:id', cors());
222-
223-
router.get('/sessions/:id', cors(), async (req, res) => {
216+
router.get('/sessions/:id', async (req, res) => {
224217
const { id } = req.params;
225218

226219
try {
@@ -243,8 +236,7 @@ router.get('/sessions/:id', cors(), async (req, res) => {
243236
}
244237
});
245238

246-
router.options('/sessions/:id/export-text', cors());
247-
router.get('/sessions/:id/export-text', cors(), async (req, res) => {
239+
router.get('/sessions/:id/export-text', async (req, res) => {
248240
const session = await findSession(req.params.id);
249241

250242
posthog.capture({ event: 'user exported srcbook' });
@@ -261,29 +253,26 @@ router.get('/sessions/:id/export-text', cors(), async (req, res) => {
261253
}
262254
});
263255

264-
router.options('/sessions/:id/secrets/:name', cors());
265-
router.put('/sessions/:id/secrets/:name', cors(), async (req, res) => {
256+
router.put('/sessions/:id/secrets/:name', async (req, res) => {
266257
const { id, name } = req.params;
267258
await associateSecretWithSession(name, id);
268259
await updateSessionEnvTypeDeclarations(id);
269260
return res.status(204).end();
270261
});
271262

272-
router.delete('/sessions/:id/secrets/:name', cors(), async (req, res) => {
263+
router.delete('/sessions/:id/secrets/:name', async (req, res) => {
273264
const { id, name } = req.params;
274265
await disassociateSecretWithSession(name, id);
275266
await updateSessionEnvTypeDeclarations(id);
276267
return res.status(204).end();
277268
});
278269

279-
router.options('/settings', cors());
280-
281-
router.get('/settings', cors(), async (_req, res) => {
270+
router.get('/settings', async (_req, res) => {
282271
const config = await getConfig();
283272
return res.json({ error: false, result: config });
284273
});
285274

286-
router.post('/settings', cors(), async (req, res) => {
275+
router.post('/settings', async (req, res) => {
287276
try {
288277
const updated = await updateConfig(req.body);
289278

@@ -300,39 +289,34 @@ router.post('/settings', cors(), async (req, res) => {
300289
}
301290
});
302291

303-
router.options('/secrets', cors());
304-
305-
router.get('/secrets', cors(), async (_req, res) => {
292+
router.get('/secrets', async (_req, res) => {
306293
const secrets = await getSecrets();
307294
return res.json({ result: secrets });
308295
});
309296

310297
// Create a new secret
311-
router.post('/secrets', cors(), async (req, res) => {
298+
router.post('/secrets', async (req, res) => {
312299
const { name, value } = req.body;
313300
posthog.capture({ event: 'user created secret' });
314301
const updated = await addSecret(name, value);
315302
return res.json({ result: updated });
316303
});
317304

318-
router.options('/secrets/:name', cors());
319-
320-
router.post('/secrets/:name', cors(), async (req, res) => {
305+
router.post('/secrets/:name', async (req, res) => {
321306
const { name } = req.params;
322307
const { name: newName, value } = req.body;
323308
await removeSecret(name);
324309
const updated = await addSecret(newName, value);
325310
return res.json({ result: updated });
326311
});
327312

328-
router.delete('/secrets/:name', cors(), async (req, res) => {
313+
router.delete('/secrets/:name', async (req, res) => {
329314
const { name } = req.params;
330315
const updated = await removeSecret(name);
331316
return res.json({ result: updated });
332317
});
333318

334-
router.options('/feedback', cors());
335-
router.post('/feedback', cors(), async (req, res) => {
319+
router.post('/feedback', async (req, res) => {
336320
const { feedback, email } = req.body;
337321
// Every time you modify the appscript here, you'll need to update the URL below
338322
// @TODO: once we have an env variable setup, we can use that here.
@@ -361,8 +345,7 @@ type NpmSearchResult = {
361345
* Returns the name, version and description of the packages.
362346
* Consider debouncing calls to this API on the client side.
363347
*/
364-
router.options('/npm/search', cors());
365-
router.get('/npm/search', cors(), async (req, res) => {
348+
router.get('/npm/search', async (req, res) => {
366349
const { q, size } = req.query;
367350
const response = await fetch(`https://registry.npmjs.org/-/v1/search?text=${q}&size=${size}`);
368351
if (!response.ok) {
@@ -375,8 +358,7 @@ router.get('/npm/search', cors(), async (req, res) => {
375358
return res.json({ result: results });
376359
});
377360

378-
router.options('/subscribe', cors());
379-
router.post('/subscribe', cors(), async (req, res) => {
361+
router.post('/subscribe', async (req, res) => {
380362
const { email } = req.body;
381363
const hubResponse = await fetch('https://hub.srcbook.com/api/subscribe', {
382364
method: 'POST',

0 commit comments

Comments
 (0)