Skip to content

Commit 668b2dd

Browse files
authored
fix(cli): secure Prisma Studio's local HTTP server (#29890)
## Linked issue Private vulnerability report submitted through GitHub PVR. ## Summary Prisma Studio advertised a localhost URL while its HTTP server listened on every interface and returned wildcard CORS headers. Because `POST /bff` executes database queries, a reachable network client—or a malicious website visited while Studio was open—could read or modify the connected database. This change binds the Node, Bun, and Deno servers explicitly to `127.0.0.1`, rejects browser requests whose `Origin` is not the active `localhost` or `127.0.0.1` Studio URL before routing, and removes wildcard CORS responses. Origin-less local tooling remains supported. ## Root cause The Studio rewrite introduced for Prisma 7.0.0 treated the advertised `http://localhost` URL as if it also constrained the listener. It did not specify a host and enabled permissive CORS around a privileged SQL BFF. The later pre-bundled server refactor preserved both behaviors. ## Testing performed - Dependency-aware CLI build passed across 39 workspace projects. - Targeted ESLint passed for the affected source and test files. - Studio request-handler tests passed (20/20). - Studio listener tests passed (5/5). - Full CLI Vitest passed all Studio/security coverage; one unrelated existing `Init.vitest.ts` fixture failed. - Manual built-CLI test confirmed only `127.0.0.1` was listening, the host LAN address refused connections, same-origin SQL succeeded, and hostile-origin SQL returned 403 before execution. - Headed browser smoke test loaded Studio with no console warnings or errors. ## Skill update n/a — security hardening only; no user-facing CLI command, flag, API, config field, error code, or terminology changed. ## Checklist - [x] All commits are signed off (`git commit -s`) per the DCO. - [x] I read `CONTRIBUTING.md` and the change is scoped to one logical concern. - [x] Tests are updated. - [ ] The PR title uses a Linear ticket prefix — n/a, this work comes from a private vulnerability report rather than a Linear issue. - [x] The **Skill update** section is filled in. ## Notes for the reviewer The server and BFF are implemented in the maintained `v7` branch of `prisma/prisma`; `studio-core` supplies the bundled frontend and does not own this HTTP trust boundary. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Security** * Studio servers now bind exclusively to the local loopback address. * Requests from unauthorized origins, including cross-origin preflight requests, return `403 Forbidden`. * Permissive cross-origin access and related response headers have been removed. * **Bug Fixes** * Improved protection for Studio backend and telemetry endpoints. * Standardized local server behavior across supported runtimes. * Same-origin requests continue to work correctly. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Søren Bramer Schmidt <sorenbs@gmail.com>
1 parent e312a9c commit 668b2dd

5 files changed

Lines changed: 275 additions & 57 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
- **CLI commands**: Most commands already accept `--config` for custom config paths. Upcoming work removes `--schema` / `--url` in favour of config-based resolution. When editing CLI help text, keep examples aligned with new config-first workflow.
7979
- For isolated Studio verification, you can run `packages/cli/src/Studio.ts` directly via `pnpm exec tsx` and pass a config object that preserves `loadedFromFile`; this keeps SQLite URLs resolving relative to the config file while avoiding unrelated `packages/cli/src/bin.ts` imports.
8080
- Studio is now pre-bundled into `packages/cli/build/studio.js` and `packages/cli/build/studio.css`, served only through explicit routes in `packages/cli/src/Studio.ts` via the runtime-specific `packages/cli/src/studio-server.ts` bindings, and should keep listener-level coverage in `packages/cli/src/__tests__/studio-server.vitest.ts` because a past Node regression dropped `GET` bodies by treating them like `HEAD`.
81+
- Studio's local HTTP server must bind explicitly to `127.0.0.1`. Its request handler must reject browser requests whose `Origin` is not the active localhost or loopback URL before routing `/bff` or `/telemetry`; removing CORS response headers alone does not prevent cross-origin writes.
8182
- If Enter or click does not open a cell editor in Studio, verify that the current table and column are writable before assuming a keyboard regression; views/system tables and read-only columns legitimately stay non-editable.
8283

8384
- **Driver adapters datasource**:

‎packages/cli/src/Studio.ts‎

Lines changed: 55 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { runtime } from 'std-env'
1818

1919
import packageJson from '../package.json' assert { type: 'json' }
2020
import { STUDIO_CSS_FILE_NAME, STUDIO_JS_FILE_NAME, type StudioAdapterType } from './studio-frontend-shared'
21-
import { startStudioServer } from './studio-server'
21+
import { startStudioServer, STUDIO_SERVER_HOST } from './studio-server'
2222
import { UserFacingError } from './utils/errors'
2323
import { getPpgInfo } from './utils/ppgInfo'
2424

@@ -278,6 +278,15 @@ ${bold('Examples')}
278278
return this.help()
279279
}
280280

281+
const requestedPort = args['--port']
282+
283+
if (
284+
requestedPort !== undefined &&
285+
(!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65_535)
286+
) {
287+
return new UserFacingError('The Studio port must be an integer between 1 and 65535.')
288+
}
289+
281290
const connectionString = args['--url'] || config.datasource?.url
282291

283292
if (!connectionString) {
@@ -308,16 +317,16 @@ ${bold('Examples')}
308317
)
309318
const version = packageJson.dependencies['@prisma/studio-core']
310319
const ppgDbInfo = await getPpgInfo(connectionString)
320+
const port = await resolveStudioPort(requestedPort)
311321
const handler = createStudioRequestHandler({
312322
adapter: studioStuff.adapter,
313323
executor,
314324
ppgDbInfo,
325+
port,
315326
protocol,
316327
version,
317328
})
318329

319-
const port = args['--port'] || (await getPort({ port: DEFAULT_PORT, portRange: [MIN_PORT, DEFAULT_PORT - 1] }))
320-
321330
const url = `http://localhost:${port}`
322331

323332
const server = startStudioServer({
@@ -345,6 +354,18 @@ function getUrlBasePath(url: string | undefined, configPath: string | null): str
345354
return url ? process.cwd() : configPath ? dirname(configPath) : process.cwd()
346355
}
347356

357+
async function resolveStudioPort(requestedPort: number | undefined): Promise<number> {
358+
if (requestedPort !== undefined) {
359+
return requestedPort
360+
}
361+
362+
return getPort({
363+
host: STUDIO_SERVER_HOST,
364+
port: DEFAULT_PORT,
365+
portRange: [MIN_PORT, DEFAULT_PORT - 1],
366+
})
367+
}
368+
348369
function serializeBffError(error: unknown): SerializedError {
349370
return getSerializedBffError(error) ?? serializeError(error)
350371
}
@@ -474,24 +495,26 @@ function createStudioRequestHandler({
474495
adapter,
475496
executor,
476497
ppgDbInfo,
498+
port,
477499
protocol,
478500
version,
479501
}: {
480502
adapter: StudioAdapterType
481503
executor: Executor
482504
ppgDbInfo: Awaited<ReturnType<typeof getPpgInfo>>
505+
port: number
483506
protocol: string
484507
version: string
485508
}): (request: Request) => Promise<Response> {
486509
let projectHash: string | null = null
487510

488511
return async (request) => {
489-
const { pathname } = new URL(request.url)
490-
491-
if (request.method === 'OPTIONS') {
492-
return optionsResponse()
512+
if (!isAllowedStudioOrigin(request, port)) {
513+
return textResponse('Forbidden', 403)
493514
}
494515

516+
const { pathname } = new URL(request.url)
517+
495518
if (isGetOrHeadRequest(request.method) && pathname === '/') {
496519
const contentType = FILE_EXTENSION_TO_CONTENT_TYPE[extname('index.html')]
497520

@@ -630,12 +653,10 @@ async function serveStudioAsset(requestPath: string): Promise<Response> {
630653
const contentType = FILE_EXTENSION_TO_CONTENT_TYPE[extname(fileName)]
631654

632655
try {
633-
return withCors(
634-
new Response(await readStudioAsset(fileName), {
635-
headers: { 'Content-Type': contentType },
636-
status: 200,
637-
}),
638-
)
656+
return new Response(await readStudioAsset(fileName), {
657+
headers: { 'Content-Type': contentType },
658+
status: 200,
659+
})
639660
} catch (error: unknown) {
640661
if (isNotFoundError(error)) {
641662
return textResponse('Not Found', 404)
@@ -676,36 +697,35 @@ function isNotFoundError(error: unknown): error is NodeJS.ErrnoException {
676697
}
677698

678699
function jsonResponse(payload: unknown): Response {
679-
return withCors(Response.json(payload))
700+
return Response.json(payload)
680701
}
681702

682703
function emptyResponse(status: number, headers?: Record<string, string>): Response {
683-
return withCors(new Response(null, { headers, status }))
704+
return new Response(null, { headers, status })
684705
}
685706

686-
function optionsResponse(): Response {
687-
return emptyResponse(204, {
688-
'Access-Control-Allow-Headers': 'Content-Type',
689-
'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
707+
function textResponse(text: string, status: number, headers?: Record<string, string>): Response {
708+
return new Response(text, {
709+
headers,
710+
status,
690711
})
691712
}
692713

693-
function textResponse(text: string, status: number, headers?: Record<string, string>): Response {
694-
return withCors(
695-
new Response(text, {
696-
headers,
697-
status,
698-
}),
699-
)
700-
}
714+
function isAllowedStudioOrigin(request: Request, port: number): boolean {
715+
const origin = request.headers.get('Origin')
701716

702-
function withCors(response: Response): Response {
703-
const headers = new Headers(response.headers)
704-
headers.set('Access-Control-Allow-Origin', '*')
717+
if (origin === null) {
718+
return true
719+
}
705720

706-
return new Response(response.body, {
707-
headers,
708-
status: response.status,
709-
statusText: response.statusText,
710-
})
721+
try {
722+
const normalizedOrigin = new URL(origin).origin
723+
724+
return (
725+
normalizedOrigin === new URL(`http://localhost:${port}`).origin ||
726+
normalizedOrigin === new URL(`http://127.0.0.1:${port}`).origin
727+
)
728+
} catch {
729+
return false
730+
}
711731
}

0 commit comments

Comments
 (0)