feat: harden auth, security headers, SVG sanitization, and pipeline ownership

- Add password strength validation (8+ chars, uppercase, lowercase, number)
- Add username validation rules
- Optimize API key lookup with SHA-256 prefix (O(1) vs O(n) scan)
- Require password change on default admin first login
- Revoke API keys on password change
- Add session cleanup cron (hourly expired session purge)
- Add Permissions-Policy, HSTS, and CSP security headers in production
- Strengthen SVG sanitizer: block XInclude, foreignObject, processing
  instructions, javascript/data/file URI schemes
- Add userId ownership to pipelines with authorization checks
- Add keyPrefix column to api_keys table
- Update integration tests for new auth behavior
This commit is contained in:
Siddharth Kumar Sah
2026-03-24 21:38:06 +08:00
parent 75f38a9fe2
commit 432cc92471
13 changed files with 570 additions and 52 deletions
+2
View File
@@ -40,6 +40,7 @@ export const apiKeys = sqliteTable("api_keys", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
keyHash: text("key_hash").notNull(),
keyPrefix: text("key_prefix"),
name: text("name").notNull().default("Default API Key"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
@@ -47,6 +48,7 @@ export const apiKeys = sqliteTable("api_keys", {
export const pipelines = sqliteTable("pipelines", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
steps: text("steps").notNull(), // JSON array of { toolId, settings }
+8
View File
@@ -42,6 +42,14 @@ app.addHook("onSend", async (_request, reply) => {
reply.header("X-Frame-Options", "DENY");
reply.header("X-XSS-Protection", "0");
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
if (process.env.NODE_ENV === "production") {
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
reply.header(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
);
}
});
await app.register(rateLimit, {
+17
View File
@@ -1,6 +1,8 @@
import { readdir, stat, rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdirSync } from "node:fs";
import { lt } from "drizzle-orm";
import { db, schema } from "../db/index.js";
import { env } from "../config.js";
export function startCleanupCron() {
@@ -37,10 +39,25 @@ export function startCleanupCron() {
}
};
// Purge expired sessions from the database
const purgeExpiredSessions = () => {
try {
const now = new Date();
const result = db.delete(schema.sessions).where(lt(schema.sessions.expiresAt, now)).run();
if (result.changes > 0) {
console.log(`Cleanup: purged ${result.changes} expired sessions`);
}
} catch (err) {
console.error("Session cleanup error:", err);
}
};
// Run on startup
cleanup();
purgeExpiredSessions();
// Schedule recurring cleanup
setInterval(cleanup, intervalMs);
setInterval(purgeExpiredSessions, 60 * 60 * 1000); // Hourly
console.log(`Cleanup scheduled: every ${env.CLEANUP_INTERVAL_MINUTES}m, max age ${env.FILE_MAX_AGE_HOURS}h`);
}
+70 -14
View File
@@ -1,4 +1,4 @@
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { randomBytes, scrypt, timingSafeEqual, createHash } from "node:crypto";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
@@ -39,6 +39,34 @@ export async function verifyPassword(
return timingSafeEqual(derived, storedBuf);
}
/**
* Compute a fast lookup prefix for an API key.
* Uses SHA-256 (not scrypt) so lookups are O(1) instead of O(n).
*/
export function computeKeyPrefix(rawKey: string): string {
return createHash("sha256").update(rawKey).digest("hex").slice(0, 16);
}
const PASSWORD_RULES = "Password must be at least 8 characters with uppercase, lowercase, and a number";
function validatePasswordStrength(password: string): string | null {
if (password.length < 8) return PASSWORD_RULES;
if (!/[A-Z]/.test(password)) return PASSWORD_RULES;
if (!/[a-z]/.test(password)) return PASSWORD_RULES;
if (!/[0-9]/.test(password)) return PASSWORD_RULES;
return null;
}
function validateUsername(username: string): string | null {
if (username.length < 3 || username.length > 50) {
return "Username must be between 3 and 50 characters";
}
if (!/^[a-zA-Z0-9_.\-]+$/.test(username)) {
return "Username can only contain letters, numbers, dots, hyphens, and underscores";
}
return null;
}
// ── Request helpers ───────────────────────────────────────────────
/** Extract the authenticated user attached by authMiddleware. */
@@ -90,11 +118,11 @@ export async function ensureDefaultAdmin(): Promise<void> {
username: env.DEFAULT_USERNAME,
passwordHash,
role: "admin",
mustChangePassword: false,
mustChangePassword: true,
})
.run();
console.log(`Default admin user '${env.DEFAULT_USERNAME}' created`);
console.log(`Default admin user '${env.DEFAULT_USERNAME}' created — password change required on first login`);
}
// ── Auth routes ────────────────────────────────────────────────────
@@ -215,9 +243,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
});
}
if (body.newPassword.length < 8) {
const pwError = validatePasswordStrength(body.newPassword);
if (pwError) {
return reply.status(400).send({
error: "New password must be at least 8 characters",
error: pwError,
code: "VALIDATION_ERROR",
});
}
@@ -253,6 +282,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}
}
// Revoke all API keys — if credentials were compromised, keys must be rotated too
db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, authUser.id)).run();
return reply.send({ ok: true });
});
@@ -297,9 +329,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
});
}
if (body.password.length < 8) {
const usernameError = validateUsername(body.username);
if (usernameError) {
return reply.status(400).send({
error: "Password must be at least 8 characters",
error: usernameError,
code: "VALIDATION_ERROR",
});
}
const registerPwError = validatePasswordStrength(body.password);
if (registerPwError) {
return reply.status(400).send({
error: registerPwError,
code: "VALIDATION_ERROR",
});
}
@@ -451,15 +492,30 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
// Try API key authentication if token has si_ prefix
if (token.startsWith("si_")) {
const apiKeys = db.select().from(schema.apiKeys).all();
for (const key of apiKeys) {
const prefix = computeKeyPrefix(token);
// Lookup by prefix (O(1) instead of scanning all keys)
const candidates = db.select().from(schema.apiKeys)
.where(eq(schema.apiKeys.keyPrefix, prefix))
.all();
// Fall back to full scan for legacy keys without a prefix
const keysToCheck = candidates.length > 0
? candidates
: db.select().from(schema.apiKeys).all().filter(k => !k.keyPrefix);
for (const key of keysToCheck) {
const matches = await verifyPassword(token, key.keyHash);
if (matches) {
// Update lastUsedAt
db.update(schema.apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(schema.apiKeys.id, key.id))
.run();
// Backfill prefix for legacy keys
if (!key.keyPrefix) {
db.update(schema.apiKeys)
.set({ keyPrefix: prefix, lastUsedAt: new Date() })
.where(eq(schema.apiKeys.id, key.id))
.run();
} else {
db.update(schema.apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(schema.apiKeys.id, key.id))
.run();
}
// Load the user
const apiUser = db.select().from(schema.users).where(eq(schema.users.id, key.userId)).get();
if (apiUser) {
+3 -1
View File
@@ -9,7 +9,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { eq, and } from "drizzle-orm";
import { db, schema } from "../db/index.js";
import { hashPassword, requireAuth } from "../plugins/auth.js";
import { hashPassword, computeKeyPrefix, requireAuth } from "../plugins/auth.js";
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/api-keys — Generate a new API key
@@ -32,6 +32,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
// Generate a raw API key: "si_" prefix + 48 random bytes as hex
const rawKey = `si_${randomBytes(48).toString("hex")}`;
const keyHash = await hashPassword(rawKey);
const keyPrefix = computeKeyPrefix(rawKey);
const id = randomUUID();
db.insert(schema.apiKeys)
@@ -39,6 +40,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
id,
userId: user.id,
keyHash,
keyPrefix,
name,
})
.run();
+20 -2
View File
@@ -17,6 +17,7 @@ import { validateImageBuffer } from "../lib/file-validation.js";
import { createWorkspace } from "../lib/workspace.js";
import { sanitizeFilename } from "../lib/filename.js";
import { db, schema } from "../db/index.js";
import { requireAuth, getAuthUser } from "../plugins/auth.js";
/** Schema for a single pipeline step. */
const pipelineStepSchema = z.object({
@@ -197,6 +198,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
app.post(
"/api/v1/pipeline/save",
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const body = request.body as unknown;
const result = savePipelineSchema.safeParse(body);
@@ -227,6 +231,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
db.insert(schema.pipelines)
.values({
id,
userId: user.id,
name,
description: description ?? null,
steps: JSON.stringify(steps),
@@ -250,8 +255,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
*/
app.get(
"/api/v1/pipeline/list",
async (_request: FastifyRequest, reply: FastifyReply) => {
const rows = db.select().from(schema.pipelines).all();
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
// Users see their own pipelines + legacy pipelines (no owner)
const rows = db.select().from(schema.pipelines).all()
.filter(row => !row.userId || row.userId === user.id);
const pipelines = rows.map((row) => ({
id: row.id,
@@ -276,6 +286,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) => {
const user = requireAuth(request, reply);
if (!user) return;
const { id } = request.params;
const existing = db
@@ -288,6 +301,11 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
return reply.status(404).send({ error: "Pipeline not found" });
}
// Only the owner (or admin) can delete; legacy pipelines (no owner) can be deleted by anyone
if (existing.userId && existing.userId !== user.id && user.role !== "admin") {
return reply.status(403).send({ error: "Not authorized to delete this pipeline" });
}
db.delete(schema.pipelines)
.where(eq(schema.pipelines.id, id))
.run();
+18 -4
View File
@@ -20,15 +20,29 @@ function sanitizeSvg(buffer: Buffer): Buffer {
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`);
}
let svg = buffer.toString("utf-8");
// Remove DOCTYPE to prevent XXE
svg = svg.replace(/<!DOCTYPE[^>]*>/gi, "");
// Remove DOCTYPE (XXE prevention, including internal subsets)
svg = svg.replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?>/gi, "");
// Remove XML processing instructions except <?xml version...?>
svg = svg.replace(/<\?(?!xml\s)[^?]*\?>/gi, "");
// Remove XInclude elements and namespace declarations
svg = svg.replace(/<[^>]*xi:include[^>]*\/?>/gi, "");
svg = svg.replace(/xmlns:xi\s*=\s*["'][^"']*["']/gi, "");
// Remove script tags
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, "");
// Remove event handlers (onload, onclick, etc.)
// Remove foreignObject elements (can embed arbitrary HTML)
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "");
svg = svg.replace(/<foreignObject[^>]*\/>/gi, "");
// Remove event handlers (onload, onclick, onerror, etc.)
svg = svg.replace(/\bon\w+\s*=/gi, "data-removed=");
// Remove external resource references
// Block dangerous URI schemes in href attributes
svg = svg.replace(/xlink:href\s*=\s*["']https?:\/\//gi, 'xlink:href="data:,');
svg = svg.replace(/href\s*=\s*["']https?:\/\//gi, 'href="data:,');
svg = svg.replace(/href\s*=\s*["']javascript:/gi, 'href="data:,');
svg = svg.replace(/href\s*=\s*["']data:text\/html/gi, 'href="data:,');
svg = svg.replace(/href\s*=\s*["']file:/gi, 'href="data:,');
// Block use elements referencing external resources
svg = svg.replace(/url\s*\(\s*["']?https?:\/\//gi, 'url("data:,');
svg = svg.replace(/url\s*\(\s*["']?file:/gi, 'url("data:,');
return Buffer.from(svg, "utf-8");
}