Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

PORT=3001

# Vite dev-server port for `bun run dev`. http://localhost:<port> and
# http://127.0.0.1:<port> 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
Expand Down
2 changes: 1 addition & 1 deletion docs/features/auth-and-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>` and `http://127.0.0.1:<port>`, plus an optional `VITE_ALLOWED_ORIGIN` for a non-localhost dev host.

---

Expand Down
7 changes: 4 additions & 3 deletions scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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})...`,
)
}
}
Expand Down
3 changes: 2 additions & 1 deletion scripts/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
37 changes: 27 additions & 10 deletions server/auth/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>`
* and `http://127.0.0.1:<port>`, 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, string | undefined>): 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 {
Expand Down
11 changes: 10 additions & 1 deletion server/config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -82,7 +91,7 @@ export function readServerConfig(
env: Record<string, string | undefined> = 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',
Expand Down
4 changes: 3 additions & 1 deletion server/healthcheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions server/router.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -490,7 +495,7 @@ function adminUiNotBuiltResponse(pathname: string): Response {
</head>
<body>
<h1>Admin UI not served on this port</h1>
<p>This is the CMS API server (port 3001). In development, the admin UI is served by the Vite dev server.</p>
<p>This is the CMS API server (port ${CMS_DEV_PORT}). In development, the admin UI is served by the Vite dev server.</p>
<p>Open <a href="${targetUrl}">${targetUrl}</a>.</p>
<p>If Vite isn't running yet, start it with <code>bun run dev</code> from the project root.</p>
</body>
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/devWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Expand Down
5 changes: 4 additions & 1 deletion src/__tests__/server/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`)
})
})

Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/server/security.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'bun:test'
import {
DEV_ORIGIN_ALLOWLIST,
buildDevOriginAllowlist,
clientIp,
configurePublicOrigins,
configureTrustedProxyCidrs,
Expand Down Expand Up @@ -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', {
Expand Down
3 changes: 2 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down