diff --git a/.env.example b/.env.example index bd175f3..67afca4 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Secrets — copy to .env and never commit # Project paths/categories belong in sourcedraft.config.json instead +# Preferred: scrypt hash from `pnpm exec tsx scripts/hash-admin-password.ts ` +# Format: scrypt$N$r$p$saltBase64$hashBase64 +SOURCEDRAFT_ADMIN_PASSWORD_HASH= +# Legacy local-dev fallback only (omit when using SOURCEDRAFT_ADMIN_PASSWORD_HASH) SOURCEDRAFT_ADMIN_PASSWORD= # Set to true to run Studio in demo mode (no remote commits) SOURCEDRAFT_DEMO_MODE= diff --git a/apps/studio/package.json b/apps/studio/package.json index 0ca154e..131bfa0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -38,6 +38,7 @@ "busboy": "^1.6.0", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-rate-limit": "^8.0.1", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/apps/studio/server/adminPassword.ts b/apps/studio/server/adminPassword.ts new file mode 100644 index 0000000..c1aaf57 --- /dev/null +++ b/apps/studio/server/adminPassword.ts @@ -0,0 +1,105 @@ +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; + +const DEFAULT_SCRYPT_N = 16384; +const DEFAULT_SCRYPT_R = 8; +const DEFAULT_SCRYPT_P = 1; +const DEFAULT_SCRYPT_KEYLEN = 64; + +export type ScryptHashParams = { + N: number; + r: number; + p: number; + salt: Buffer; + hash: Buffer; +}; + +export function parseScryptPasswordHash( + value: string, +): ScryptHashParams | null { + const parts = value.trim().split("$"); + if (parts.length !== 6 || parts[0] !== "scrypt") { + return null; + } + + const N = Number(parts[1]); + const r = Number(parts[2]); + const p = Number(parts[3]); + if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) { + return null; + } + + try { + const salt = Buffer.from(parts[4] ?? "", "base64"); + const hash = Buffer.from(parts[5] ?? "", "base64"); + if (salt.length === 0 || hash.length === 0) { + return null; + } + + return { N, r, p, salt, hash }; + } catch { + return null; + } +} + +export function formatScryptPasswordHash( + params: ScryptHashParams, +): string { + return [ + "scrypt", + params.N, + params.r, + params.p, + params.salt.toString("base64"), + params.hash.toString("base64"), + ].join("$"); +} + +export function hashAdminPassword( + password: string, + options?: { N?: number; r?: number; p?: number; keylen?: number }, +): string { + const N = options?.N ?? DEFAULT_SCRYPT_N; + const r = options?.r ?? DEFAULT_SCRYPT_R; + const p = options?.p ?? DEFAULT_SCRYPT_P; + const keylen = options?.keylen ?? DEFAULT_SCRYPT_KEYLEN; + const salt = randomBytes(16); + const hash = scryptSync(password, salt, keylen, { N, r, p }); + + return formatScryptPasswordHash({ N, r, p, salt, hash }); +} + +export function verifyScryptPassword( + password: string, + storedHash: string, +): boolean { + const parsed = parseScryptPasswordHash(storedHash); + if (parsed === null) { + return false; + } + + const derived = scryptSync(password, parsed.salt, parsed.hash.length, { + N: parsed.N, + r: parsed.r, + p: parsed.p, + }); + + if (derived.length !== parsed.hash.length) { + return false; + } + + return timingSafeEqual(derived, parsed.hash); +} + +export function verifyPlaintextPassword( + password: string, + expected: string, +): boolean { + const provided = Buffer.from(password); + const target = Buffer.from(expected); + + if (provided.length !== target.length) { + return false; + } + + return timingSafeEqual(provided, target); +} diff --git a/apps/studio/server/auth.test.ts b/apps/studio/server/auth.test.ts new file mode 100644 index 0000000..f24e6c6 --- /dev/null +++ b/apps/studio/server/auth.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { hashAdminPassword } from "./adminPassword.js"; +import { isAuthConfigured, verifyPassword } from "./auth.js"; + +const ENV_KEYS = [ + "SOURCEDRAFT_ADMIN_PASSWORD_HASH", + "SOURCEDRAFT_ADMIN_PASSWORD", +] as const; + +const originalEnv = new Map(); + +function saveEnv(): void { + for (const key of ENV_KEYS) { + originalEnv.set(key, process.env[key]); + } +} + +function restoreEnv(): void { + for (const key of ENV_KEYS) { + const value = originalEnv.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function clearAuthEnv(): void { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +} + +describe("studio auth", () => { + afterEach(() => { + restoreEnv(); + }); + + it("accepts valid scrypt hash login and prefers hash over plaintext", () => { + saveEnv(); + clearAuthEnv(); + + const hash = hashAdminPassword("studio-secret"); + process.env.SOURCEDRAFT_ADMIN_PASSWORD_HASH = hash; + process.env.SOURCEDRAFT_ADMIN_PASSWORD = "legacy-only"; + + assert.equal(isAuthConfigured(), true); + assert.equal(verifyPassword("studio-secret"), true); + assert.equal(verifyPassword("legacy-only"), false); + assert.equal(verifyPassword("wrong"), false); + }); + + it("rejects invalid scrypt hash login", () => { + saveEnv(); + clearAuthEnv(); + + process.env.SOURCEDRAFT_ADMIN_PASSWORD_HASH = "scrypt$16384$8$1$invalid$invalid"; + + assert.equal(isAuthConfigured(), true); + assert.equal(verifyPassword("anything"), false); + }); + + it("falls back to legacy plaintext password when hash is absent", () => { + saveEnv(); + clearAuthEnv(); + + process.env.SOURCEDRAFT_ADMIN_PASSWORD = "legacy-password"; + + assert.equal(isAuthConfigured(), true); + assert.equal(verifyPassword("legacy-password"), true); + assert.equal(verifyPassword("other"), false); + }); + + it("reports missing auth config when no password values are set", () => { + saveEnv(); + clearAuthEnv(); + + assert.equal(isAuthConfigured(), false); + assert.equal(verifyPassword("anything"), false); + }); +}); diff --git a/apps/studio/server/auth.ts b/apps/studio/server/auth.ts index bd375f1..84d76d5 100644 --- a/apps/studio/server/auth.ts +++ b/apps/studio/server/auth.ts @@ -1,10 +1,14 @@ -import { randomBytes, timingSafeEqual } from "node:crypto"; +import { randomBytes } from "node:crypto"; import type { NextFunction, Request, Response } from "express"; import { isDemoModeAvailable, isDemoModeForced, isPublisherConfigured, } from "./demoMode.js"; +import { + verifyPlaintextPassword, + verifyScryptPassword, +} from "./adminPassword.js"; const SESSION_COOKIE = "sourcedraft_session"; /** 24 hours — in-memory MVP sessions, not durable account auth. */ @@ -17,7 +21,12 @@ type SessionRecord = { const sessions = new Map(); -function getAdminPassword(): string | null { +function getAdminPasswordHash(): string | null { + const hash = process.env.SOURCEDRAFT_ADMIN_PASSWORD_HASH?.trim(); + return hash && hash.length > 0 ? hash : null; +} + +function getLegacyAdminPassword(): string | null { const password = process.env.SOURCEDRAFT_ADMIN_PASSWORD?.trim(); return password && password.length > 0 ? password : null; } @@ -103,23 +112,21 @@ function purgeExpiredSessions(): void { } export function isAuthConfigured(): boolean { - return getAdminPassword() !== null; + return getAdminPasswordHash() !== null || getLegacyAdminPassword() !== null; } export function verifyPassword(password: string): boolean { - const expected = getAdminPassword(); - if (expected === null) { - return false; + const hash = getAdminPasswordHash(); + if (hash !== null) { + return verifyScryptPassword(password, hash); } - const provided = Buffer.from(password); - const target = Buffer.from(expected); - - if (provided.length !== target.length) { + const legacyPassword = getLegacyAdminPassword(); + if (legacyPassword === null) { return false; } - return timingSafeEqual(provided, target); + return verifyPlaintextPassword(password, legacyPassword); } export function createSession(options?: { demo?: boolean }): string { @@ -198,7 +205,8 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo if (!isAuthConfigured() && !isDemoModeAvailable()) { res.status(500).json({ ok: false, - error: "SOURCEDRAFT_ADMIN_PASSWORD is not configured.", + error: + "Studio auth is not configured. Set SOURCEDRAFT_ADMIN_PASSWORD_HASH or SOURCEDRAFT_ADMIN_PASSWORD.", }); return; } diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index a6dd681..91788f7 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -25,6 +25,7 @@ import { publishArticle, type PublishRequestBody } from "./publish.js"; import { requireSameSiteRequest } from "./requestProtection.js"; import { initializePlugins } from "./plugins.js"; import { getSetupHealth } from "./setupHealth.js"; +import { readLimiter, strictAuthLimiter, writeLimiter } from "./rateLimit.js"; const envPaths = [ resolve(process.cwd(), ".env"), @@ -60,7 +61,7 @@ app.get("/api/auth/status", (req, res) => { }); }); -app.post("/api/auth/login", requireSameSiteRequest, (req, res) => { +app.post("/api/auth/login", strictAuthLimiter, requireSameSiteRequest, (req, res) => { const password = typeof req.body?.password === "string" ? req.body.password : ""; const result = login(req, password, res); @@ -75,7 +76,7 @@ app.post("/api/auth/login", requireSameSiteRequest, (req, res) => { res.json({ ok: true }); }); -app.post("/api/auth/demo", requireSameSiteRequest, (req, res) => { +app.post("/api/auth/demo", strictAuthLimiter, requireSameSiteRequest, (req, res) => { const result = enterDemo(req, res); if (!result.ok) { @@ -86,12 +87,12 @@ app.post("/api/auth/demo", requireSameSiteRequest, (req, res) => { res.json({ ok: true, demoMode: true }); }); -app.post("/api/auth/logout", requireSameSiteRequest, (req, res) => { +app.post("/api/auth/logout", writeLimiter, requireSameSiteRequest, (req, res) => { logout(req, res); res.json({ ok: true }); }); -app.get("/api/config", requireAuth, (req, res) => { +app.get("/api/config", readLimiter, requireAuth, (req, res) => { const runtime = loadPublicConfig(); const demoMode = isRequestDemoSession(req); @@ -111,11 +112,11 @@ app.get("/api/config", requireAuth, (req, res) => { }); }); -app.get("/api/health/setup", requireAuth, (_req, res) => { +app.get("/api/health/setup", readLimiter, requireAuth, (_req, res) => { res.json(getSetupHealth()); }); -app.get("/api/posts", requireAuth, async (req, res) => { +app.get("/api/posts", readLimiter, requireAuth, async (req, res) => { const demoMode = isRequestDemoSession(req); const pathParam = typeof req.query.path === "string" ? req.query.path.trim() : ""; @@ -150,7 +151,7 @@ app.get("/api/posts", requireAuth, async (req, res) => { res.status(result.status).json(result.body); }); -app.get("/api/media", requireAuth, async (req, res) => { +app.get("/api/media", readLimiter, requireAuth, async (req, res) => { if (isRequestDemoSession(req)) { const result = await listDemoMediaHandler(); res.status(result.status).json(result.body); @@ -169,6 +170,7 @@ app.get("/api/media", requireAuth, async (req, res) => { app.post( "/api/media/upload", + writeLimiter, requireSameSiteRequest, requireAuth, async (req, res) => { @@ -190,7 +192,7 @@ app.post( }, ); -app.post("/api/publish", requireSameSiteRequest, requireAuth, async (req, res) => { +app.post("/api/publish", writeLimiter, requireSameSiteRequest, requireAuth, async (req, res) => { if (isRequestDemoSession(req)) { const runtime = loadPublicConfig(); const result = await publishDemoArticle( diff --git a/apps/studio/server/mediaPaths.ts b/apps/studio/server/mediaPaths.ts index f2bf236..d2a5533 100644 --- a/apps/studio/server/mediaPaths.ts +++ b/apps/studio/server/mediaPaths.ts @@ -1,10 +1,11 @@ +import { trimLeadingSlashes, trimSlashes } from "@sourcedraft/core"; import { isAllowedMediaExtension, normalizeExtension, } from "./mediaValidation.js"; export function normalizeMediaDir(mediaDir: string): string { - return mediaDir.replace(/^\/+/u, "").replace(/\/+$/u, "").trim(); + return trimSlashes(mediaDir).trim(); } export function safeMediaPath( @@ -16,7 +17,7 @@ export function safeMediaPath( return { ok: false, error: "Media directory is not configured." }; } - const path = inputPath.replace(/^\/+/u, "").trim(); + const path = trimLeadingSlashes(inputPath).trim(); if (path.length === 0) { return { ok: false, error: "Path is required." }; } diff --git a/apps/studio/server/mediaValidation.ts b/apps/studio/server/mediaValidation.ts index 80ec9d2..707110b 100644 --- a/apps/studio/server/mediaValidation.ts +++ b/apps/studio/server/mediaValidation.ts @@ -36,8 +36,12 @@ const EXTENSION_TO_MIME: Record = { }; export function normalizeExtension(filename: string): string { - const match = filename.match(/\.([^.]+)$/u); - return match?.[1]?.toLowerCase() ?? ""; + const dotIndex = filename.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === filename.length - 1) { + return ""; + } + + return filename.slice(dotIndex + 1).toLowerCase(); } export function mediaKindFromMime(mimeType: string): MediaKind | null { diff --git a/apps/studio/server/postPaths.ts b/apps/studio/server/postPaths.ts index bb606c8..a8f19d3 100644 --- a/apps/studio/server/postPaths.ts +++ b/apps/studio/server/postPaths.ts @@ -1,7 +1,7 @@ -const POST_FILE_PATTERN = /\.(md|mdx)$/iu; +import { hasFileExtension, trimLeadingSlashes, trimSlashes } from "@sourcedraft/core"; export function normalizeContentDir(contentDir: string): string { - return contentDir.replace(/^\/+/u, "").replace(/\/+$/u, "").trim(); + return trimSlashes(contentDir).trim(); } export function safePostPath( @@ -13,7 +13,7 @@ export function safePostPath( return { ok: false, error: "Content directory is not configured." }; } - const path = inputPath.replace(/^\/+/u, "").trim(); + const path = trimLeadingSlashes(inputPath).trim(); if (path.length === 0) { return { ok: false, error: "Path is required." }; } @@ -30,7 +30,7 @@ export function safePostPath( }; } - if (!POST_FILE_PATTERN.test(path)) { + if (!hasFileExtension(path, ["md", "mdx"])) { return { ok: false, error: "Post path must end with .md or .mdx." }; } diff --git a/apps/studio/server/rateLimit.test.ts b/apps/studio/server/rateLimit.test.ts new file mode 100644 index 0000000..2f1f6e9 --- /dev/null +++ b/apps/studio/server/rateLimit.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import express from "express"; +import { describe, it } from "node:test"; +import { strictAuthLimiter } from "./rateLimit.js"; + +describe("rate limiting", () => { + it("returns the standard 429 JSON error payload", async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousRelaxed = process.env.STUDIO_RATE_LIMIT_RELAXED; + process.env.NODE_ENV = "production"; + delete process.env.STUDIO_RATE_LIMIT_RELAXED; + + try { + const app = express(); + app.post("/api/auth/login", strictAuthLimiter, (_req, res) => { + res.json({ ok: true }); + }); + + const server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to bind test server."); + } + + const baseUrl = `http://127.0.0.1:${address.port}`; + let blockedBody: { ok: boolean; error: string } | null = null; + + for (let attempt = 0; attempt < 12; attempt += 1) { + const response = await fetch(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + if (response.status === 429) { + blockedBody = (await response.json()) as { ok: boolean; error: string }; + break; + } + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + assert.ok(blockedBody); + assert.equal(blockedBody?.ok, false); + assert.equal(blockedBody?.error, "Too many requests. Try again later."); + } finally { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + + if (previousRelaxed === undefined) { + delete process.env.STUDIO_RATE_LIMIT_RELAXED; + } else { + process.env.STUDIO_RATE_LIMIT_RELAXED = previousRelaxed; + } + } + }); +}); diff --git a/apps/studio/server/rateLimit.ts b/apps/studio/server/rateLimit.ts new file mode 100644 index 0000000..8361b91 --- /dev/null +++ b/apps/studio/server/rateLimit.ts @@ -0,0 +1,36 @@ +import rateLimit from "express-rate-limit"; + +const RATE_LIMIT_MESSAGE = "Too many requests. Try again later."; + +function isRateLimitRelaxed(): boolean { + if (process.env.NODE_ENV !== "production") { + return true; + } + + return process.env.STUDIO_RATE_LIMIT_RELAXED?.trim().toLowerCase() === "true"; +} + +function resolveMax(max: number): number { + return isRateLimitRelaxed() ? max * 20 : max; +} + +function createLimiter(max: number, windowMs: number) { + return rateLimit({ + windowMs, + max: () => resolveMax(max), + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => { + res.status(429).json({ ok: false, error: RATE_LIMIT_MESSAGE }); + }, + }); +} + +/** Login and demo entry — strict to slow brute-force attempts. */ +export const strictAuthLimiter = createLimiter(10, 15 * 60 * 1000); + +/** Logout, publish, and media upload — moderate write protection. */ +export const writeLimiter = createLimiter(60, 15 * 60 * 1000); + +/** Config, health, posts, and media reads — generous for normal Studio use. */ +export const readLimiter = createLimiter(300, 15 * 60 * 1000); diff --git a/docs/security.md b/docs/security.md index 9c89350..4f6b2dc 100644 --- a/docs/security.md +++ b/docs/security.md @@ -6,7 +6,8 @@ All credentials are read from `.env` in the publish API only. Studio stores a se | Secret | Used for | |--------|----------| -| `SOURCEDRAFT_ADMIN_PASSWORD` | Studio login | +| `SOURCEDRAFT_ADMIN_PASSWORD_HASH` | Studio login (preferred scrypt hash) | +| `SOURCEDRAFT_ADMIN_PASSWORD` | Studio login legacy plaintext fallback for local dev | | `GITHUB_*` | GitHub Contents API (publish, list, media) | | `GITLAB_*` | GitLab Repository Files API | | `BITBUCKET_*` | Bitbucket commit-upload API | @@ -45,7 +46,9 @@ Sessions reset when the API process restarts. This is not durable account auth. Protected routes include login, logout, publish, and media upload. Middleware checks `Sec-Fetch-Site` or `Origin`/`Referer` and rejects obvious cross-site POSTs. Optional `STUDIO_ALLOWED_ORIGINS` for reverse-proxy deployments. -This is basic MVP hardening — not a substitute for CSRF tokens, rate limiting, or production auth on a public deployment. +Rate limiting is enabled on auth, publish, media, and read endpoints. Limits are relaxed automatically outside `NODE_ENV=production` so local development stays usable. Set `STUDIO_RATE_LIMIT_RELAXED=true` in production only when you intentionally need higher local-style limits behind a trusted reverse proxy. + +This is basic MVP hardening — not a substitute for CSRF tokens or production-grade account auth on a public deployment. ## Server-only publisher access @@ -97,6 +100,20 @@ Details: [plugins.md](plugins.md) Single shared password, in-memory sessions. +Prefer `SOURCEDRAFT_ADMIN_PASSWORD_HASH` over plaintext `SOURCEDRAFT_ADMIN_PASSWORD`. The hash format is: + +```text +scrypt$N$r$p$saltBase64$hashBase64 +``` + +Generate a hash: + +```bash +pnpm exec tsx scripts/hash-admin-password.ts "your-password" +``` + +When both hash and plaintext are set, the hash is used. Plaintext remains a legacy local-dev fallback only. + **Intended for local/private use.** Do not expose Studio publicly without HTTPS, stronger auth, and deployment hardening. Report security concerns privately; redact tokens in bug reports. See [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/package.json b/package.json index e381f7b..3f19cb5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "pnpm -r lint", "setup": "tsx packages/setup/src/cli.ts setup", "validate:config": "tsx packages/setup/src/cli.ts validate", - "test": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/plugins --filter @sourcedraft/setup --filter @sourcedraft/github-publisher --filter studio test", + "test": "pnpm --filter @sourcedraft/core --filter @sourcedraft/config --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/plugins --filter @sourcedraft/setup --filter @sourcedraft/github-publisher --filter studio test", "test:e2e": "pnpm --filter studio test:e2e", "screenshots:generate": "pnpm --filter studio screenshots:generate", "capture-doc-screenshots": "cd apps/studio && pnpm exec tsx ../../scripts/capture-doc-screenshots.ts" diff --git a/packages/adapter-astro-mdx/src/path.ts b/packages/adapter-astro-mdx/src/path.ts index bb3d0ed..d73506a 100644 --- a/packages/adapter-astro-mdx/src/path.ts +++ b/packages/adapter-astro-mdx/src/path.ts @@ -1,4 +1,5 @@ import type { Article } from "@sourcedraft/core"; +import { trimTrailingSlashes } from "@sourcedraft/core"; export type AstroMdxPathConfig = { contentDir: string; @@ -9,7 +10,7 @@ export function getAstroMdxPath( article: Article, config: AstroMdxPathConfig, ): string { - const contentDir = config.contentDir.replace(/\/+$/u, ""); + const contentDir = trimTrailingSlashes(config.contentDir); const rawExtension = config.extension ?? "mdx"; const extension = rawExtension.startsWith(".") ? rawExtension.slice(1) diff --git a/packages/adapter-docusaurus-mdx/src/path.ts b/packages/adapter-docusaurus-mdx/src/path.ts index 379ff30..4198bb2 100644 --- a/packages/adapter-docusaurus-mdx/src/path.ts +++ b/packages/adapter-docusaurus-mdx/src/path.ts @@ -1,4 +1,5 @@ import type { Article } from "@sourcedraft/core"; +import { trimTrailingSlashes } from "@sourcedraft/core"; import { resolveDocusaurusMdxOptions, type FilenameConvention, @@ -40,7 +41,7 @@ export function getDocusaurusMdxPath( article: Article, config: DocusaurusMdxPathConfig, ): string { - const contentDir = config.contentDir.replace(/\/+$/u, ""); + const contentDir = trimTrailingSlashes(config.contentDir); const rawExtension = config.extension ?? "mdx"; const extension = rawExtension.startsWith(".") ? rawExtension.slice(1) diff --git a/packages/adapter-eleventy-jekyll-markdown/src/path.ts b/packages/adapter-eleventy-jekyll-markdown/src/path.ts index 8e92d1b..4ac6517 100644 --- a/packages/adapter-eleventy-jekyll-markdown/src/path.ts +++ b/packages/adapter-eleventy-jekyll-markdown/src/path.ts @@ -1,4 +1,5 @@ import type { Article } from "@sourcedraft/core"; +import { trimTrailingSlashes } from "@sourcedraft/core"; import { resolveEleventyJekyllOptions } from "./options.js"; export type EleventyJekyllMarkdownPathConfig = { @@ -21,7 +22,7 @@ export function getEleventyJekyllMarkdownPath( article: Article, config: EleventyJekyllMarkdownPathConfig, ): string { - const contentDir = config.contentDir.replace(/\/+$/u, ""); + const contentDir = trimTrailingSlashes(config.contentDir); const rawExtension = config.extension ?? "md"; const extension = rawExtension.startsWith(".") ? rawExtension.slice(1) diff --git a/packages/adapter-hugo-markdown/src/path.ts b/packages/adapter-hugo-markdown/src/path.ts index b2815e6..3ebc69c 100644 --- a/packages/adapter-hugo-markdown/src/path.ts +++ b/packages/adapter-hugo-markdown/src/path.ts @@ -1,4 +1,5 @@ import type { Article } from "@sourcedraft/core"; +import { trimTrailingSlashes } from "@sourcedraft/core"; export type HugoMarkdownPathConfig = { contentDir: string; @@ -9,7 +10,7 @@ export function getHugoMarkdownPath( article: Article, config: HugoMarkdownPathConfig, ): string { - const contentDir = config.contentDir.replace(/\/+$/u, ""); + const contentDir = trimTrailingSlashes(config.contentDir); const rawExtension = config.extension ?? "md"; const extension = rawExtension.startsWith(".") ? rawExtension.slice(1) diff --git a/packages/adapter-markdown/src/path.ts b/packages/adapter-markdown/src/path.ts index f8fd5de..065cf3e 100644 --- a/packages/adapter-markdown/src/path.ts +++ b/packages/adapter-markdown/src/path.ts @@ -1,4 +1,5 @@ import type { Article } from "@sourcedraft/core"; +import { trimTrailingSlashes } from "@sourcedraft/core"; export type MarkdownPathConfig = { contentDir: string; @@ -9,7 +10,7 @@ export function getMarkdownPath( article: Article, config: MarkdownPathConfig, ): string { - const contentDir = config.contentDir.replace(/\/+$/u, ""); + const contentDir = trimTrailingSlashes(config.contentDir); const rawExtension = config.extension ?? "md"; const extension = rawExtension.startsWith(".") ? rawExtension.slice(1) diff --git a/packages/adapter-mkdocs-markdown/src/path.ts b/packages/adapter-mkdocs-markdown/src/path.ts index 5aca30e..a57a9e7 100644 --- a/packages/adapter-mkdocs-markdown/src/path.ts +++ b/packages/adapter-mkdocs-markdown/src/path.ts @@ -1,4 +1,5 @@ import type { Article } from "@sourcedraft/core"; +import { trimTrailingSlashes } from "@sourcedraft/core"; import { resolveMkdocsMarkdownOptions, type FilenameConvention, @@ -40,7 +41,7 @@ export function getMkdocsMarkdownPath( article: Article, config: MkdocsMarkdownPathConfig, ): string { - const contentDir = config.contentDir.replace(/\/+$/u, ""); + const contentDir = trimTrailingSlashes(config.contentDir); const rawExtension = config.extension ?? "md"; const extension = rawExtension.startsWith(".") ? rawExtension.slice(1) diff --git a/packages/adapter-nextjs-mdx/src/path.ts b/packages/adapter-nextjs-mdx/src/path.ts index 136d1c3..2a45a30 100644 --- a/packages/adapter-nextjs-mdx/src/path.ts +++ b/packages/adapter-nextjs-mdx/src/path.ts @@ -1,4 +1,5 @@ import type { Article } from "@sourcedraft/core"; +import { trimTrailingSlashes } from "@sourcedraft/core"; export type NextjsMdxPathConfig = { contentDir: string; @@ -9,7 +10,7 @@ export function getNextjsMdxPath( article: Article, config: NextjsMdxPathConfig, ): string { - const contentDir = config.contentDir.replace(/\/+$/u, ""); + const contentDir = trimTrailingSlashes(config.contentDir); const rawExtension = config.extension ?? "mdx"; const extension = rawExtension.startsWith(".") ? rawExtension.slice(1) diff --git a/packages/adapter-nuxt-content-markdown/src/path.ts b/packages/adapter-nuxt-content-markdown/src/path.ts index c988c9a..f8eb0da 100644 --- a/packages/adapter-nuxt-content-markdown/src/path.ts +++ b/packages/adapter-nuxt-content-markdown/src/path.ts @@ -1,4 +1,5 @@ import type { Article } from "@sourcedraft/core"; +import { trimTrailingSlashes } from "@sourcedraft/core"; import { resolveNuxtContentMarkdownOptions, type FilenameConvention, @@ -40,7 +41,7 @@ export function getNuxtContentMarkdownPath( article: Article, config: NuxtContentMarkdownPathConfig, ): string { - const contentDir = config.contentDir.replace(/\/+$/u, ""); + const contentDir = trimTrailingSlashes(config.contentDir); const rawExtension = config.extension ?? "md"; const extension = rawExtension.startsWith(".") ? rawExtension.slice(1) diff --git a/packages/config/package.json b/packages/config/package.json index b7c35df..44b8175 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -15,9 +15,14 @@ ], "scripts": { "build": "tsc", - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "test": "node --import tsx --test src/**/*.test.ts" + }, + "dependencies": { + "@sourcedraft/core": "workspace:*" }, "devDependencies": { + "tsx": "^4.20.3", "@types/node": "^22.15.30", "typescript": "^5.8.3" } diff --git a/packages/config/src/publicMediaPath.test.ts b/packages/config/src/publicMediaPath.test.ts new file mode 100644 index 0000000..abaf0e6 --- /dev/null +++ b/packages/config/src/publicMediaPath.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + derivePublicMediaPath, + joinPublicMediaPath, + normalizePublicMediaPath, +} from "./publicMediaPath.js"; + +describe("publicMediaPath", () => { + it("normalizes trailing slashes and empty values", () => { + assert.equal(normalizePublicMediaPath("/images///"), "/images"); + assert.equal(normalizePublicMediaPath("images"), "/images"); + assert.equal(normalizePublicMediaPath(" "), "/"); + }); + + it("derives public paths from mediaDir", () => { + assert.equal(derivePublicMediaPath("public/images"), "/images"); + assert.equal(derivePublicMediaPath("src/assets/images"), "/images"); + assert.equal(derivePublicMediaPath(""), "/media"); + }); + + it("joins filenames without duplicate slashes", () => { + assert.equal(joinPublicMediaPath("/images", "photo.png"), "/images/photo.png"); + assert.equal(joinPublicMediaPath("/images/", "/photo.png"), "/images/photo.png"); + }); +}); diff --git a/packages/config/src/publicMediaPath.ts b/packages/config/src/publicMediaPath.ts index 8615bee..0d584e5 100644 --- a/packages/config/src/publicMediaPath.ts +++ b/packages/config/src/publicMediaPath.ts @@ -1,5 +1,12 @@ +import { + collapseSlashes, + trimLeadingSlashes, + trimSlashes, + trimTrailingSlashes, +} from "@sourcedraft/core"; + export function normalizePublicMediaPath(publicMediaPath: string): string { - const trimmed = publicMediaPath.trim().replace(/\/+$/u, ""); + const trimmed = trimTrailingSlashes(publicMediaPath.trim()); if (trimmed.length === 0) { return "/"; } @@ -8,7 +15,7 @@ export function normalizePublicMediaPath(publicMediaPath: string): string { } export function derivePublicMediaPath(mediaDir: string): string { - const normalized = mediaDir.replace(/^\/+/u, "").replace(/\/+$/u, "").trim(); + const normalized = trimSlashes(mediaDir.trim()); if (normalized.length === 0) { return "/media"; @@ -27,10 +34,10 @@ export function joinPublicMediaPath( filename: string, ): string { const base = normalizePublicMediaPath(publicMediaPath); - const cleanFilename = filename.replace(/^\/+/u, ""); + const cleanFilename = trimLeadingSlashes(filename); if (cleanFilename.length === 0) { return base; } - return `${base}/${cleanFilename}`.replace(/\/+/gu, "/"); + return collapseSlashes(`${base}/${cleanFilename}`); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 642bd72..9e06085 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,14 @@ export type { } from "./article.js"; export { createSlug } from "./slug.js"; +export { + collapseSlashes, + fileExtension, + hasFileExtension, + trimLeadingSlashes, + trimSlashes, + trimTrailingSlashes, +} from "./path.js"; export { appendSeoFrontmatterLines, mergeArticleInputWithSeo, diff --git a/packages/core/src/path.test.ts b/packages/core/src/path.test.ts new file mode 100644 index 0000000..4c9cbbe --- /dev/null +++ b/packages/core/src/path.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + collapseSlashes, + fileExtension, + hasFileExtension, + trimLeadingSlashes, + trimSlashes, + trimTrailingSlashes, +} from "./path.js"; + +describe("path helpers", () => { + it("trims leading slashes", () => { + assert.equal(trimLeadingSlashes("///public/images"), "public/images"); + assert.equal(trimLeadingSlashes("public/images"), "public/images"); + assert.equal(trimLeadingSlashes(""), ""); + }); + + it("trims trailing slashes", () => { + assert.equal(trimTrailingSlashes("public/images///"), "public/images"); + assert.equal(trimTrailingSlashes("public/images"), "public/images"); + }); + + it("trims both ends", () => { + assert.equal(trimSlashes("///public/images///"), "public/images"); + }); + + it("collapses repeated slashes", () => { + assert.equal(collapseSlashes("/images//photo.png"), "/images/photo.png"); + assert.equal(collapseSlashes("///"), "/"); + }); + + it("reads file extensions without regex", () => { + assert.equal(fileExtension("post.mdx"), "mdx"); + assert.equal(fileExtension("archive.tar.gz"), "gz"); + assert.equal(fileExtension("no-extension"), ""); + assert.equal(hasFileExtension("post.md", ["md", "mdx"]), true); + assert.equal(hasFileExtension("post.txt", ["md", "mdx"]), false); + }); +}); diff --git a/packages/core/src/path.ts b/packages/core/src/path.ts new file mode 100644 index 0000000..0d7f324 --- /dev/null +++ b/packages/core/src/path.ts @@ -0,0 +1,65 @@ +export function trimLeadingSlashes(value: string): string { + let start = 0; + while (start < value.length && value[start] === "/") { + start += 1; + } + + return value.slice(start); +} + +export function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === "/") { + end -= 1; + } + + return value.slice(0, end); +} + +export function trimSlashes(value: string): string { + return trimTrailingSlashes(trimLeadingSlashes(value)); +} + +export function collapseSlashes(value: string): string { + let result = ""; + let previousWasSlash = false; + + for (const char of value) { + if (char === "/") { + if (!previousWasSlash) { + result += "/"; + previousWasSlash = true; + } + continue; + } + + previousWasSlash = false; + result += char; + } + + return result; +} + +export function fileExtension(filename: string): string { + const dotIndex = filename.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === filename.length - 1) { + return ""; + } + + return filename.slice(dotIndex + 1).toLowerCase(); +} + +export function hasFileExtension(filename: string, extensions: string[]): boolean { + const extension = fileExtension(filename); + if (extension.length === 0) { + return false; + } + + for (const candidate of extensions) { + if (extension === candidate.toLowerCase()) { + return true; + } + } + + return false; +} diff --git a/packages/github-publisher/src/githubErrors.test.ts b/packages/github-publisher/src/githubErrors.test.ts index dff4dc8..2022e2f 100644 --- a/packages/github-publisher/src/githubErrors.test.ts +++ b/packages/github-publisher/src/githubErrors.test.ts @@ -23,6 +23,19 @@ describe("formatGitHubApiError", () => { assert.match(error, /403/); }); + it("maps rate limit 403 messages without regex", () => { + const error = formatGitHubApiError(403, "API rate limit exceeded", "publish"); + assert.match(error, /rate limit/i); + }); + + it("maps repository-not-found 404 messages without regex", () => { + const error = formatGitHubApiError(404, "Repository not found", "publish", { + owner: "acme", + repo: "blog", + }); + assert.match(error, /GITHUB_OWNER/); + }); + it("maps listPosts 404 to contentDir guidance", () => { const error = formatGitHubApiError(404, "Not Found", "listPosts", { owner: "acme", diff --git a/packages/github-publisher/src/githubErrors.ts b/packages/github-publisher/src/githubErrors.ts index eb42b11..0eb2773 100644 --- a/packages/github-publisher/src/githubErrors.ts +++ b/packages/github-publisher/src/githubErrors.ts @@ -53,7 +53,8 @@ export function formatGitHubApiError( } if (status === 403) { - if (/rate limit/i.test(message)) { + const lowerMessage = message.toLowerCase(); + if (lowerMessage.includes("rate limit")) { return "GitHub API rate limit reached. Wait a few minutes and try again."; } @@ -61,7 +62,11 @@ export function formatGitHubApiError( } if (status === 404) { - if (/repository.*not found/i.test(message) || /not found.*repository/i.test(message)) { + const lowerMessage = message.toLowerCase(); + if ( + lowerMessage.includes("repository") && + lowerMessage.includes("not found") + ) { return `Repository ${target} was not found. Check GITHUB_OWNER and GITHUB_REPO in .env.`; } diff --git a/packages/media-providers/package.json b/packages/media-providers/package.json index 42684f9..bd59d18 100644 --- a/packages/media-providers/package.json +++ b/packages/media-providers/package.json @@ -18,6 +18,9 @@ "check": "tsc --noEmit", "test": "node --import tsx --test 'src/**/*.test.ts' 'src/**/**/*.test.ts'" }, + "dependencies": { + "@sourcedraft/core": "workspace:*" + }, "devDependencies": { "@types/node": "^22.15.30", "tsx": "^4.20.3", diff --git a/packages/media-providers/src/cloudinary/cloudinarySignature.test.ts b/packages/media-providers/src/cloudinary/cloudinarySignature.test.ts new file mode 100644 index 0000000..2711f2c --- /dev/null +++ b/packages/media-providers/src/cloudinary/cloudinarySignature.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { describe, it } from "node:test"; +import { + buildCloudinarySignature, + serializeCloudinarySignatureParams, +} from "./cloudinarySignature.js"; + +describe("cloudinary signature", () => { + it("serializes params in sorted order", () => { + const serialized = serializeCloudinarySignatureParams({ + timestamp: 1_700_000_000, + folder: "sourcedraft", + }); + + assert.equal(serialized, "folder=sourcedraft×tamp=1700000000"); + }); + + it("builds deterministic SHA-256 signatures", () => { + const params = { + timestamp: 1_700_000_000, + folder: "sourcedraft", + }; + const apiSecret = "secret456"; + const serialized = serializeCloudinarySignatureParams(params); + const expected = createHash("sha256") + .update(`${serialized}${apiSecret}`) + .digest("hex"); + + assert.equal(buildCloudinarySignature(params, apiSecret), expected); + assert.equal( + expected, + "19b24d26130786a58ef8371151d9559bbb54c4044ed58226a507ef4f4a129f7f", + ); + }); +}); diff --git a/packages/media-providers/src/cloudinary/cloudinarySignature.ts b/packages/media-providers/src/cloudinary/cloudinarySignature.ts index f2d6699..8aa8880 100644 --- a/packages/media-providers/src/cloudinary/cloudinarySignature.ts +++ b/packages/media-providers/src/cloudinary/cloudinarySignature.ts @@ -1,13 +1,18 @@ import { createHash } from "node:crypto"; -export function buildCloudinarySignature( +export function serializeCloudinarySignatureParams( params: Record, - apiSecret: string, ): string { - const serialized = Object.keys(params) + return Object.keys(params) .sort() .map((key) => `${key}=${params[key]}`) .join("&"); +} - return createHash("sha1").update(`${serialized}${apiSecret}`).digest("hex"); +export function buildCloudinarySignature( + params: Record, + apiSecret: string, +): string { + const serialized = serializeCloudinarySignatureParams(params); + return createHash("sha256").update(`${serialized}${apiSecret}`).digest("hex"); } diff --git a/packages/media-providers/src/s3/s3Config.ts b/packages/media-providers/src/s3/s3Config.ts index d3f9312..fb645b6 100644 --- a/packages/media-providers/src/s3/s3Config.ts +++ b/packages/media-providers/src/s3/s3Config.ts @@ -1,3 +1,5 @@ +import { trimTrailingSlashes } from "@sourcedraft/core"; + export type S3MediaConfig = { endpoint: string; region: string; @@ -59,13 +61,13 @@ export function validateS3MediaConfig(input: { return { ok: true, config: { - endpoint: endpoint.replace(/\/+$/, ""), + endpoint: trimTrailingSlashes(endpoint), region, bucket, accessKeyId, secretAccessKey, ...(input.publicBaseUrl?.trim() - ? { publicBaseUrl: input.publicBaseUrl.trim().replace(/\/+$/, "") } + ? { publicBaseUrl: trimTrailingSlashes(input.publicBaseUrl.trim()) } : {}), ...(input.forcePathStyle === true ? { forcePathStyle: true } : {}), }, diff --git a/packages/publishers/src/ghost/ghostJwt.ts b/packages/publishers/src/ghost/ghostJwt.ts index 09ada8e..e6ed24d 100644 --- a/packages/publishers/src/ghost/ghostJwt.ts +++ b/packages/publishers/src/ghost/ghostJwt.ts @@ -53,6 +53,7 @@ export function createGhostAdminJwt( aud: "/admin/", }), ); + // CodeQL: HMAC-SHA256 signs Ghost Admin API JWTs (RFC 7519), not password storage. const signature = createHmac("sha256", parsed.secret) .update(`${header}.${payload}`) .digest("base64url"); diff --git a/packages/publishers/src/index.ts b/packages/publishers/src/index.ts index 73b4df0..1b942ed 100644 --- a/packages/publishers/src/index.ts +++ b/packages/publishers/src/index.ts @@ -10,6 +10,11 @@ export { supportedPublisherSummary, } from "./publisherRegistry.js"; +export { + createGhostAdminJwt, + parseGhostAdminApiKey, +} from "./ghost/ghostJwt.js"; + export { PUBLISHER_IDS, type CmsArticlePayload, diff --git a/packages/setup/package.json b/packages/setup/package.json index b763e18..e89d86a 100644 --- a/packages/setup/package.json +++ b/packages/setup/package.json @@ -19,6 +19,7 @@ "test": "node --import tsx --test 'src/**/*.test.ts'" }, "dependencies": { + "@sourcedraft/core": "workspace:*", "@sourcedraft/adapters": "workspace:*", "@sourcedraft/config": "workspace:*", "@sourcedraft/media-providers": "workspace:*", diff --git a/packages/setup/src/connectionChecks.ts b/packages/setup/src/connectionChecks.ts index ed5d292..1f886fe 100644 --- a/packages/setup/src/connectionChecks.ts +++ b/packages/setup/src/connectionChecks.ts @@ -1,3 +1,6 @@ +import { trimTrailingSlashes } from "@sourcedraft/core"; +import { createGhostAdminJwt } from "@sourcedraft/publishers"; + export type ConnectionCheckResult = { ok: boolean; detail: string; @@ -48,7 +51,7 @@ export async function checkGitLabConnection( const token = env.GITLAB_TOKEN?.trim(); const projectId = env.GITLAB_PROJECT_ID?.trim(); const projectPath = env.GITLAB_PROJECT_PATH?.trim(); - const baseUrl = (env.GITLAB_BASE_URL?.trim() || "https://gitlab.com").replace(/\/$/u, ""); + const baseUrl = trimTrailingSlashes(env.GITLAB_BASE_URL?.trim() || "https://gitlab.com"); if (!token) { return { ok: false, detail: "Missing GitLab token for connection check." }; @@ -110,7 +113,7 @@ export async function checkBitbucketConnection( export async function checkWordPressConnection( env: Record, ): Promise { - const apiUrl = env.WORDPRESS_API_URL?.trim()?.replace(/\/$/u, ""); + const apiUrl = trimTrailingSlashes(env.WORDPRESS_API_URL?.trim() ?? ""); const username = env.WORDPRESS_USERNAME?.trim(); const appPassword = env.WORDPRESS_APP_PASSWORD?.trim(); @@ -137,47 +140,23 @@ export async function checkWordPressConnection( export async function checkGhostConnection( env: Record, ): Promise { - const adminUrl = env.GHOST_ADMIN_URL?.trim()?.replace(/\/$/u, ""); + const adminUrl = trimTrailingSlashes(env.GHOST_ADMIN_URL?.trim() ?? ""); const apiKey = env.GHOST_ADMIN_API_KEY?.trim(); if (!adminUrl || !apiKey) { return { ok: false, detail: "Missing Ghost credentials for connection check." }; } - const [id, secret] = apiKey.split(":"); - if (!id || !secret) { - return { ok: false, detail: "GHOST_ADMIN_API_KEY must be id:secret format." }; + const jwt = createGhostAdminJwt(apiKey); + if ("ok" in jwt) { + return { ok: false, detail: jwt.error }; } - const header = Buffer.from( - JSON.stringify({ - alg: "HS256", - typ: "JWT", - kid: id, - }), - ).toString("base64url"); - - const now = Math.floor(Date.now() / 1000); - const payload = Buffer.from( - JSON.stringify({ - iat: now, - exp: now + 5 * 60, - aud: "/admin/", - }), - ).toString("base64url"); - - const crypto = await import("node:crypto"); - const signature = crypto - .createHmac("sha256", Buffer.from(secret, "hex")) - .update(`${header}.${payload}`) - .digest("base64url"); - - const token = `${header}.${payload}.${signature}`; const version = env.GHOST_ACCEPT_VERSION?.trim() || "v5.126"; const response = await fetch(`${adminUrl}/ghost/api/admin/site/`, { headers: { - Authorization: `Ghost ${token}`, + Authorization: `Ghost ${jwt.token}`, Accept: "application/json", "Accept-Version": version, "User-Agent": "SourceDraft-Setup", diff --git a/packages/setup/src/envFile.test.ts b/packages/setup/src/envFile.test.ts index 19565a9..7dd41a0 100644 --- a/packages/setup/src/envFile.test.ts +++ b/packages/setup/src/envFile.test.ts @@ -55,6 +55,40 @@ test("mergeEnvMaps respects overwrite decisions", () => { assert.equal(merged.get("GITHUB_OWNER"), "acme"); }); +test("serializeEnvFile escapes unsafe values and rejects invalid keys", () => { + const map = new Map([ + ["GITHUB_TOKEN", "secret value"], + ["QUOTED", 'say "hello"'], + ["BACKSLASH", "path\\to\\file"], + ["NEWLINE", "line1\nline2"], + ["CARRIAGE", "line1\rline2"], + ["TAB", "col1\tcol2"], + ["EMPTY", ""], + ["HASH", "value#comment"], + ["INJECTION", "safe\nGITHUB_TOKEN=hijacked"], + ]); + + const serialized = serializeEnvFile(map); + assert.match(serialized, /GITHUB_TOKEN="secret value"/); + assert.match(serialized, /QUOTED=/); + assert.match(serialized, /\\n/); + assert.match(serialized, /EMPTY=""/); + assert.doesNotMatch(serialized, /^GITHUB_TOKEN=hijacked/m); + + const loaded = parseEnvFile(serialized); + assert.equal(loaded.get("GITHUB_TOKEN"), "secret value"); + assert.equal(loaded.get("QUOTED"), 'say "hello"'); + assert.equal(loaded.get("BACKSLASH"), "path\\to\\file"); + assert.equal(loaded.get("NEWLINE"), "line1\nline2"); + assert.equal(loaded.get("CARRIAGE"), "line1\rline2"); + assert.equal(loaded.get("TAB"), "col1\tcol2"); + assert.equal(loaded.get("EMPTY"), ""); + assert.equal(loaded.get("HASH"), "value#comment"); + assert.equal(loaded.get("INJECTION"), "safe\nGITHUB_TOKEN=hijacked"); + + assert.throws(() => serializeEnvFile(new Map([["bad-key", "value"]])), /Invalid env key/); +}); + test("serializeEnvFile round-trips through loadEnvMap", () => { const dir = mkdtempSync(join(tmpdir(), "sourcedraft-setup-")); const envPath = join(dir, ".env"); diff --git a/packages/setup/src/envFile.ts b/packages/setup/src/envFile.ts index 34c99c2..e1f0871 100644 --- a/packages/setup/src/envFile.ts +++ b/packages/setup/src/envFile.ts @@ -4,6 +4,128 @@ import { formatEnvValueForDisplay } from "./maskSecrets.js"; export type EnvMap = Map; +const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/u; + +export function isValidEnvKey(key: string): boolean { + return ENV_KEY_PATTERN.test(key); +} + +function assertValidEnvKey(key: string): void { + if (!isValidEnvKey(key)) { + throw new Error(`Invalid env key: ${key}`); + } +} + +function needsQuoting(value: string): boolean { + if (value.length === 0) { + return true; + } + + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if ( + value[index] === " " || + value[index] === "#" || + value[index] === '"' || + value[index] === "\\" || + value[index] === "\n" || + value[index] === "\r" || + value[index] === "\t" || + code < 32 + ) { + return true; + } + } + + return false; +} + +export function escapeEnvValue(value: string): string { + if (!needsQuoting(value)) { + return value; + } + + let escaped = ""; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + const code = value.charCodeAt(index); + + if (char === "\\" || char === '"') { + escaped += `\\${char}`; + continue; + } + + if (char === "\n") { + escaped += "\\n"; + continue; + } + + if (char === "\r") { + escaped += "\\r"; + continue; + } + + if (char === "\t") { + escaped += "\\t"; + continue; + } + + if (code < 32) { + escaped += `\\u${code.toString(16).padStart(4, "0")}`; + continue; + } + + escaped += char; + } + + return `"${escaped}"`; +} + +function unescapeQuotedEnvValue(value: string): string { + let unescaped = ""; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (char !== "\\" || index === value.length - 1) { + unescaped += char; + continue; + } + + const next = value[index + 1]; + if (next === "n") { + unescaped += "\n"; + index += 1; + continue; + } + + if (next === "r") { + unescaped += "\r"; + index += 1; + continue; + } + + if (next === "t") { + unescaped += "\t"; + index += 1; + continue; + } + + if (next === "u") { + const hex = value.slice(index + 2, index + 6); + if (/^[0-9a-fA-F]{4}$/u.test(hex)) { + unescaped += String.fromCharCode(Number.parseInt(hex, 16)); + index += 5; + continue; + } + } + + unescaped += next; + index += 1; + } + + return unescaped; +} + export function parseEnvFile(content: string): EnvMap { const map: EnvMap = new Map(); @@ -21,10 +143,9 @@ export function parseEnvFile(content: string): EnvMap { const key = trimmed.slice(0, eq).trim(); let value = trimmed.slice(eq + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { + if (value.startsWith('"') && value.endsWith('"')) { + value = unescapeQuotedEnvValue(value.slice(1, -1)); + } else if (value.startsWith("'") && value.endsWith("'")) { value = value.slice(1, -1); } @@ -50,9 +171,8 @@ export function serializeEnvFile(map: EnvMap, header?: string): string { } for (const [key, value] of [...map.entries()].sort(([a], [b]) => a.localeCompare(b))) { - const escaped = - value.includes(" ") || value.includes("#") ? `"${value.replace(/"/gu, '\\"')}"` : value; - lines.push(`${key}=${escaped}`); + assertValidEnvKey(key); + lines.push(`${key}=${escapeEnvValue(value)}`); } lines.push(""); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cc1299..25f5fdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + express-rate-limit: + specifier: ^8.0.1 + version: 8.5.2(express@5.2.1) react: specifier: ^19.2.6 version: 19.2.7 @@ -272,10 +275,17 @@ importers: version: 5.9.3 packages/config: + dependencies: + '@sourcedraft/core': + specifier: workspace:* + version: link:../core devDependencies: '@types/node': specifier: ^22.15.30 version: 22.19.19 + tsx: + specifier: ^4.20.3 + version: 4.22.4 typescript: specifier: ^5.8.3 version: 5.9.3 @@ -302,6 +312,10 @@ importers: version: 5.9.3 packages/media-providers: + dependencies: + '@sourcedraft/core': + specifier: workspace:* + version: link:../core devDependencies: '@types/node': specifier: ^22.15.30 @@ -362,6 +376,9 @@ importers: '@sourcedraft/config': specifier: workspace:* version: link:../config + '@sourcedraft/core': + specifier: workspace:* + version: link:../core '@sourcedraft/media-providers': specifier: workspace:* version: link:../media-providers @@ -1179,6 +1196,12 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -1310,6 +1333,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2603,6 +2630,11 @@ snapshots: etag@1.8.1: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -2749,6 +2781,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-extglob@2.1.1: {} diff --git a/scripts/hash-admin-password.ts b/scripts/hash-admin-password.ts new file mode 100644 index 0000000..f1a7ebb --- /dev/null +++ b/scripts/hash-admin-password.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env tsx +import { hashAdminPassword } from "../apps/studio/server/adminPassword.js"; + +const password = process.argv[2]; + +if (!password || password.length === 0) { + console.error("Usage: pnpm exec tsx scripts/hash-admin-password.ts "); + console.error(""); + console.error("Output format: scrypt$N$r$p$saltBase64$hashBase64"); + console.error("Set SOURCEDRAFT_ADMIN_PASSWORD_HASH in .env with the generated value."); + process.exit(1); +} + +console.log(hashAdminPassword(password));