mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `api_keys` ADD `key_prefix` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `pipelines` ADD `user_id` text REFERENCES users(id);
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b73098db-4ef1-44e2-834f-9114a4da4e77",
|
||||||
|
"prevId": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458",
|
||||||
|
"tables": {
|
||||||
|
"api_keys": {
|
||||||
|
"name": "api_keys",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_hash": {
|
||||||
|
"name": "key_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_prefix": {
|
||||||
|
"name": "key_prefix",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'Default API Key'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"api_keys_user_id_users_id_fk": {
|
||||||
|
"name": "api_keys_user_id_users_id_fk",
|
||||||
|
"tableFrom": "api_keys",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"jobs": {
|
||||||
|
"name": "jobs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'queued'"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"name": "progress",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"input_files": {
|
||||||
|
"name": "input_files",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"output_path": {
|
||||||
|
"name": "output_path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"pipelines": {
|
||||||
|
"name": "pipelines",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"name": "steps",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"pipelines_user_id_users_id_fk": {
|
||||||
|
"name": "pipelines_user_id_users_id_fk",
|
||||||
|
"tableFrom": "pipelines",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"must_change_password": {
|
||||||
|
"name": "must_change_password",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1774125684700,
|
"when": 1774125684700,
|
||||||
"tag": "0001_amusing_omega_red",
|
"tag": "0001_amusing_omega_red",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1774357742003,
|
||||||
|
"tag": "0002_pale_silver_sable",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,7 @@ export const apiKeys = sqliteTable("api_keys", {
|
|||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
keyHash: text("key_hash").notNull(),
|
keyHash: text("key_hash").notNull(),
|
||||||
|
keyPrefix: text("key_prefix"),
|
||||||
name: text("name").notNull().default("Default API Key"),
|
name: text("name").notNull().default("Default API Key"),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
||||||
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
||||||
@@ -47,6 +48,7 @@ export const apiKeys = sqliteTable("api_keys", {
|
|||||||
|
|
||||||
export const pipelines = sqliteTable("pipelines", {
|
export const pipelines = sqliteTable("pipelines", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
steps: text("steps").notNull(), // JSON array of { toolId, settings }
|
steps: text("steps").notNull(), // JSON array of { toolId, settings }
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ app.addHook("onSend", async (_request, reply) => {
|
|||||||
reply.header("X-Frame-Options", "DENY");
|
reply.header("X-Frame-Options", "DENY");
|
||||||
reply.header("X-XSS-Protection", "0");
|
reply.header("X-XSS-Protection", "0");
|
||||||
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
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, {
|
await app.register(rateLimit, {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { readdir, stat, rm } from "node:fs/promises";
|
import { readdir, stat, rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { lt } from "drizzle-orm";
|
||||||
|
import { db, schema } from "../db/index.js";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
|
|
||||||
export function startCleanupCron() {
|
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
|
// Run on startup
|
||||||
cleanup();
|
cleanup();
|
||||||
|
purgeExpiredSessions();
|
||||||
|
|
||||||
// Schedule recurring cleanup
|
// Schedule recurring cleanup
|
||||||
setInterval(cleanup, intervalMs);
|
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`);
|
console.log(`Cleanup scheduled: every ${env.CLEANUP_INTERVAL_MINUTES}m, max age ${env.FILE_MAX_AGE_HOURS}h`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { randomUUID } from "node:crypto";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||||
@@ -39,6 +39,34 @@ export async function verifyPassword(
|
|||||||
return timingSafeEqual(derived, storedBuf);
|
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 ───────────────────────────────────────────────
|
// ── Request helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
/** Extract the authenticated user attached by authMiddleware. */
|
/** Extract the authenticated user attached by authMiddleware. */
|
||||||
@@ -90,11 +118,11 @@ export async function ensureDefaultAdmin(): Promise<void> {
|
|||||||
username: env.DEFAULT_USERNAME,
|
username: env.DEFAULT_USERNAME,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
role: "admin",
|
role: "admin",
|
||||||
mustChangePassword: false,
|
mustChangePassword: true,
|
||||||
})
|
})
|
||||||
.run();
|
.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 ────────────────────────────────────────────────────
|
// ── 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({
|
return reply.status(400).send({
|
||||||
error: "New password must be at least 8 characters",
|
error: pwError,
|
||||||
code: "VALIDATION_ERROR",
|
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 });
|
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({
|
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",
|
code: "VALIDATION_ERROR",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -451,15 +492,30 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
// Try API key authentication if token has si_ prefix
|
// Try API key authentication if token has si_ prefix
|
||||||
if (token.startsWith("si_")) {
|
if (token.startsWith("si_")) {
|
||||||
const apiKeys = db.select().from(schema.apiKeys).all();
|
const prefix = computeKeyPrefix(token);
|
||||||
for (const key of apiKeys) {
|
// 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);
|
const matches = await verifyPassword(token, key.keyHash);
|
||||||
if (matches) {
|
if (matches) {
|
||||||
// Update lastUsedAt
|
// Backfill prefix for legacy keys
|
||||||
db.update(schema.apiKeys)
|
if (!key.keyPrefix) {
|
||||||
.set({ lastUsedAt: new Date() })
|
db.update(schema.apiKeys)
|
||||||
.where(eq(schema.apiKeys.id, key.id))
|
.set({ keyPrefix: prefix, lastUsedAt: new Date() })
|
||||||
.run();
|
.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
|
// Load the user
|
||||||
const apiUser = db.select().from(schema.users).where(eq(schema.users.id, key.userId)).get();
|
const apiUser = db.select().from(schema.users).where(eq(schema.users.id, key.userId)).get();
|
||||||
if (apiUser) {
|
if (apiUser) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { db, schema } from "../db/index.js";
|
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> {
|
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// POST /api/v1/api-keys — Generate a new API key
|
// 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
|
// Generate a raw API key: "si_" prefix + 48 random bytes as hex
|
||||||
const rawKey = `si_${randomBytes(48).toString("hex")}`;
|
const rawKey = `si_${randomBytes(48).toString("hex")}`;
|
||||||
const keyHash = await hashPassword(rawKey);
|
const keyHash = await hashPassword(rawKey);
|
||||||
|
const keyPrefix = computeKeyPrefix(rawKey);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
|
||||||
db.insert(schema.apiKeys)
|
db.insert(schema.apiKeys)
|
||||||
@@ -39,6 +40,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
id,
|
id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
keyHash,
|
keyHash,
|
||||||
|
keyPrefix,
|
||||||
name,
|
name,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { validateImageBuffer } from "../lib/file-validation.js";
|
|||||||
import { createWorkspace } from "../lib/workspace.js";
|
import { createWorkspace } from "../lib/workspace.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
|
import { requireAuth, getAuthUser } from "../plugins/auth.js";
|
||||||
|
|
||||||
/** Schema for a single pipeline step. */
|
/** Schema for a single pipeline step. */
|
||||||
const pipelineStepSchema = z.object({
|
const pipelineStepSchema = z.object({
|
||||||
@@ -197,6 +198,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/v1/pipeline/save",
|
"/api/v1/pipeline/save",
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const user = requireAuth(request, reply);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
const body = request.body as unknown;
|
const body = request.body as unknown;
|
||||||
const result = savePipelineSchema.safeParse(body);
|
const result = savePipelineSchema.safeParse(body);
|
||||||
|
|
||||||
@@ -227,6 +231,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
db.insert(schema.pipelines)
|
db.insert(schema.pipelines)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
|
userId: user.id,
|
||||||
name,
|
name,
|
||||||
description: description ?? null,
|
description: description ?? null,
|
||||||
steps: JSON.stringify(steps),
|
steps: JSON.stringify(steps),
|
||||||
@@ -250,8 +255,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
*/
|
*/
|
||||||
app.get(
|
app.get(
|
||||||
"/api/v1/pipeline/list",
|
"/api/v1/pipeline/list",
|
||||||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const rows = db.select().from(schema.pipelines).all();
|
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) => ({
|
const pipelines = rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -276,6 +286,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
request: FastifyRequest<{ Params: { id: string } }>,
|
request: FastifyRequest<{ Params: { id: string } }>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
) => {
|
) => {
|
||||||
|
const user = requireAuth(request, reply);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const existing = db
|
const existing = db
|
||||||
@@ -288,6 +301,11 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
return reply.status(404).send({ error: "Pipeline not found" });
|
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)
|
db.delete(schema.pipelines)
|
||||||
.where(eq(schema.pipelines.id, id))
|
.where(eq(schema.pipelines.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
@@ -20,15 +20,29 @@ function sanitizeSvg(buffer: Buffer): Buffer {
|
|||||||
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`);
|
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`);
|
||||||
}
|
}
|
||||||
let svg = buffer.toString("utf-8");
|
let svg = buffer.toString("utf-8");
|
||||||
// Remove DOCTYPE to prevent XXE
|
// Remove DOCTYPE (XXE prevention, including internal subsets)
|
||||||
svg = svg.replace(/<!DOCTYPE[^>]*>/gi, "");
|
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
|
// Remove script tags
|
||||||
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, "");
|
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=");
|
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(/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*["']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");
|
return Buffer.from(svg, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ describe("Auth endpoints", () => {
|
|||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "admin", password: "adminpass" },
|
payload: { username: "admin", password: "Adminpass1" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
@@ -89,7 +89,7 @@ describe("Auth endpoints", () => {
|
|||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { password: "adminpass" },
|
payload: { password: "Adminpass1" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
@@ -137,7 +137,7 @@ describe("Auth endpoints", () => {
|
|||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "a".repeat(10_000), password: "adminpass" },
|
payload: { username: "a".repeat(10_000), password: "Adminpass1" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
@@ -146,7 +146,7 @@ describe("Auth endpoints", () => {
|
|||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "\u0000admin", password: "adminpass" },
|
payload: { username: "\u0000admin", password: "Adminpass1" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
@@ -210,7 +210,7 @@ describe("Auth endpoints", () => {
|
|||||||
const loginRes = await app.inject({
|
const loginRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "admin", password: "adminpass" },
|
payload: { username: "admin", password: "Adminpass1" },
|
||||||
});
|
});
|
||||||
const freshToken = JSON.parse(loginRes.body).token;
|
const freshToken = JSON.parse(loginRes.body).token;
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { username: "changepw_user", password: "original1234", role: "user" },
|
payload: { username: "changepw_user", password: "Original1234", role: "user" },
|
||||||
});
|
});
|
||||||
expect(regRes.statusCode).toBe(201);
|
expect(regRes.statusCode).toBe(201);
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ describe("Auth endpoints", () => {
|
|||||||
const loginRes = await app.inject({
|
const loginRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "changepw_user", password: "original1234" },
|
payload: { username: "changepw_user", password: "Original1234" },
|
||||||
});
|
});
|
||||||
const userToken = JSON.parse(loginRes.body).token;
|
const userToken = JSON.parse(loginRes.body).token;
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/change-password",
|
url: "/api/auth/change-password",
|
||||||
headers: { authorization: `Bearer ${userToken}` },
|
headers: { authorization: `Bearer ${userToken}` },
|
||||||
payload: { currentPassword: "original1234", newPassword: "newpassword99" },
|
payload: { currentPassword: "Original1234", newPassword: "Newpassword99" },
|
||||||
});
|
});
|
||||||
expect(changeRes.statusCode).toBe(200);
|
expect(changeRes.statusCode).toBe(200);
|
||||||
expect(JSON.parse(changeRes.body).ok).toBe(true);
|
expect(JSON.parse(changeRes.body).ok).toBe(true);
|
||||||
@@ -276,7 +276,7 @@ describe("Auth endpoints", () => {
|
|||||||
const oldLoginRes = await app.inject({
|
const oldLoginRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "changepw_user", password: "original1234" },
|
payload: { username: "changepw_user", password: "Original1234" },
|
||||||
});
|
});
|
||||||
expect(oldLoginRes.statusCode).toBe(401);
|
expect(oldLoginRes.statusCode).toBe(401);
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ describe("Auth endpoints", () => {
|
|||||||
const newLoginRes = await app.inject({
|
const newLoginRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "changepw_user", password: "newpassword99" },
|
payload: { username: "changepw_user", password: "Newpassword99" },
|
||||||
});
|
});
|
||||||
expect(newLoginRes.statusCode).toBe(200);
|
expect(newLoginRes.statusCode).toBe(200);
|
||||||
});
|
});
|
||||||
@@ -294,7 +294,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/change-password",
|
url: "/api/auth/change-password",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { currentPassword: "wrong_password_here", newPassword: "newpass1234" },
|
payload: { currentPassword: "wrong_password_here", newPassword: "Newpass1234" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
expect(JSON.parse(res.body).code).toBe("INVALID_PASSWORD");
|
expect(JSON.parse(res.body).code).toBe("INVALID_PASSWORD");
|
||||||
@@ -305,7 +305,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/change-password",
|
url: "/api/auth/change-password",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { currentPassword: "adminpass", newPassword: "short" },
|
payload: { currentPassword: "Adminpass1", newPassword: "short" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR");
|
expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR");
|
||||||
@@ -316,7 +316,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/change-password",
|
url: "/api/auth/change-password",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { currentPassword: "adminpass" },
|
payload: { currentPassword: "Adminpass1" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
@@ -325,7 +325,7 @@ describe("Auth endpoints", () => {
|
|||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/change-password",
|
url: "/api/auth/change-password",
|
||||||
payload: { currentPassword: "adminpass", newPassword: "newpass1234" },
|
payload: { currentPassword: "Adminpass1", newPassword: "Newpass1234" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
@@ -338,7 +338,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { username: "newuser1", password: "password1234", role: "user" },
|
payload: { username: "newuser1", password: "Password1234", role: "user" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(201);
|
expect(res.statusCode).toBe(201);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
@@ -353,14 +353,14 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { username: "duplicate_user", password: "password1234" },
|
payload: { username: "duplicate_user", password: "Password1234" },
|
||||||
});
|
});
|
||||||
// Second attempt
|
// Second attempt
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { username: "duplicate_user", password: "differentpass1" },
|
payload: { username: "duplicate_user", password: "Differentpass1" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(409);
|
expect(res.statusCode).toBe(409);
|
||||||
expect(JSON.parse(res.body).code).toBe("CONFLICT");
|
expect(JSON.parse(res.body).code).toBe("CONFLICT");
|
||||||
@@ -381,7 +381,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { password: "password1234" },
|
payload: { password: "Password1234" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
@@ -392,13 +392,13 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { username: "regular_user", password: "password1234", role: "user" },
|
payload: { username: "regular_user", password: "Password1234", role: "user" },
|
||||||
});
|
});
|
||||||
// Log in as regular user
|
// Log in as regular user
|
||||||
const loginRes = await app.inject({
|
const loginRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "regular_user", password: "password1234" },
|
payload: { username: "regular_user", password: "Password1234" },
|
||||||
});
|
});
|
||||||
const userToken = JSON.parse(loginRes.body).token;
|
const userToken = JSON.parse(loginRes.body).token;
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${userToken}` },
|
headers: { authorization: `Bearer ${userToken}` },
|
||||||
payload: { username: "sneaky_user", password: "password1234" },
|
payload: { username: "sneaky_user", password: "Password1234" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(403);
|
expect(res.statusCode).toBe(403);
|
||||||
});
|
});
|
||||||
@@ -417,7 +417,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { username: "badrole_user", password: "password1234", role: "superadmin" },
|
payload: { username: "badrole_user", password: "Password1234", role: "superadmin" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(201);
|
expect(res.statusCode).toBe(201);
|
||||||
expect(JSON.parse(res.body).role).toBe("user");
|
expect(JSON.parse(res.body).role).toBe("user");
|
||||||
@@ -450,7 +450,7 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { username: "doomed_user", password: "password1234" },
|
payload: { username: "doomed_user", password: "Password1234" },
|
||||||
});
|
});
|
||||||
const userId = JSON.parse(regRes.body).id;
|
const userId = JSON.parse(regRes.body).id;
|
||||||
|
|
||||||
@@ -466,7 +466,7 @@ describe("Auth endpoints", () => {
|
|||||||
const loginRes = await app.inject({
|
const loginRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "doomed_user", password: "password1234" },
|
payload: { username: "doomed_user", password: "Password1234" },
|
||||||
});
|
});
|
||||||
expect(loginRes.statusCode).toBe(401);
|
expect(loginRes.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
@@ -486,12 +486,12 @@ describe("Auth endpoints", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { username: "nonadmin_deleter", password: "password1234", role: "user" },
|
payload: { username: "nonadmin_deleter", password: "Password1234", role: "user" },
|
||||||
});
|
});
|
||||||
const loginRes = await app.inject({
|
const loginRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "nonadmin_deleter", password: "password1234" },
|
payload: { username: "nonadmin_deleter", password: "Password1234" },
|
||||||
});
|
});
|
||||||
const userToken = JSON.parse(loginRes.body).token;
|
const userToken = JSON.parse(loginRes.body).token;
|
||||||
|
|
||||||
@@ -1340,12 +1340,12 @@ describe("Settings", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/register",
|
url: "/api/auth/register",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { username: "settings_user", password: "password1234", role: "user" },
|
payload: { username: "settings_user", password: "Password1234", role: "user" },
|
||||||
});
|
});
|
||||||
const loginRes = await app.inject({
|
const loginRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: { username: "settings_user", password: "password1234" },
|
payload: { username: "settings_user", password: "Password1234" },
|
||||||
});
|
});
|
||||||
const userToken = JSON.parse(loginRes.body).token;
|
const userToken = JSON.parse(loginRes.body).token;
|
||||||
|
|
||||||
@@ -2727,7 +2727,7 @@ describe("Edge cases & adversarial inputs", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
payload: JSON.stringify(["admin", "adminpass"]),
|
payload: JSON.stringify(["admin", "Adminpass1"]),
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export async function loginAsAdmin(
|
|||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
payload: {
|
payload: {
|
||||||
username: "admin",
|
username: "admin",
|
||||||
password: "adminpass",
|
password: "Adminpass1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
env: {
|
env: {
|
||||||
AUTH_ENABLED: "true",
|
AUTH_ENABLED: "true",
|
||||||
DEFAULT_USERNAME: "admin",
|
DEFAULT_USERNAME: "admin",
|
||||||
DEFAULT_PASSWORD: "adminpass",
|
DEFAULT_PASSWORD: "Adminpass1",
|
||||||
DB_PATH: path.join(testDir, "test.db"),
|
DB_PATH: path.join(testDir, "test.db"),
|
||||||
WORKSPACE_PATH: path.join(testDir, "workspace"),
|
WORKSPACE_PATH: path.join(testDir, "workspace"),
|
||||||
MAX_UPLOAD_SIZE_MB: "10",
|
MAX_UPLOAD_SIZE_MB: "10",
|
||||||
|
|||||||
Reference in New Issue
Block a user