Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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 <password>`
# 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=
Expand Down
1 change: 1 addition & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
105 changes: 105 additions & 0 deletions apps/studio/server/adminPassword.ts
Original file line number Diff line number Diff line change
@@ -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);
}
83 changes: 83 additions & 0 deletions apps/studio/server/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>();

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);
});
});
32 changes: 20 additions & 12 deletions apps/studio/server/auth.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -17,7 +21,12 @@ type SessionRecord = {

const sessions = new Map<string, SessionRecord>();

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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
18 changes: 10 additions & 8 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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);

Expand All @@ -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) {
Expand All @@ -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);

Expand All @@ -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() : "";
Expand Down Expand Up @@ -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);
Expand All @@ -169,6 +170,7 @@ app.get("/api/media", requireAuth, async (req, res) => {

app.post(
"/api/media/upload",
writeLimiter,
requireSameSiteRequest,
requireAuth,
async (req, res) => {
Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions apps/studio/server/mediaPaths.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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." };
}
Expand Down
8 changes: 6 additions & 2 deletions apps/studio/server/mediaValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ const EXTENSION_TO_MIME: Record<string, string> = {
};

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 {
Expand Down
Loading
Loading