From 6894519af62f3a2b8b82c111882dbf153b828153 Mon Sep 17 00:00:00 2001 From: dremchee Date: Wed, 8 Jul 2026 10:26:30 +0300 Subject: [PATCH 1/2] fix(server): respect custom dev ports in origin allowlist and admin placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom VITE_PORT broke every state-changing request in dev: the CSRF origin check only allowlisted the default Vite ports 5173/5174, so the browser's Origin on any other port got 403 'Forbidden: invalid origin' — including the first-run setup POST, making the install wizard unusable. - Derive DEV_ORIGIN_ALLOWLIST from the environment via a pure buildDevOriginAllowlist(env): default ports plus VITE_PORT, each as localhost and 127.0.0.1, plus the existing VITE_ALLOWED_ORIGIN escape hatch for non-localhost dev hosts. - Extract DEFAULT_CMS_PORT / DEFAULT_VITE_PORT into server/config.ts as the single source of truth; consume them in the router, security, healthcheck, vite.config.ts, and the dev/start scripts instead of scattered '3001' / '5173' literals. - Point the 'Admin UI not served on this port' placeholder page at the configured PORT / VITE_PORT instead of hardcoded 3001 / 5173. - Document VITE_PORT and VITE_ALLOWED_ORIGIN in .env.example and update the auth-and-access doc, which had drifted from the code. --- .env.example | 6 +++++ docs/features/auth-and-access.md | 2 +- scripts/dev.ts | 7 ++--- scripts/start.ts | 3 ++- server/auth/security.ts | 37 +++++++++++++++++++-------- server/config.ts | 11 +++++++- server/healthcheck.ts | 4 ++- server/router.ts | 9 +++++-- src/__tests__/server/router.test.ts | 5 +++- src/__tests__/server/security.test.ts | 27 +++++++++++++++++++ vite.config.ts | 3 ++- 11 files changed, 93 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 33fe0d113..fd6938778 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ PORT=3001 +# Vite dev-server port for `bun run dev`. http://localhost: and +# http://127.0.0.1: are allowed by the API's dev CSRF/CORS allowlist +# automatically; use VITE_ALLOWED_ORIGIN for a non-localhost dev origin. +# VITE_PORT=5173 +# VITE_ALLOWED_ORIGIN= + # Comma-separated CIDRs for reverse proxies whose X-Forwarded-For header may # be trusted. Leave empty for direct/local development. # TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 diff --git a/docs/features/auth-and-access.md b/docs/features/auth-and-access.md index d81f56e6a..83225f6ca 100644 --- a/docs/features/auth-and-access.md +++ b/docs/features/auth-and-access.md @@ -355,7 +355,7 @@ The expected origin is derived **only** from the configured public origin set at `SameSite=Lax` on the session cookie covers the typical CSRF surface; this check closes the same-site-different-subdomain edge case. -`DEV_ORIGIN_ALLOWLIST` allows `http://localhost:5173`, `http://localhost:3001`, and `http://127.0.0.1:5173` so dev-time cross-origin from Vite to the API works. +`DEV_ORIGIN_ALLOWLIST` (built by `buildDevOriginAllowlist` in `server/auth/security.ts`) allows the Vite dev origins so dev-time cross-origin from Vite to the API works: the default ports 5173/5174 plus a `VITE_PORT` override, each as both `http://localhost:` and `http://127.0.0.1:`, plus an optional `VITE_ALLOWED_ORIGIN` for a non-localhost dev host. --- diff --git a/scripts/dev.ts b/scripts/dev.ts index 360b13596..7ad88bfa5 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -28,11 +28,12 @@ import { mkdir } from 'node:fs/promises' import { dirname } from 'node:path' import { isSqliteUrl } from '../server/db' +import { DEFAULT_CMS_PORT, DEFAULT_VITE_PORT } from '../server/config' import { bunCommand, bunRunCommand } from './lib/bunCommand' import { ensurePortFree } from './lib/freePort' -const CMS_PORT = Number(process.env.PORT ?? '3001') -const VITE_PORT = Number(process.env.VITE_PORT ?? '5173') +const CMS_PORT = Number(process.env.PORT ?? DEFAULT_CMS_PORT) +const VITE_PORT = Number(process.env.VITE_PORT ?? DEFAULT_VITE_PORT) const POSTGRES_HOST = '127.0.0.1' const POSTGRES_PORT = 5433 const DATABASE_URL = process.env.DATABASE_URL ?? 'sqlite:./.tmp/dev.db' @@ -172,7 +173,7 @@ function stopAppContainerIfRunning(): void { if (state === 'running') { runDocker( ['compose', 'stop', 'app'], - 'Docker `app` container is running — stopping it (it would conflict with the local cms on port 3001)...', + `Docker \`app\` container is running — stopping it (it would conflict with the local cms on port ${CMS_PORT})...`, ) } } diff --git a/scripts/start.ts b/scripts/start.ts index 22a4040d5..fb08245c3 100644 --- a/scripts/start.ts +++ b/scripts/start.ts @@ -21,8 +21,9 @@ import { bunRunCommand } from './lib/bunCommand' import { ensurePortFree } from './lib/freePort' +import { DEFAULT_CMS_PORT } from '../server/config' -const CMS_PORT = Number(process.env.PORT ?? '3001') +const CMS_PORT = Number(process.env.PORT ?? DEFAULT_CMS_PORT) function log(msg: string): void { console.error(`[start] ${msg}`) diff --git a/server/auth/security.ts b/server/auth/security.ts index dea2bdd1f..1c0aaf3b3 100644 --- a/server/auth/security.ts +++ b/server/auth/security.ts @@ -43,16 +43,33 @@ * endpoint for rate limiting. */ import { isIP } from 'node:net' -import { normalizeOrigin } from '../config' - -/** Extra origins allowed by the Origin check (set via env in dev/test). */ -export const DEV_ORIGIN_ALLOWLIST: string[] = [ - 'http://localhost:5173', - 'http://127.0.0.1:5173', - 'http://localhost:5174', - 'http://127.0.0.1:5174', - process.env.VITE_ALLOWED_ORIGIN ?? '', -].filter(Boolean) +import { DEFAULT_VITE_PORT, normalizeOrigin } from '../config' + +/** + * Vite dev-server ports whose localhost origins are always allowed: the + * default port plus its auto-increment fallback (which is also the port + * `scripts/e2e-dev.ts` runs Vite on). + */ +const DEFAULT_VITE_PORTS = [DEFAULT_VITE_PORT, DEFAULT_VITE_PORT + 1] + +/** + * Build the dev-origin allowlist from the environment: the default Vite dev + * ports plus a `VITE_PORT` override, each as both `http://localhost:` + * and `http://127.0.0.1:`, plus an optional fully-custom + * `VITE_ALLOWED_ORIGIN` (for a non-localhost dev host). Without the + * `VITE_PORT` entries, running Vite on a custom port would 403 every + * state-changing request in dev. Pure so tests can exercise env combinations + * without reloading the module. + */ +export function buildDevOriginAllowlist(env: Record): string[] { + const ports = [...DEFAULT_VITE_PORTS.map(String), env.VITE_PORT ?? ''].filter(Boolean) + const origins = ports.flatMap((port) => [`http://localhost:${port}`, `http://127.0.0.1:${port}`]) + if (env.VITE_ALLOWED_ORIGIN) origins.push(env.VITE_ALLOWED_ORIGIN) + return [...new Set(origins)] +} + +/** Extra origins allowed by the Origin check (Vite dev server + env overrides). */ +export const DEV_ORIGIN_ALLOWLIST: string[] = buildDevOriginAllowlist(process.env) /** Methods that mutate server state — the only ones the Origin check applies to. */ export function isStateChangingMethod(method: string): boolean { diff --git a/server/config.ts b/server/config.ts index 3a73208c4..962034a37 100644 --- a/server/config.ts +++ b/server/config.ts @@ -1,3 +1,12 @@ +/** + * Default dev ports — the single source of truth for every consumer that + * falls back when `PORT` / `VITE_PORT` are unset: `readServerConfig`, the + * router's dev pointers, the CSRF dev-origin allowlist, the container + * healthcheck, `scripts/dev.ts` / `scripts/start.ts`, and `vite.config.ts`. + */ +export const DEFAULT_CMS_PORT = 3001 +export const DEFAULT_VITE_PORT = 5173 + interface ServerConfig { port: number databaseUrl: string @@ -82,7 +91,7 @@ export function readServerConfig( env: Record = process.env, ): ServerConfig { return { - port: Number(env.PORT ?? 3001), + port: Number(env.PORT ?? DEFAULT_CMS_PORT), databaseUrl: env.DATABASE_URL ?? 'sqlite:./.tmp/dev.db', uploadsDir: env.UPLOADS_DIR ?? './uploads', staticDir: env.STATIC_DIR ?? './dist', diff --git a/server/healthcheck.ts b/server/healthcheck.ts index c02a25815..3952bc322 100644 --- a/server/healthcheck.ts +++ b/server/healthcheck.ts @@ -7,7 +7,9 @@ * the YAML/Dockerfile readable (no JSON-escaped JS one-liners) and makes the * healthcheck logic discoverable in the codebase. */ -const port = process.env.PORT ?? '3001' +import { DEFAULT_CMS_PORT } from './config' + +const port = process.env.PORT ?? String(DEFAULT_CMS_PORT) const url = `http://127.0.0.1:${port}/health` try { diff --git a/server/router.ts b/server/router.ts index ef778e617..83af87021 100644 --- a/server/router.ts +++ b/server/router.ts @@ -1,3 +1,4 @@ +import { DEFAULT_CMS_PORT, DEFAULT_VITE_PORT } from './config' import { tryHandleAi } from './ai/handlers' import { handleMcpHttp, MCP_ENDPOINT_PATH } from './ai/mcp' import { handleCmsRequest } from './handlers/cms' @@ -21,7 +22,11 @@ import type { CssBundleFile, SiteCssBundleId } from '@core/publisher' import { buildPublishedSiteCssBundle } from './publish/siteCssBundle' import { mediaStorageRegistry } from '@core/plugins/mediaStorageRegistry' -const VITE_DEV_URL = 'http://localhost:5173' +// Dev-only pointers for the "Admin UI not served on this port" page. Both +// respect the same env overrides `bun run dev` (scripts/dev.ts) reads, so the +// page never sends a developer with custom ports to a dead URL. +const VITE_DEV_URL = `http://localhost:${process.env.VITE_PORT ?? DEFAULT_VITE_PORT}` +const CMS_DEV_PORT = process.env.PORT ?? String(DEFAULT_CMS_PORT) interface ServerRuntime { db: DbClient @@ -490,7 +495,7 @@ function adminUiNotBuiltResponse(pathname: string): Response {

Admin UI not served on this port

-

This is the CMS API server (port 3001). In development, the admin UI is served by the Vite dev server.

+

This is the CMS API server (port ${CMS_DEV_PORT}). In development, the admin UI is served by the Vite dev server.

Open ${targetUrl}.

If Vite isn't running yet, start it with bun run dev from the project root.

diff --git a/src/__tests__/server/router.test.ts b/src/__tests__/server/router.test.ts index 9d4328ba5..5840afbd4 100644 --- a/src/__tests__/server/router.test.ts +++ b/src/__tests__/server/router.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { handleServerRequest } from '../../../server/router' +import { DEFAULT_VITE_PORT } from '../../../server/config' import type { DbClient, DbResult } from '../../../server/db' import { prepareInactiveSlot, @@ -76,7 +77,9 @@ describe('server router', () => { expect(res.status).toBe(404) expect(res.headers.get('content-type')).toContain('text/html') const body = await res.text() - expect(body).toContain('http://localhost:5173/admin') + // The page points at the configured Vite port — bun auto-loads .env, so + // the expectation must read the same override the router read at import. + expect(body).toContain(`http://localhost:${process.env.VITE_PORT ?? DEFAULT_VITE_PORT}/admin`) }) }) diff --git a/src/__tests__/server/security.test.ts b/src/__tests__/server/security.test.ts index 45344b9d5..0a7660d02 100644 --- a/src/__tests__/server/security.test.ts +++ b/src/__tests__/server/security.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'bun:test' import { DEV_ORIGIN_ALLOWLIST, + buildDevOriginAllowlist, clientIp, configurePublicOrigins, configureTrustedProxyCidrs, @@ -170,6 +171,32 @@ describe('originAllowed', () => { expect(DEV_ORIGIN_ALLOWLIST).toContain('http://127.0.0.1:5173') }) + describe('buildDevOriginAllowlist', () => { + it('contains localhost and 127.0.0.1 origins for the default Vite ports', () => { + const list = buildDevOriginAllowlist({}) + expect(list).toContain('http://localhost:5173') + expect(list).toContain('http://127.0.0.1:5173') + expect(list).toContain('http://localhost:5174') + expect(list).toContain('http://127.0.0.1:5174') + }) + + it('adds both host variants for a custom VITE_PORT', () => { + const list = buildDevOriginAllowlist({ VITE_PORT: '7282' }) + expect(list).toContain('http://localhost:7282') + expect(list).toContain('http://127.0.0.1:7282') + }) + + it('does not duplicate entries when VITE_PORT matches a default port', () => { + const list = buildDevOriginAllowlist({ VITE_PORT: '5173' }) + expect(list.filter((origin) => origin === 'http://localhost:5173')).toHaveLength(1) + }) + + it('appends VITE_ALLOWED_ORIGIN when set', () => { + const list = buildDevOriginAllowlist({ VITE_ALLOWED_ORIGIN: 'http://dev.example.test:8080' }) + expect(list).toContain('http://dev.example.test:8080') + }) + }) + it('rejects requests from a foreign origin', () => { configurePublicOrigins(['https://cms.example.com']) const req = makeReq('http://app:3001/admin/api/cms/login', { diff --git a/vite.config.ts b/vite.config.ts index 24432c221..474b16708 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,8 +3,9 @@ import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' import path from 'path' import type { IncomingMessage, ServerResponse } from 'node:http' +import { DEFAULT_CMS_PORT } from './server/config' -const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? '3001'}` +const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? DEFAULT_CMS_PORT}` const FILE_EXTENSION_RE = /\.[a-zA-Z0-9]+$/ function isEditorAppPath(pathname: string): boolean { From 1282515d58291a89534759747dbb88fff82b18de Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Wed, 8 Jul 2026 16:01:56 +0200 Subject: [PATCH 2/2] test(dev): align workflow assertion with shared port constant --- src/__tests__/devWorkflow.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index a75836e6b..2f0839621 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -80,7 +80,8 @@ describe('development workflow', () => { // to `Path=/admin`) is sent on every request to the Bun backend. expect(viteConfig).toContain("'/admin/api'") expect(viteConfig).toContain("'/uploads'") - expect(viteConfig).toContain("const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? '3001'}`") + expect(viteConfig).toContain("import { DEFAULT_CMS_PORT } from './server/config'") + expect(viteConfig).toContain('const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? DEFAULT_CMS_PORT}`') expect(viteConfig).toContain('target: CMS_DEV_SERVER_ORIGIN') expect(viteConfig).toContain('changeOrigin: true') })