From 23f86dbb4817cacb932cc72635ccf2f85dda82a9 Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 9 Jun 2026 12:19:00 +0200 Subject: [PATCH] Studio build, media path config, and security hardening --- .env.example | 1 + README.md | 6 +- apps/studio/package.json | 2 +- apps/studio/server/auth.ts | 65 +++++++++--- apps/studio/server/config.ts | 29 ++++- apps/studio/server/index.ts | 35 +++--- apps/studio/server/media.ts | 9 +- apps/studio/server/requestProtection.ts | 111 ++++++++++++++++++++ apps/studio/src/App.tsx | 10 ++ apps/studio/src/lib/studioConfig.ts | 4 + docs/configuration.md | 25 +++-- docs/media.md | 45 +++++--- docs/security.md | 35 ++++++ examples/astro-blog/README.md | 15 ++- examples/astro-blog/sourcedraft.config.json | 3 +- packages/config/src/index.ts | 6 ++ packages/config/src/loadConfig.ts | 19 +++- packages/config/src/publicMediaPath.ts | 36 +++++++ packages/config/src/types.ts | 6 ++ sourcedraft.config.example.json | 3 +- 20 files changed, 399 insertions(+), 66 deletions(-) create mode 100644 apps/studio/server/requestProtection.ts create mode 100644 packages/config/src/publicMediaPath.ts diff --git a/.env.example b/.env.example index 2726480..278b68a 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,5 @@ GITHUB_BRANCH=main # Optional overrides for sourcedraft.config.json CMS_CONTENT_DIR= CMS_MEDIA_DIR= +CMS_PUBLIC_MEDIA_PATH= CMS_ADAPTER= diff --git a/README.md b/README.md index 1f9b3a7..1ed4a19 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,12 @@ Details: [docs/security.md](docs/security.md) | | `sourcedraft.config.json` | `.env` | |---|---------------------------|--------| | **Purpose** | Project settings safe to commit | Secrets and private targets | -| **Examples** | `contentDir`, `mediaDir`, `categories`, `adapter` | `GITHUB_TOKEN`, `GITHUB_OWNER`, `GITHUB_REPO`, `SOURCEDRAFT_ADMIN_PASSWORD` | +| **Examples** | `contentDir`, `mediaDir`, `publicMediaPath`, `categories`, `adapter` | `GITHUB_TOKEN`, `GITHUB_OWNER`, `GITHUB_REPO`, `SOURCEDRAFT_ADMIN_PASSWORD` | | **Shared in git?** | Yes (copy from `sourcedraft.config.example.json`) | Never | -Optional env vars (`CMS_CONTENT_DIR`, `CMS_MEDIA_DIR`, `CMS_ADAPTER`, etc.) can override values from the JSON file. Secrets always stay in `.env`. +Optional env vars (`CMS_CONTENT_DIR`, `CMS_MEDIA_DIR`, `CMS_PUBLIC_MEDIA_PATH`, `CMS_ADAPTER`, etc.) can override values from the JSON file. Secrets always stay in `.env`. + +`mediaDir` is where images are committed in your site repo. `publicMediaPath` is the URL path Studio inserts into posts (for example `/images`). Reference: [docs/configuration.md](docs/configuration.md) diff --git a/apps/studio/package.json b/apps/studio/package.json index 0934b61..711fee1 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -9,7 +9,7 @@ "dev": "concurrently -n web,api -c blue,gray \"vite\" \"tsx watch server/index.ts\"", "dev:web": "vite", "dev:server": "tsx watch server/index.ts", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && tsc -p server/tsconfig.json", "build:server": "tsc -p server/tsconfig.json", "start:server": "node dist-server/index.js", "lint": "eslint .", diff --git a/apps/studio/server/auth.ts b/apps/studio/server/auth.ts index c0c8ae9..1e87757 100644 --- a/apps/studio/server/auth.ts +++ b/apps/studio/server/auth.ts @@ -2,6 +2,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import type { NextFunction, Request, Response } from "express"; const SESSION_COOKIE = "sourcedraft_session"; +/** 24 hours — in-memory MVP sessions, not durable account auth. */ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; type SessionRecord = { @@ -37,19 +38,53 @@ function readCookie(req: Request, name: string): string | null { return null; } -function setSessionCookie(res: Response, token: string): void { +export function isSecureCookieEnvironment(req: Request): boolean { + const explicit = process.env.STUDIO_SECURE_COOKIES?.trim().toLowerCase(); + if (explicit === "true") { + return true; + } + if (explicit === "false") { + return false; + } + + const forwardedProto = req.headers["x-forwarded-proto"]; + if (typeof forwardedProto === "string") { + const proto = forwardedProto.split(",")[0]?.trim().toLowerCase(); + if (proto === "https") { + return true; + } + } + + return process.env.NODE_ENV === "production"; +} + +function buildSessionCookie( + value: string, + maxAge: number, + req: Request, +): string { + const parts = [ + `${SESSION_COOKIE}=${encodeURIComponent(value)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${maxAge}`, + ]; + + if (isSecureCookieEnvironment(req)) { + parts.push("Secure"); + } + + return parts.join("; "); +} + +function setSessionCookie(req: Request, res: Response, token: string): void { const maxAge = Math.floor(SESSION_TTL_MS / 1000); - res.setHeader( - "Set-Cookie", - `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`, - ); + res.setHeader("Set-Cookie", buildSessionCookie(token, maxAge, req)); } -function clearSessionCookie(res: Response): void { - res.setHeader( - "Set-Cookie", - `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`, - ); +function clearSessionCookie(req: Request, res: Response): void { + res.setHeader("Set-Cookie", buildSessionCookie("", 0, req)); } function purgeExpiredSessions(): void { @@ -135,7 +170,11 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo next(); } -export function login(password: string, res: Response): { ok: boolean; error?: string } { +export function login( + req: Request, + password: string, + res: Response, +): { ok: boolean; error?: string } { if (!isAuthConfigured()) { return { ok: false, error: "Studio auth is not configured." }; } @@ -145,11 +184,11 @@ export function login(password: string, res: Response): { ok: boolean; error?: s } const token = createSession(); - setSessionCookie(res, token); + setSessionCookie(req, res, token); return { ok: true }; } export function logout(req: Request, res: Response): void { destroySession(getSessionToken(req)); - clearSessionCookie(res); + clearSessionCookie(req, res); } diff --git a/apps/studio/server/config.ts b/apps/studio/server/config.ts index 3798477..b3f2748 100644 --- a/apps/studio/server/config.ts +++ b/apps/studio/server/config.ts @@ -1,4 +1,8 @@ -import { loadSourceDraftConfig } from "@sourcedraft/config"; +import { + derivePublicMediaPath, + loadSourceDraftConfig, + normalizePublicMediaPath, +} from "@sourcedraft/config"; import type { SourceDraftConfig } from "@sourcedraft/config"; export type SupportedAdapter = "astro-mdx" | "markdown"; @@ -10,6 +14,7 @@ export type PublishEnvConfig = { branch: string; contentDir: string; mediaDir: string; + publicMediaPath: string; adapter: SupportedAdapter; categories: string[]; }; @@ -32,6 +37,22 @@ export function loadProjectConfig(): SourceDraftConfig { return loadSourceDraftConfig(); } +function resolvePublicMediaPath( + mediaDir: string, + project: SourceDraftConfig, +): string { + const envOverride = process.env.CMS_PUBLIC_MEDIA_PATH?.trim(); + if (envOverride) { + return normalizePublicMediaPath(envOverride); + } + + if (project.publicMediaPathExplicit !== undefined) { + return project.publicMediaPathExplicit; + } + + return derivePublicMediaPath(mediaDir); +} + export function loadPublishEnv(): PublishEnvResult { const project = loadProjectConfig(); @@ -43,6 +64,7 @@ export function loadPublishEnv(): PublishEnvResult { const contentDir = process.env.CMS_CONTENT_DIR?.trim() || project.contentDir; const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir; + const publicMediaPath = resolvePublicMediaPath(mediaDir, project); const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter; const adapter = resolveAdapter(rawAdapter); @@ -74,6 +96,7 @@ export function loadPublishEnv(): PublishEnvResult { branch, contentDir, mediaDir, + publicMediaPath, adapter, categories: project.categories, }, @@ -84,13 +107,15 @@ export function loadPublicConfig(): Omit { const project = loadProjectConfig(); const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter; const adapter = resolveAdapter(rawAdapter) ?? "astro-mdx"; + const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir; return { owner: process.env.GITHUB_OWNER?.trim() || "", repo: process.env.GITHUB_REPO?.trim() || "", branch: process.env.GITHUB_BRANCH?.trim() || project.defaultBranch, contentDir: process.env.CMS_CONTENT_DIR?.trim() || project.contentDir, - mediaDir: process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir, + mediaDir, + publicMediaPath: resolvePublicMediaPath(mediaDir, project), adapter, categories: project.categories, }; diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 4a43c1f..6ebdeb7 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -14,6 +14,7 @@ import { loadPublicConfig, loadPublishEnv } from "./config.js"; import { uploadMedia } from "./media.js"; import { listPosts, loadPost } from "./posts.js"; import { publishArticle, type PublishRequestBody } from "./publish.js"; +import { requireSameSiteRequest } from "./requestProtection.js"; const envPaths = [ resolve(process.cwd(), ".env"), @@ -41,9 +42,9 @@ app.get("/api/auth/status", (req, res) => { }); }); -app.post("/api/auth/login", (req, res) => { +app.post("/api/auth/login", requireSameSiteRequest, (req, res) => { const password = typeof req.body?.password === "string" ? req.body.password : ""; - const result = login(password, res); + const result = login(req, password, res); if (!result.ok) { res.status(result.error === "Invalid password." ? 401 : 500).json({ @@ -56,7 +57,7 @@ app.post("/api/auth/login", (req, res) => { res.json({ ok: true }); }); -app.post("/api/auth/logout", (req, res) => { +app.post("/api/auth/logout", requireSameSiteRequest, (req, res) => { logout(req, res); res.json({ ok: true }); }); @@ -68,6 +69,7 @@ app.get("/api/config", requireAuth, (_req, res) => { adapter: runtime.adapter, contentDir: runtime.contentDir, mediaDir: runtime.mediaDir, + publicMediaPath: runtime.publicMediaPath, defaultBranch: runtime.branch, categories: runtime.categories, githubOwner: runtime.owner, @@ -95,18 +97,23 @@ app.get("/api/posts", requireAuth, async (req, res) => { res.status(result.status).json(result.body); }); -app.post("/api/media/upload", requireAuth, async (req, res) => { - const envResult = loadPublishEnv(); - if (!envResult.ok) { - res.status(500).json({ ok: false, error: envResult.error }); - return; - } - - const result = await uploadMedia(req, envResult.config); - res.status(result.status).json(result.body); -}); +app.post( + "/api/media/upload", + requireSameSiteRequest, + requireAuth, + async (req, res) => { + const envResult = loadPublishEnv(); + if (!envResult.ok) { + res.status(500).json({ ok: false, error: envResult.error }); + return; + } + + const result = await uploadMedia(req, envResult.config); + res.status(result.status).json(result.body); + }, +); -app.post("/api/publish", requireAuth, async (req, res) => { +app.post("/api/publish", requireSameSiteRequest, requireAuth, async (req, res) => { const envResult = loadPublishEnv(); if (!envResult.ok) { res.status(500).json({ ok: false, error: envResult.error }); diff --git a/apps/studio/server/media.ts b/apps/studio/server/media.ts index 07d0387..7f129e3 100644 --- a/apps/studio/server/media.ts +++ b/apps/studio/server/media.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import type { Request } from "express"; import Busboy from "busboy"; +import { joinPublicMediaPath } from "@sourcedraft/config"; import { createGitHubPublisher } from "@sourcedraft/github-publisher"; import type { PublishEnvConfig } from "./config.js"; @@ -38,12 +39,6 @@ function normalizeMediaDir(mediaDir: string): string { return mediaDir.replace(/^\/+/u, "").replace(/\/+$/u, "").trim(); } -function mediaPublicPath(mediaDir: string, filename: string): string { - const normalized = normalizeMediaDir(mediaDir); - const leaf = normalized.split("/").pop() ?? "media"; - return `/${leaf}/${filename}`; -} - function sanitizeFilename(filename: string): string { const base = filename.split(/[/\\]/u).pop() ?? "upload"; const cleaned = base @@ -249,7 +244,7 @@ export async function uploadMedia( `-${uniqueSuffix}$1`, ); const repoPath = `${mediaDir}/${repoFilename}`; - const publicPath = mediaPublicPath(mediaDir, repoFilename); + const publicPath = joinPublicMediaPath(env.publicMediaPath, repoFilename); const publisher = createGitHubPublisher({ token: env.token, diff --git a/apps/studio/server/requestProtection.ts b/apps/studio/server/requestProtection.ts new file mode 100644 index 0000000..29b78ae --- /dev/null +++ b/apps/studio/server/requestProtection.ts @@ -0,0 +1,111 @@ +import type { NextFunction, Request, Response } from "express"; + +const ALLOWED_SEC_FETCH_SITE = new Set(["same-origin", "same-site", "none"]); + +function readHeader(req: Request, name: string): string | null { + const value = req.headers[name]; + if (typeof value !== "string" || value.trim().length === 0) { + return null; + } + + return value.trim().toLowerCase(); +} + +function originFromReferer(referer: string): string | null { + try { + return new URL(referer).origin; + } catch { + return null; + } +} + +function isLoopbackHostname(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function isLocalPrivateHost(req: Request): boolean { + const hostname = req.headers.host?.split(":")[0] ?? ""; + return isLoopbackHostname(hostname); +} + +function configuredAllowedOrigins(): string[] { + const raw = process.env.STUDIO_ALLOWED_ORIGINS?.trim(); + if (!raw) { + return []; + } + + return raw + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function isAllowedRequestOrigin(req: Request, origin: string): boolean { + const allowed = configuredAllowedOrigins(); + if (allowed.length > 0) { + return allowed.includes(origin); + } + + try { + const originUrl = new URL(origin); + + if (isLoopbackHostname(originUrl.hostname) && isLocalPrivateHost(req)) { + return true; + } + + const host = req.headers.host ?? ""; + const requestHostname = host.split(":")[0] ?? ""; + return originUrl.hostname === requestHostname; + } catch { + return false; + } +} + +/** + * MVP hardening for state-changing routes. + * Blocks obvious cross-site POSTs using Fetch Metadata and Origin/Referer checks. + * This is not full hosted-SaaS CSRF protection — keep Studio local/private unless + * deployed behind HTTPS with stronger auth and deployment hardening. + */ +export function requireSameSiteRequest( + req: Request, + res: Response, + next: NextFunction, +): void { + const secFetchSite = readHeader(req, "sec-fetch-site"); + if (secFetchSite !== null) { + if (!ALLOWED_SEC_FETCH_SITE.has(secFetchSite)) { + res.status(403).json({ ok: false, error: "Cross-site request blocked." }); + return; + } + + next(); + return; + } + + const originHeader = req.headers.origin; + const refererHeader = req.headers.referer; + const origin = + typeof originHeader === "string" && originHeader.length > 0 + ? originHeader + : typeof refererHeader === "string" + ? originFromReferer(refererHeader) + : null; + + if (origin === null) { + if (isLocalPrivateHost(req)) { + next(); + return; + } + + res.status(403).json({ ok: false, error: "Origin verification required." }); + return; + } + + if (!isAllowedRequestOrigin(req, origin)) { + res.status(403).json({ ok: false, error: "Cross-site request blocked." }); + return; + } + + next(); +} diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 58cf260..1b3d03c 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -441,6 +441,16 @@ function App() { /> + +