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
+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");
}