Merge branch 'dev'

This commit is contained in:
Michael Freno
2025-12-28 21:58:17 -05:00
24 changed files with 5373 additions and 165 deletions

View File

@@ -5,7 +5,11 @@
"dev": "vinxi dev", "dev": "vinxi dev",
"dev-flush": "vinxi dev --env-file=.env", "dev-flush": "vinxi dev --env-file=.env",
"build": "vinxi build", "build": "vinxi build",
"start": "vinxi start" "start": "vinxi start",
"test": "bun test",
"test:security": "bun test src/server/security/",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.953.0", "@aws-sdk/client-s3": "^3.953.0",

View File

@@ -383,38 +383,69 @@ const SuggestionDecoration = Extension.create({
return DecorationSet.empty; return DecorationSet.empty;
}, },
apply(tr, oldSet, oldState, newState) { apply(tr, oldSet, oldState, newState) {
// Get suggestion from editor storage // Get suggestion and loading state from editor storage
const suggestion = const storage = (editor.storage as any).suggestionDecoration || {};
(editor.storage as any).suggestionDecoration?.text || ""; const suggestion = storage.text || "";
const isLoading = storage.isLoading || false;
if (!suggestion) {
return DecorationSet.empty;
}
const { selection } = newState; const { selection } = newState;
const pos = selection.$anchor.pos; const pos = selection.$anchor.pos;
const decorations = [];
// Create a widget decoration at cursor position // Show loading spinner inline if loading
const decoration = Decoration.widget( if (isLoading) {
pos, const loadingDecoration = Decoration.widget(
() => { pos,
const span = document.createElement("span"); () => {
span.textContent = suggestion; const span = document.createElement("span");
span.style.color = "rgb(239, 68, 68)"; // Tailwind red-500 span.className = "inline-flex items-center ml-1";
span.style.opacity = "0.5"; span.style.pointerEvents = "none";
span.style.fontStyle = "italic";
span.style.fontFamily = "monospace";
span.style.pointerEvents = "none";
span.style.whiteSpace = "pre-wrap";
span.style.wordWrap = "break-word";
return span;
},
{
side: 1 // Place after the cursor
}
);
return DecorationSet.create(newState.doc, [decoration]); // Create a simple spinner using CSS animation
const spinner = document.createElement("span");
spinner.className =
"inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin";
spinner.style.color = "rgb(239, 68, 68)"; // Tailwind red-500
spinner.style.opacity = "0.5";
span.appendChild(spinner);
return span;
},
{
side: 1 // Place after the cursor
}
);
decorations.push(loadingDecoration);
}
// Show suggestion text if present
if (suggestion) {
const suggestionDecoration = Decoration.widget(
pos,
() => {
const span = document.createElement("span");
span.textContent = suggestion;
span.style.color = "rgb(239, 68, 68)"; // Tailwind red-500
span.style.opacity = "0.5";
span.style.fontStyle = "italic";
span.style.fontFamily = "monospace";
span.style.pointerEvents = "none";
span.style.whiteSpace = "pre-wrap";
span.style.wordWrap = "break-word";
return span;
},
{
side: 1 // Place after the cursor
}
);
decorations.push(suggestionDecoration);
}
if (decorations.length === 0) {
return DecorationSet.empty;
}
return DecorationSet.create(newState.doc, decorations);
} }
}, },
props: { props: {
@@ -428,7 +459,8 @@ const SuggestionDecoration = Extension.create({
addStorage() { addStorage() {
return { return {
text: "" text: "",
isLoading: false
}; };
} }
}); });
@@ -804,10 +836,14 @@ export default function TextEditor(props: TextEditorProps) {
createEffect(() => { createEffect(() => {
const instance = editor(); const instance = editor();
const suggestion = currentSuggestion(); const suggestion = currentSuggestion();
const loading = isInfillLoading();
if (instance) { if (instance) {
// Store suggestion in editor storage (cast to any to avoid TS error) // Store suggestion and loading state in editor storage (cast to any to avoid TS error)
(instance.storage as any).suggestionDecoration = { text: suggestion }; (instance.storage as any).suggestionDecoration = {
text: suggestion,
isLoading: loading
};
// Force view update to show/hide decoration // Force view update to show/hide decoration
instance.view.dispatch(instance.state.tr); instance.view.dispatch(instance.state.tr);
} }
@@ -833,13 +869,6 @@ export default function TextEditor(props: TextEditorProps) {
stream: false stream: false
}; };
console.log("[Infill] Request:", {
prefix: context.prefix,
suffix: context.suffix,
prefixLength: context.prefix.length,
suffixLength: context.suffix.length
});
const response = await fetch(config.endpoint, { const response = await fetch(config.endpoint, {
method: "POST", method: "POST",
headers: { headers: {
@@ -4130,16 +4159,6 @@ export default function TextEditor(props: TextEditorProps) {
</div> </div>
</div> </div>
</Show> </Show>
{/* Infill Loading Indicator */}
<Show when={isInfillLoading()}>
<div class="bg-surface0 border-surface2 text-subtext0 fixed right-4 bottom-4 z-50 animate-pulse rounded border px-3 py-2 text-xs shadow-lg">
<span>
<Spinner />
</span>
AI thinking...
</div>
</Show>
</div> </div>
); );
} }

View File

@@ -11,6 +11,20 @@ const getBaseUrl = () => {
return `http://localhost:${process.env.PORT ?? 3000}`; return `http://localhost:${process.env.PORT ?? 3000}`;
}; };
/**
* Get CSRF token from cookies
*/
function getCSRFToken(): string | undefined {
if (typeof document === "undefined") return undefined;
const value = `; ${document.cookie}`;
const parts = value.split(`; csrf-token=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift();
}
return undefined;
}
export const api = createTRPCProxyClient<AppRouter>({ export const api = createTRPCProxyClient<AppRouter>({
links: [ links: [
// Only enable logging in development mode // Only enable logging in development mode
@@ -30,7 +44,11 @@ export const api = createTRPCProxyClient<AppRouter>({
: []), : []),
// identifies what url will handle trpc requests // identifies what url will handle trpc requests
httpBatchLink({ httpBatchLink({
url: `${getBaseUrl()}/api/trpc` url: `${getBaseUrl()}/api/trpc`,
headers: () => {
const csrfToken = getCSRFToken();
return csrfToken ? { "x-csrf-token": csrfToken } : {};
}
}) })
] ]
}); });

View File

@@ -6,37 +6,102 @@
* Validate email format * Validate email format
*/ */
export function isValidEmail(email: string): boolean { export function isValidEmail(email: string): boolean {
// Basic email format check
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email); if (!emailRegex.test(email)) {
return false;
}
// Additional checks for invalid patterns
// Reject consecutive dots
if (email.includes("..")) {
return false;
}
return true;
} }
/** /**
* Validate password strength * Password strength levels
*/
export type PasswordStrength = "weak" | "fair" | "good" | "strong";
/**
* Validate password strength with comprehensive requirements
*/ */
export function validatePassword(password: string): { export function validatePassword(password: string): {
isValid: boolean; isValid: boolean;
errors: string[]; errors: string[];
strength: PasswordStrength;
} { } {
const errors: string[] = []; const errors: string[] = [];
if (password.length < 8) { // Minimum length: 12 characters
errors.push("Password must be at least 8 characters long"); if (password.length < 12) {
errors.push("Password must be at least 12 characters long");
} }
// Optional: Add more password requirements // Require uppercase letter
// if (!/[A-Z]/.test(password)) { if (!/[A-Z]/.test(password)) {
// errors.push("Password must contain at least one uppercase letter"); errors.push("Password must contain at least one uppercase letter");
// } }
// if (!/[a-z]/.test(password)) {
// errors.push("Password must contain at least one lowercase letter"); // Require lowercase letter
// } if (!/[a-z]/.test(password)) {
// if (!/[0-9]/.test(password)) { errors.push("Password must contain at least one lowercase letter");
// errors.push("Password must contain at least one number"); }
// }
// Require number
if (!/[0-9]/.test(password)) {
errors.push("Password must contain at least one number");
}
// Require special character
if (!/[^A-Za-z0-9]/.test(password)) {
errors.push("Password must contain at least one special character");
}
// Check for common weak passwords
const commonPasswords = [
"password",
"12345678",
"qwerty",
"letmein",
"welcome",
"monkey",
"dragon",
"master",
"sunshine",
"princess",
"admin",
"login"
];
const lowerPassword = password.toLowerCase();
for (const common of commonPasswords) {
if (lowerPassword.includes(common)) {
errors.push("Password contains common patterns and is not secure");
break;
}
}
// Calculate password strength
let strength: PasswordStrength = "weak";
if (errors.length === 0) {
if (password.length >= 20) {
strength = "strong";
} else if (password.length >= 16) {
strength = "good";
} else if (password.length >= 12) {
strength = "fair";
}
}
return { return {
isValid: errors.length === 0, isValid: errors.length === 0,
errors errors,
strength
}; };
} }

View File

@@ -9,7 +9,7 @@ export async function POST() {
setCookie(event, "userIDToken", "", { setCookie(event, "userIDToken", "", {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: true, // Always enforce secure cookies
sameSite: "lax", sameSite: "lax",
maxAge: 0, // Expire immediately maxAge: 0, // Expire immediately
expires: new Date(0) // Set expiry to past date expires: new Date(0) // Set expiry to past date

View File

@@ -331,12 +331,12 @@ export default function LoginPage() {
<Show when={error()}> <Show when={error()}>
<div class="border-maroon bg-red mb-4 w-full max-w-md rounded-lg border px-4 py-3 text-center"> <div class="border-maroon bg-red mb-4 w-full max-w-md rounded-lg border px-4 py-3 text-center">
<Show when={error() === "passwordMismatch"}> <Show when={error() === "passwordMismatch"}>
<div class="text-red text-lg font-semibold"> <div class="text-base text-lg font-semibold">
Passwords did not match! Passwords did not match!
</div> </div>
</Show> </Show>
<Show when={error() === "duplicate"}> <Show when={error() === "duplicate"}>
<div class="text-red text-lg font-semibold"> <div class="text-base text-lg font-semibold">
Email Already Exists! Email Already Exists!
</div> </div>
</Show> </Show>
@@ -347,7 +347,7 @@ export default function LoginPage() {
error() !== "duplicate" error() !== "duplicate"
} }
> >
<div class="text-red text-sm">{error()}</div> <div class="text-base text-sm">{error()}</div>
</Show> </Show>
</div> </div>
</Show> </Show>

View File

@@ -1,5 +1,6 @@
import { exampleRouter } from "./routers/example"; import { exampleRouter } from "./routers/example";
import { authRouter } from "./routers/auth"; import { authRouter } from "./routers/auth";
import { auditRouter } from "./routers/audit";
import { databaseRouter } from "./routers/database"; import { databaseRouter } from "./routers/database";
import { lineageRouter } from "./routers/lineage"; import { lineageRouter } from "./routers/lineage";
import { miscRouter } from "./routers/misc"; import { miscRouter } from "./routers/misc";
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
example: exampleRouter, example: exampleRouter,
auth: authRouter, auth: authRouter,
audit: auditRouter,
database: databaseRouter, database: databaseRouter,
lineage: lineageRouter, lineage: lineageRouter,
misc: miscRouter, misc: miscRouter,

View File

@@ -0,0 +1,133 @@
import { createTRPCRouter, adminProcedure } from "../utils";
import { z } from "zod";
import {
queryAuditLogs,
getUserSecuritySummary,
cleanupOldLogs,
getFailedLoginAttempts,
detectSuspiciousActivity
} from "~/server/audit";
/**
* Audit log router - admin-only endpoints for querying security logs
*/
export const auditRouter = createTRPCRouter({
/**
* Query audit logs with filters
*/
getLogs: adminProcedure
.input(
z.object({
userId: z.string().optional(),
eventType: z.string().optional(),
success: z.boolean().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
limit: z.number().min(1).max(1000).default(100),
offset: z.number().min(0).default(0)
})
)
.query(async ({ input }) => {
const logs = await queryAuditLogs({
userId: input.userId,
eventType: input.eventType as any,
success: input.success,
startDate: input.startDate,
endDate: input.endDate,
limit: input.limit,
offset: input.offset
});
return {
logs,
count: logs.length,
offset: input.offset,
limit: input.limit
};
}),
/**
* Get failed login attempts (last 24 hours by default)
*/
getFailedLogins: adminProcedure
.input(
z.object({
hours: z.number().min(1).max(168).default(24),
limit: z.number().min(1).max(1000).default(100)
})
)
.query(async ({ input }) => {
const attempts = (await getFailedLoginAttempts(
input.hours,
input.limit
)) as Array<any>;
return {
attempts,
count: attempts.length,
timeWindow: `${input.hours} hours`
};
}),
/**
* Get security summary for a specific user
*/
getUserSummary: adminProcedure
.input(
z.object({
userId: z.string(),
days: z.number().min(1).max(90).default(30)
})
)
.query(async ({ input }) => {
const summary = await getUserSecuritySummary(input.userId, input.days);
return {
userId: input.userId,
summary,
timeWindow: `${input.days} days`
};
}),
/**
* Detect suspicious activity patterns
*/
getSuspiciousActivity: adminProcedure
.input(
z.object({
hours: z.number().min(1).max(168).default(24),
minFailedAttempts: z.number().min(1).default(5)
})
)
.query(async ({ input }) => {
const suspicious = (await detectSuspiciousActivity(
input.hours,
input.minFailedAttempts
)) as Array<any>;
return {
suspicious,
count: suspicious.length,
timeWindow: `${input.hours} hours`,
threshold: input.minFailedAttempts
};
}),
/**
* Clean up old logs
*/
cleanupLogs: adminProcedure
.input(
z.object({
olderThanDays: z.number().min(1).max(365).default(90)
})
)
.mutation(async ({ input }) => {
const deleted = await cleanupOldLogs(input.olderThanDays);
return {
deleted,
olderThanDays: input.olderThanDays
};
})
});

View File

@@ -3,7 +3,12 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { v4 as uuidV4 } from "uuid"; import { v4 as uuidV4 } from "uuid";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils"; import {
ConnectionFactory,
hashPassword,
checkPassword,
checkPasswordSafe
} from "~/server/utils";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import { setCookie, getCookie } from "vinxi/http"; import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/db/types"; import type { User } from "~/db/types";
@@ -21,19 +26,123 @@ import {
resetPasswordSchema, resetPasswordSchema,
requestPasswordResetSchema requestPasswordResetSchema
} from "../schemas/user"; } from "../schemas/user";
import {
setCSRFToken,
csrfProtection,
getClientIP,
getUserAgent,
getAuditContext,
rateLimitLogin,
rateLimitPasswordReset,
rateLimitRegistration,
rateLimitEmailVerification
} from "~/server/security";
import { logAuditEvent } from "~/server/audit";
import type { H3Event } from "vinxi/http";
import type { Context } from "../utils";
/**
* Safely extract H3Event from Context
* In production: ctx.event is APIEvent, H3Event is at ctx.event.nativeEvent
* In development: ctx.event might be H3Event directly
*/
function getH3Event(ctx: Context): H3Event {
// Check if nativeEvent exists (production)
if (ctx.event && 'nativeEvent' in ctx.event && ctx.event.nativeEvent) {
return ctx.event.nativeEvent as H3Event;
}
// Otherwise, assume ctx.event is H3Event (development)
return ctx.event as unknown as H3Event;
}
/**
* Create JWT with session tracking
* @param userId - User ID
* @param sessionId - Session ID for revocation tracking
* @param expiresIn - Token expiration time (e.g., "14d", "12h")
*/
async function createJWT( async function createJWT(
userId: string, userId: string,
sessionId: string,
expiresIn: string = "14d" expiresIn: string = "14d"
): Promise<string> { ): Promise<string> {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ id: userId }) const token = await new SignJWT({
id: userId,
sid: sessionId, // Session ID for revocation
iat: Math.floor(Date.now() / 1000)
})
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setExpirationTime(expiresIn) .setExpirationTime(expiresIn)
.sign(secret); .sign(secret);
return token; return token;
} }
/**
* Create a new session in the database
* @param userId - User ID
* @param expiresIn - Session expiration (e.g., "14d", "12h")
* @param ipAddress - Client IP address
* @param userAgent - Client user agent string
* @returns Session ID
*/
async function createSession(
userId: string,
expiresIn: string,
ipAddress: string,
userAgent: string
): Promise<string> {
const conn = ConnectionFactory();
const sessionId = uuidV4();
// Calculate expiration timestamp
const expiresAt = new Date();
if (expiresIn.endsWith("d")) {
const days = parseInt(expiresIn);
expiresAt.setDate(expiresAt.getDate() + days);
} else if (expiresIn.endsWith("h")) {
const hours = parseInt(expiresIn);
expiresAt.setHours(expiresAt.getHours() + hours);
}
await conn.execute({
sql: `INSERT INTO Session (id, user_id, token_family, expires_at, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [
sessionId,
userId,
uuidV4(), // token_family for future refresh token rotation
expiresAt.toISOString(),
ipAddress,
userAgent
]
});
return sessionId;
}
/**
* Helper to set authentication cookies including CSRF token
*/
function setAuthCookies(
event: any,
token: string,
options: { maxAge?: number } = {}
) {
const cookieOptions: any = {
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
...options
};
setCookie(event, "userIDToken", token, cookieOptions);
// Set CSRF token for authenticated session
setCSRFToken(event);
}
async function sendEmail(to: string, subject: string, htmlContent: string) { async function sendEmail(to: string, subject: string, htmlContent: string) {
const apiKey = env.SENDINBLUE_KEY; const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
@@ -199,9 +308,20 @@ export const authRouter = createTRPCRouter({
} }
} }
const token = await createJWT(userId); // Create session with client info
const clientIP = getClientIP(getH3Event(ctx));
const userAgent =
getUserAgent(getH3Event(ctx));
const sessionId = await createSession(
userId,
"14d",
clientIP,
userAgent
);
setCookie(ctx.event.nativeEvent, "userIDToken", token, { const token = await createJWT(userId, sessionId);
setCookie(getH3Event(ctx), "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/", path: "/",
httpOnly: true, httpOnly: true,
@@ -209,11 +329,37 @@ export const authRouter = createTRPCRouter({
sameSite: "lax" sameSite: "lax"
}); });
// Set CSRF token for authenticated session
setCSRFToken(getH3Event(ctx));
// Log successful OAuth login
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "github", isNewUser: !res.rows[0] },
ipAddress: clientIP,
userAgent,
success: true
});
return { return {
success: true, success: true,
redirectTo: "/account" redirectTo: "/account"
}; };
} catch (error) { } catch (error) {
// Log failed OAuth login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
method: "github",
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;
} }
@@ -345,9 +491,20 @@ export const authRouter = createTRPCRouter({
} }
} }
const token = await createJWT(userId); // Create session with client info
const clientIP = getClientIP(getH3Event(ctx));
const userAgent =
getUserAgent(getH3Event(ctx));
const sessionId = await createSession(
userId,
"14d",
clientIP,
userAgent
);
setCookie(ctx.event.nativeEvent, "userIDToken", token, { const token = await createJWT(userId, sessionId);
setCookie(getH3Event(ctx), "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/", path: "/",
httpOnly: true, httpOnly: true,
@@ -355,11 +512,37 @@ export const authRouter = createTRPCRouter({
sameSite: "lax" sameSite: "lax"
}); });
// Set CSRF token for authenticated session
setCSRFToken(getH3Event(ctx));
// Log successful OAuth login
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "google", isNewUser: !res.rows[0] },
ipAddress: clientIP,
userAgent,
success: true
});
return { return {
success: true, success: true,
redirectTo: "/account" redirectTo: "/account"
}; };
} catch (error) { } catch (error) {
// Log failed OAuth login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
method: "google",
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;
} }
@@ -428,7 +611,19 @@ export const authRouter = createTRPCRouter({
const userId = (res.rows[0] as unknown as User).id; const userId = (res.rows[0] as unknown as User).id;
const userToken = await createJWT(userId); // Create session with client info
const clientIP = getClientIP(getH3Event(ctx));
const userAgent =
getUserAgent(getH3Event(ctx));
const expiresIn = rememberMe ? "14d" : "12h";
const sessionId = await createSession(
userId,
expiresIn,
clientIP,
userAgent
);
const userToken = await createJWT(userId, sessionId, expiresIn);
const cookieOptions: any = { const cookieOptions: any = {
path: "/", path: "/",
@@ -442,17 +637,44 @@ export const authRouter = createTRPCRouter({
} }
setCookie( setCookie(
ctx.event.nativeEvent, getH3Event(ctx),
"userIDToken", "userIDToken",
userToken, userToken,
cookieOptions cookieOptions
); );
// Set CSRF token for authenticated session
setCSRFToken(getH3Event(ctx));
// Log successful email link login
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "email_link", rememberMe: rememberMe || false },
ipAddress: clientIP,
userAgent,
success: true
});
return { return {
success: true, success: true,
redirectTo: "/account" redirectTo: "/account"
}; };
} catch (error) { } catch (error) {
// Log failed email link login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
method: "email_link",
email: input.email,
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;
} }
@@ -471,7 +693,7 @@ export const authRouter = createTRPCRouter({
token: z.string() token: z.string()
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const { email, token } = input; const { email, token } = input;
try { try {
@@ -486,15 +708,47 @@ export const authRouter = createTRPCRouter({
} }
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Get user ID for audit log
const userRes = await conn.execute({
sql: "SELECT id FROM User WHERE email = ?",
args: [email]
});
const userId = userRes.rows[0] ? (userRes.rows[0] as any).id : null;
const query = `UPDATE User SET email_verified = ? WHERE email = ?`; const query = `UPDATE User SET email_verified = ? WHERE email = ?`;
const params = [true, email]; const params = [true, email];
await conn.execute({ sql: query, args: params }); await conn.execute({ sql: query, args: params });
// Log successful email verification
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
eventType: "auth.email.verify.complete",
eventData: { email },
ipAddress,
userAgent,
success: true
});
return { return {
success: true, success: true,
message: "Email verification success, you may close this window" message: "Email verification success, you may close this window"
}; };
} catch (error) { } catch (error) {
// Log failed email verification
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.email.verify.complete",
eventData: {
email,
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;
} }
@@ -511,6 +765,10 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation } = input; const { email, password, passwordConfirmation } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
rateLimitRegistration(clientIP, getH3Event(ctx));
// Schema already validates password match, but double check // Schema already validates password match, but double check
if (password !== passwordConfirmation) { if (password !== passwordConfirmation) {
throw new TRPCError({ throw new TRPCError({
@@ -529,9 +787,20 @@ export const authRouter = createTRPCRouter({
args: [userId, email, passwordHash, "email"] args: [userId, email, passwordHash, "email"]
}); });
const token = await createJWT(userId); // Create session with client info
const clientIP = getClientIP(getH3Event(ctx));
const userAgent =
getUserAgent(getH3Event(ctx));
const sessionId = await createSession(
userId,
"14d",
clientIP,
userAgent
);
setCookie(ctx.event.nativeEvent, "userIDToken", token, { const token = await createJWT(userId, sessionId);
setCookie(getH3Event(ctx), "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/", path: "/",
httpOnly: true, httpOnly: true,
@@ -539,8 +808,35 @@ export const authRouter = createTRPCRouter({
sameSite: "lax" sameSite: "lax"
}); });
// Set CSRF token for authenticated session
setCSRFToken(getH3Event(ctx));
// Log successful registration
await logAuditEvent({
userId,
eventType: "auth.register.success",
eventData: { email, method: "email" },
ipAddress: clientIP,
userAgent,
success: true
});
return { success: true, message: "success" }; return { success: true, message: "success" };
} catch (e) { } catch (e) {
// Log failed registration
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.register.failed",
eventData: {
email,
method: "email",
reason: e instanceof Error ? e.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
console.error("Registration error:", e); console.error("Registration error:", e);
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -552,66 +848,131 @@ export const authRouter = createTRPCRouter({
emailPasswordLogin: publicProcedure emailPasswordLogin: publicProcedure
.input(loginUserSchema) .input(loginUserSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email, password, rememberMe } = input; try {
const { email, password, rememberMe } = input;
const conn = ConnectionFactory(); // Apply rate limiting
const res = await conn.execute({ const clientIP = getClientIP(getH3Event(ctx));
sql: "SELECT * FROM User WHERE email = ?", rateLimitLogin(email, clientIP, getH3Event(ctx));
args: [email]
});
if (res.rows.length === 0) { const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email]
});
// Always run password check to prevent timing attacks
const user =
res.rows.length > 0 ? (res.rows[0] as unknown as User) : null;
const passwordHash = user?.password_hash || null;
const passwordMatch = await checkPasswordSafe(password, passwordHash);
// Check all conditions after password verification
if (!user || !passwordHash || !passwordMatch) {
// Debug logging (remove after fixing)
console.log("Login failed for:", email);
console.log("User found:", !!user);
console.log("Password hash exists:", !!passwordHash);
console.log("Password match:", passwordMatch);
// Log failed login attempt (wrap in try-catch to ensure it never blocks auth flow)
try {
const { ipAddress, userAgent } = getAuditContext(
getH3Event(ctx)
);
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
email,
method: "password",
reason: "invalid_credentials"
},
ipAddress,
userAgent,
success: false
});
} catch (auditError) {
console.error("Audit logging failed:", auditError);
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match"
});
}
if (
!user.provider ||
!["email", "google", "github", "apple"].includes(user.provider)
) {
await conn.execute({
sql: "UPDATE User SET provider = ? WHERE id = ?",
args: ["email", user.id]
});
}
const expiresIn = rememberMe ? "14d" : "12h";
// Create session with client info (reuse clientIP from rate limiting)
const userAgent =
getUserAgent(getH3Event(ctx));
const sessionId = await createSession(
user.id,
expiresIn,
clientIP,
userAgent
);
const token = await createJWT(user.id, sessionId, expiresIn);
const cookieOptions: any = {
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax"
};
if (rememberMe) {
cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days
}
setCookie(getH3Event(ctx), "userIDToken", token, cookieOptions);
// Set CSRF token for authenticated session
setCSRFToken(getH3Event(ctx));
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
try {
await logAuditEvent({
userId: user.id,
eventType: "auth.login.success",
eventData: { method: "password", rememberMe: rememberMe || false },
ipAddress: clientIP,
userAgent,
success: true
});
} catch (auditError) {
console.error("Audit logging failed:", auditError);
}
return { success: true, message: "success" };
} catch (error) {
// Log the actual error for debugging
console.error("emailPasswordLogin error:", error);
console.error("Error stack:", error instanceof Error ? error.stack : "no stack");
// Re-throw TRPCErrors as-is
if (error instanceof TRPCError) {
throw error;
}
// Wrap other errors
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "INTERNAL_SERVER_ERROR",
message: "no-match" message: "An error occurred during login",
cause: error
}); });
} }
const user = res.rows[0] as unknown as User;
if (!user.password_hash) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match"
});
}
const passwordMatch = await checkPassword(password, user.password_hash);
if (!passwordMatch) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match"
});
}
if (
!user.provider ||
!["email", "google", "github", "apple"].includes(user.provider)
) {
await conn.execute({
sql: "UPDATE User SET provider = ? WHERE id = ?",
args: ["email", user.id]
});
}
const expiresIn = rememberMe ? "14d" : "12h";
const token = await createJWT(user.id, expiresIn);
const cookieOptions: any = {
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax"
};
if (rememberMe) {
cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days
}
setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions);
return { success: true, message: "success" };
}), }),
requestEmailLinkLogin: publicProcedure requestEmailLinkLogin: publicProcedure
@@ -626,7 +987,7 @@ export const authRouter = createTRPCRouter({
try { try {
const requested = getCookie( const requested = getCookie(
ctx.event.nativeEvent, getH3Event(ctx),
"emailLoginLinkRequested" "emailLoginLinkRequested"
); );
if (requested) { if (requested) {
@@ -705,7 +1066,7 @@ export const authRouter = createTRPCRouter({
const exp = new Date(Date.now() + 2 * 60 * 1000); const exp = new Date(Date.now() + 2 * 60 * 1000);
setCookie( setCookie(
ctx.event.nativeEvent, getH3Event(ctx),
"emailLoginLinkRequested", "emailLoginLinkRequested",
exp.toUTCString(), exp.toUTCString(),
{ {
@@ -745,9 +1106,13 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email } = input; const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
rateLimitPasswordReset(clientIP, getH3Event(ctx));
try { try {
const requested = getCookie( const requested = getCookie(
ctx.event.nativeEvent, getH3Event(ctx),
"passwordResetRequested" "passwordResetRequested"
); );
if (requested) { if (requested) {
@@ -821,7 +1186,7 @@ export const authRouter = createTRPCRouter({
const exp = new Date(Date.now() + 5 * 60 * 1000); const exp = new Date(Date.now() + 5 * 60 * 1000);
setCookie( setCookie(
ctx.event.nativeEvent, getH3Event(ctx),
"passwordResetRequested", "passwordResetRequested",
exp.toUTCString(), exp.toUTCString(),
{ {
@@ -830,8 +1195,38 @@ export const authRouter = createTRPCRouter({
} }
); );
// Log password reset request
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId: user.id,
eventType: "auth.password.reset.request",
eventData: { email },
ipAddress,
userAgent,
success: true
});
return { success: true, message: "email sent" }; return { success: true, message: "email sent" };
} catch (error) { } catch (error) {
// Log failed password reset request (only if not rate limited)
if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
) {
const { ipAddress, userAgent } = getAuditContext(
getH3Event(ctx)
);
await logAuditEvent({
eventType: "auth.password.reset.request",
eventData: {
email: input.email,
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
}
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;
} }
@@ -912,17 +1307,40 @@ export const authRouter = createTRPCRouter({
}); });
} }
setCookie(ctx.event.nativeEvent, "emailToken", "", { setCookie(getH3Event(ctx), "emailToken", "", {
maxAge: 0, maxAge: 0,
path: "/" path: "/"
}); });
setCookie(ctx.event.nativeEvent, "userIDToken", "", { setCookie(getH3Event(ctx), "userIDToken", "", {
maxAge: 0, maxAge: 0,
path: "/" path: "/"
}); });
// Log successful password reset
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId: payload.id,
eventType: "auth.password.reset.complete",
eventData: {},
ipAddress,
userAgent,
success: true
});
return { success: true, message: "success" }; return { success: true, message: "success" };
} catch (error) { } catch (error) {
// Log failed password reset
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.password.reset.complete",
eventData: {
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;
} }
@@ -939,9 +1357,13 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email } = input; const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
rateLimitEmailVerification(clientIP, getH3Event(ctx));
try { try {
const requested = getCookie( const requested = getCookie(
ctx.event.nativeEvent, getH3Event(ctx),
"emailVerificationRequested" "emailVerificationRequested"
); );
if (requested) { if (requested) {
@@ -971,6 +1393,8 @@ export const authRouter = createTRPCRouter({
}); });
} }
const user = res.rows[0] as unknown as User;
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ email }) const token = await new SignJWT({ email })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
@@ -1016,7 +1440,7 @@ export const authRouter = createTRPCRouter({
await sendEmail(email, "freno.me email verification", htmlContent); await sendEmail(email, "freno.me email verification", htmlContent);
setCookie( setCookie(
ctx.event.nativeEvent, getH3Event(ctx),
"emailVerificationRequested", "emailVerificationRequested",
Date.now().toString(), Date.now().toString(),
{ {
@@ -1025,8 +1449,38 @@ export const authRouter = createTRPCRouter({
} }
); );
// Log email verification request
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId: user.id,
eventType: "auth.email.verify.request",
eventData: { email },
ipAddress,
userAgent,
success: true
});
return { success: true, message: "Verification email sent" }; return { success: true, message: "Verification email sent" };
} catch (error) { } catch (error) {
// Log failed email verification request (only if not rate limited)
if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
) {
const { ipAddress, userAgent } = getAuditContext(
getH3Event(ctx)
);
await logAuditEvent({
eventType: "auth.email.verify.request",
eventData: {
email: input.email,
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
}
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;
} }
@@ -1052,15 +1506,39 @@ export const authRouter = createTRPCRouter({
}), }),
signOut: publicProcedure.mutation(async ({ ctx }) => { signOut: publicProcedure.mutation(async ({ ctx }) => {
setCookie(ctx.event.nativeEvent, "userIDToken", "", { // Try to get user ID for audit log before clearing cookies
let userId: string | null = null;
try {
const token = getCookie(getH3Event(ctx), "userIDToken");
if (token) {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(token, secret);
userId = payload.id as string;
}
} catch (e) {
// Ignore token verification errors during signout
}
setCookie(getH3Event(ctx), "userIDToken", "", {
maxAge: 0, maxAge: 0,
path: "/" path: "/"
}); });
setCookie(ctx.event.nativeEvent, "emailToken", "", { setCookie(getH3Event(ctx), "emailToken", "", {
maxAge: 0, maxAge: 0,
path: "/" path: "/"
}); });
// Log signout
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
eventType: "auth.logout",
eventData: {},
ipAddress,
userAgent,
success: true
});
return { success: true }; return { success: true };
}) })
}); });

View File

@@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { validatePassword } from "~/lib/validation";
/** /**
* User API Validation Schemas * User API Validation Schemas
@@ -7,6 +8,31 @@ import { z } from "zod";
* profile updates, and password management * profile updates, and password management
*/ */
// ============================================================================
// Custom Password Validator
// ============================================================================
/**
* Secure password validation with strength requirements
* Minimum 12 characters, uppercase, lowercase, number, and special character
*/
const securePasswordSchema = z
.string()
.min(12, "Password must be at least 12 characters")
.refine(
(password) => {
const result = validatePassword(password);
return result.isValid;
},
(password) => {
const result = validatePassword(password);
return {
message:
result.errors.join(", ") || "Password does not meet requirements"
};
}
);
// ============================================================================ // ============================================================================
// Authentication Schemas // Authentication Schemas
// ============================================================================ // ============================================================================
@@ -17,8 +43,8 @@ import { z } from "zod";
export const registerUserSchema = z export const registerUserSchema = z
.object({ .object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters"), password: securePasswordSchema,
passwordConfirmation: z.string().min(8) passwordConfirmation: z.string().min(12)
}) })
.refine((data) => data.password === data.passwordConfirmation, { .refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords do not match", message: "Passwords do not match",
@@ -73,10 +99,8 @@ export const updateProfileImageSchema = z.object({
export const changePasswordSchema = z export const changePasswordSchema = z
.object({ .object({
oldPassword: z.string().min(1, "Current password is required"), oldPassword: z.string().min(1, "Current password is required"),
newPassword: z newPassword: securePasswordSchema,
.string() newPasswordConfirmation: z.string().min(12)
.min(8, "New password must be at least 8 characters"),
newPasswordConfirmation: z.string().min(8)
}) })
.refine((data) => data.newPassword === data.newPasswordConfirmation, { .refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match", message: "Passwords do not match",
@@ -92,8 +116,8 @@ export const changePasswordSchema = z
*/ */
export const setPasswordSchema = z export const setPasswordSchema = z
.object({ .object({
newPassword: z.string().min(8, "Password must be at least 8 characters"), newPassword: securePasswordSchema,
newPasswordConfirmation: z.string().min(8) newPasswordConfirmation: z.string().min(12)
}) })
.refine((data) => data.newPassword === data.newPasswordConfirmation, { .refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match", message: "Passwords do not match",
@@ -113,8 +137,8 @@ export const requestPasswordResetSchema = z.object({
export const resetPasswordSchema = z export const resetPasswordSchema = z
.object({ .object({
token: z.string().min(1), token: z.string().min(1),
newPassword: z.string().min(8, "Password must be at least 8 characters"), newPassword: securePasswordSchema,
newPasswordConfirmation: z.string().min(8) newPasswordConfirmation: z.string().min(12)
}) })
.refine((data) => data.newPassword === data.newPasswordConfirmation, { .refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match", message: "Passwords do not match",

406
src/server/audit.test.ts Normal file
View File

@@ -0,0 +1,406 @@
/**
* Audit Logging Tests
* Tests for audit logging system including log creation, querying, and analysis
*/
import { describe, it, expect, beforeEach } from "bun:test";
import {
logAuditEvent,
queryAuditLogs,
getFailedLoginAttempts,
getUserSecuritySummary,
detectSuspiciousActivity,
cleanupOldLogs
} from "~/server/audit";
import { ConnectionFactory } from "~/server/database";
// Helper to clean up test audit logs
async function cleanupTestLogs() {
const conn = ConnectionFactory();
await conn.execute({
sql: "DELETE FROM AuditLog WHERE event_data LIKE '%test-%'"
});
}
describe("Audit Logging System", () => {
beforeEach(async () => {
await cleanupTestLogs();
});
describe("logAuditEvent", () => {
it("should create audit log with all fields", async () => {
await logAuditEvent({
eventType: "auth.login.success",
eventData: { method: "password", test: "test-1" },
ipAddress: "192.168.1.100",
userAgent: "Test Browser 1.0",
success: true
});
const logs = await queryAuditLogs({
ipAddress: "192.168.1.100",
limit: 1
});
expect(logs.length).toBe(1);
expect(logs[0].userId).toBeNull();
expect(logs[0].eventType).toBe("auth.login.success");
expect(logs[0].ipAddress).toBe("192.168.1.100");
expect(logs[0].userAgent).toBe("Test Browser 1.0");
expect(logs[0].success).toBe(true);
expect(logs[0].eventData.method).toBe("password");
});
it("should create audit log without user ID", async () => {
await logAuditEvent({
eventType: "auth.login.failed",
eventData: { email: "test@example.com", test: "test-2" },
ipAddress: "192.168.1.2",
userAgent: "Test Browser 1.0",
success: false
});
const logs = await queryAuditLogs({
eventType: "auth.login.failed",
limit: 1
});
expect(logs.length).toBe(1);
expect(logs[0].userId).toBeNull();
expect(logs[0].success).toBe(false);
});
it("should handle missing optional fields gracefully", async () => {
await logAuditEvent({
eventType: "auth.logout",
success: true
});
const logs = await queryAuditLogs({
eventType: "auth.logout",
limit: 1
});
expect(logs.length).toBe(1);
expect(logs[0].ipAddress).toBeNull();
expect(logs[0].userAgent).toBeNull();
expect(logs[0].eventData).toBeNull();
});
it("should not throw errors on logging failures", async () => {
// This should not throw even if there's an invalid event type
await expect(
logAuditEvent({
eventType: "invalid.test.event",
success: true
})
).resolves.toBeUndefined();
});
});
describe("queryAuditLogs", () => {
beforeEach(async () => {
// Create test logs
await logAuditEvent({
eventType: "auth.login.success",
eventData: { test: "test-query-1", testUser: "user-1" },
ipAddress: "192.168.1.10",
success: true
});
await logAuditEvent({
eventType: "auth.login.failed",
eventData: { test: "test-query-2", testUser: "user-1" },
ipAddress: "192.168.1.10",
success: false
});
await logAuditEvent({
eventType: "auth.login.success",
eventData: { test: "test-query-3", testUser: "user-2" },
ipAddress: "192.168.1.20",
success: true
});
await logAuditEvent({
eventType: "auth.password_reset.requested",
eventData: { test: "test-query-4", testUser: "user-2" },
ipAddress: "192.168.1.20",
success: true
});
});
it("should query logs by IP address (simulating user)", async () => {
const logs = await queryAuditLogs({ ipAddress: "192.168.1.10" });
expect(logs.length).toBeGreaterThanOrEqual(2);
expect(logs.every((log) => log.ipAddress === "192.168.1.10")).toBe(true);
});
it("should query logs by event type", async () => {
const logs = await queryAuditLogs({
eventType: "auth.login.success"
});
expect(logs.length).toBeGreaterThanOrEqual(2);
expect(logs.every((log) => log.eventType === "auth.login.success")).toBe(
true
);
});
it("should query logs by success status", async () => {
const successLogs = await queryAuditLogs({ success: true });
const failedLogs = await queryAuditLogs({ success: false });
expect(successLogs.length).toBeGreaterThanOrEqual(3);
expect(failedLogs.length).toBeGreaterThanOrEqual(1);
expect(successLogs.every((log) => log.success === true)).toBe(true);
expect(failedLogs.every((log) => log.success === false)).toBe(true);
});
it("should respect limit parameter", async () => {
const logs = await queryAuditLogs({ limit: 2 });
expect(logs.length).toBeLessThanOrEqual(2);
});
it("should respect offset parameter", async () => {
const firstPage = await queryAuditLogs({ limit: 2, offset: 0 });
const secondPage = await queryAuditLogs({ limit: 2, offset: 2 });
expect(firstPage.length).toBeGreaterThan(0);
if (secondPage.length > 0) {
expect(firstPage[0].id).not.toBe(secondPage[0].id);
}
});
it("should filter by date range", async () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const logs = await queryAuditLogs({
startDate: oneHourAgo.toISOString(),
endDate: oneDayFromNow.toISOString()
});
expect(logs.length).toBeGreaterThanOrEqual(4);
});
it("should return empty array when no logs match", async () => {
const logs = await queryAuditLogs({
userId: "nonexistent-user-xyz"
});
expect(logs).toEqual([]);
});
});
describe("getFailedLoginAttempts", () => {
beforeEach(async () => {
// Create failed login attempts
for (let i = 0; i < 5; i++) {
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
email: `test${i}@example.com`,
test: `test-failed-${i}`
},
ipAddress: `192.168.1.${i}`,
success: false
});
}
// Create successful logins (should be excluded)
await logAuditEvent({
eventType: "auth.login.success",
eventData: { test: "test-success-1" },
success: true
});
});
it("should return only failed login attempts", async () => {
const attempts = await getFailedLoginAttempts(24, 10);
expect(attempts.length).toBeGreaterThanOrEqual(5);
expect(
attempts.every((attempt) => attempt.event_type === "auth.login.failed")
).toBe(true);
expect(attempts.every((attempt) => attempt.success === 0)).toBe(true);
});
it("should respect limit parameter", async () => {
const attempts = await getFailedLoginAttempts(24, 3);
expect(attempts.length).toBeLessThanOrEqual(3);
});
it("should filter by time window", async () => {
const attemptsIn24h = await getFailedLoginAttempts(24, 100);
const attemptsIn1h = await getFailedLoginAttempts(1, 100);
expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5);
// Recent attempts should be within 1 hour
expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5);
});
});
describe("getUserSecuritySummary", () => {
beforeEach(async () => {
// Create various events for summary (without user IDs due to FK constraint)
await logAuditEvent({
eventType: "auth.login.success",
eventData: { test: "test-summary-1", testSummaryUser: "summary-123" },
ipAddress: "192.168.99.1",
success: true
});
await logAuditEvent({
eventType: "auth.login.failed",
eventData: { test: "test-summary-2", testSummaryUser: "summary-123" },
ipAddress: "192.168.99.1",
success: false
});
await logAuditEvent({
eventType: "auth.login.failed",
eventData: { test: "test-summary-3", testSummaryUser: "summary-123" },
ipAddress: "192.168.99.1",
success: false
});
await logAuditEvent({
eventType: "auth.password_reset.requested",
eventData: { test: "test-summary-4", testSummaryUser: "summary-123" },
ipAddress: "192.168.99.1",
success: true
});
});
it("should return zero counts for non-existent user", async () => {
// Since we can't use real user IDs in tests, this test verifies query works
const summary = await getUserSecuritySummary("nonexistent-user", 30);
expect(summary).toHaveProperty("totalEvents");
expect(summary).toHaveProperty("successfulEvents");
expect(summary).toHaveProperty("failedEvents");
expect(summary).toHaveProperty("eventTypes");
expect(summary).toHaveProperty("uniqueIPs");
});
it("should return summary structure", async () => {
const summary = await getUserSecuritySummary("nonexistent-user", 30);
expect(summary.totalEvents).toBe(0);
expect(summary.successfulEvents).toBe(0);
expect(summary.failedEvents).toBe(0);
expect(summary.eventTypes.length).toBe(0);
expect(summary.uniqueIPs.length).toBe(0);
});
});
describe("detectSuspiciousActivity", () => {
beforeEach(async () => {
// Create suspicious pattern: many failed logins from same IP
for (let i = 0; i < 10; i++) {
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
email: `victim${i}@example.com`,
test: `test-suspicious-${i}`
},
ipAddress: "10.0.0.1",
success: false
});
}
// Create normal activity
await logAuditEvent({
eventType: "auth.login.success",
eventData: { test: "test-normal-1" },
ipAddress: "10.0.0.2",
success: true
});
});
it("should detect IPs with excessive failed attempts", async () => {
const suspicious = await detectSuspiciousActivity(24, 5);
expect(suspicious.length).toBeGreaterThanOrEqual(1);
const suspiciousIP = suspicious.find((s) => s.ipAddress === "10.0.0.1");
expect(suspiciousIP).toBeDefined();
expect(suspiciousIP!.failedAttempts).toBeGreaterThanOrEqual(10);
});
it("should respect minimum attempts threshold", async () => {
const lowThreshold = await detectSuspiciousActivity(24, 5);
const highThreshold = await detectSuspiciousActivity(24, 20);
expect(lowThreshold.length).toBeGreaterThanOrEqual(highThreshold.length);
});
it("should return empty array when no suspicious activity", async () => {
await cleanupTestLogs();
// Create only successful logins
await logAuditEvent({
eventType: "auth.login.success",
eventData: { test: "test-clean-1" },
ipAddress: "10.0.0.100",
success: true
});
const suspicious = await detectSuspiciousActivity(24, 5);
const cleanIP = suspicious.find((s) => s.ipAddress === "10.0.0.100");
expect(cleanIP).toBeUndefined();
});
});
describe("cleanupOldLogs", () => {
it("should delete logs older than specified days", async () => {
// Create an old log by directly inserting with past date
const conn = ConnectionFactory();
const veryOldDate = new Date();
veryOldDate.setDate(veryOldDate.getDate() - 100); // 100 days ago
await conn.execute({
sql: `INSERT INTO AuditLog (id, event_type, event_data, success, created_at)
VALUES (?, ?, ?, ?, ?)`,
args: [
`old-log-${Date.now()}`,
"auth.login.success",
JSON.stringify({ test: "test-cleanup-1" }),
1,
veryOldDate.toISOString()
]
});
// Clean up logs older than 90 days
const deleted = await cleanupOldLogs(90);
expect(deleted).toBeGreaterThanOrEqual(1);
});
it("should not delete recent logs", async () => {
await logAuditEvent({
eventType: "auth.login.success",
eventData: { test: "test-recent-1" },
success: true
});
const logsBefore = await queryAuditLogs({ limit: 100 });
const countBefore = logsBefore.length;
// Try to clean up logs older than 1 day (should not delete recent log)
await cleanupOldLogs(1);
const logsAfter = await queryAuditLogs({ limit: 100 });
// Should still have recent logs
expect(logsAfter.length).toBeGreaterThan(0);
});
});
});

516
src/server/audit.ts Normal file
View File

@@ -0,0 +1,516 @@
/**
* Audit Logging System
* Tracks security-relevant events for incident response and forensics
*/
import { ConnectionFactory } from "./database";
import { v4 as uuid } from "uuid";
/**
* Audit event types for security tracking
*/
export type AuditEventType =
// Authentication events
| "auth.login.success"
| "auth.login.failed"
| "auth.logout"
| "auth.register.success"
| "auth.register.failed"
// Password events
| "auth.password.change"
| "auth.password.reset.request"
| "auth.password.reset.complete"
// Email verification
| "auth.email.verify.request"
| "auth.email.verify.complete"
// OAuth events
| "auth.oauth.github.success"
| "auth.oauth.github.failed"
| "auth.oauth.google.success"
| "auth.oauth.google.failed"
// Session management
| "auth.session.revoke"
| "auth.session.revokeAll"
// Security events
| "security.rate_limit.exceeded"
| "security.csrf.failed"
| "security.suspicious.activity"
// Admin actions
| "admin.action";
/**
* Audit log entry structure
*/
export interface AuditLogEntry {
userId?: string;
eventType: AuditEventType;
eventData?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
success: boolean;
}
/**
* Log security/audit event to database
* Fire-and-forget - failures are logged to console but don't block operations
*
* @param entry - Audit log entry to record
* @returns Promise that resolves when log is written (or fails silently)
*/
export async function logAuditEvent(entry: AuditLogEntry): Promise<void> {
try {
const conn = ConnectionFactory();
await conn.execute({
sql: `INSERT INTO AuditLog (id, user_id, event_type, event_data, ip_address, user_agent, success)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
uuid(),
entry.userId || null,
entry.eventType,
entry.eventData ? JSON.stringify(entry.eventData) : null,
entry.ipAddress || null,
entry.userAgent || null,
entry.success ? 1 : 0
]
});
} catch (error) {
// Never throw - logging failures shouldn't break auth flows
console.error("Failed to write audit log:", error, entry);
}
}
/**
* Query parameters for audit log searches
*/
export interface AuditLogQuery {
userId?: string;
eventType?: AuditEventType;
success?: boolean;
ipAddress?: string;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
}
/**
* Query audit logs for security analysis
*
* @param query - Search parameters
* @returns Array of audit log entries
*/
export async function queryAuditLogs(
query: AuditLogQuery
): Promise<Array<Record<string, any>>> {
const conn = ConnectionFactory();
let sql = "SELECT * FROM AuditLog WHERE 1=1";
const args: any[] = [];
if (query.userId) {
sql += " AND user_id = ?";
args.push(query.userId);
}
if (query.eventType) {
sql += " AND event_type = ?";
args.push(query.eventType);
}
if (query.success !== undefined) {
sql += " AND success = ?";
args.push(query.success ? 1 : 0);
}
if (query.ipAddress) {
sql += " AND ip_address = ?";
args.push(query.ipAddress);
}
if (query.startDate) {
sql += " AND created_at >= ?";
args.push(
typeof query.startDate === "string"
? query.startDate
: query.startDate.toISOString()
);
}
if (query.endDate) {
sql += " AND created_at <= ?";
args.push(
typeof query.endDate === "string"
? query.endDate
: query.endDate.toISOString()
);
}
sql += " ORDER BY created_at DESC";
if (query.limit) {
sql += " LIMIT ?";
args.push(query.limit);
}
if (query.offset) {
sql += " OFFSET ?";
args.push(query.offset);
}
const result = await conn.execute({ sql, args });
return result.rows.map((row) => ({
id: row.id,
userId: row.user_id,
eventType: row.event_type,
eventData: row.event_data ? JSON.parse(row.event_data as string) : null,
ipAddress: row.ip_address,
userAgent: row.user_agent,
success: row.success === 1,
createdAt: row.created_at
}));
}
/**
* Get recent failed login attempts for a user or IP address
* Can also be used to query all recent failed login attempts
*
* @param identifierOrHours - User ID, IP address, or number of hours to look back
* @param identifierTypeOrLimit - Type of identifier ('user_id' or 'ip_address'), or limit for aggregate query
* @param withinMinutes - Time window to check (default: 15 minutes) - only used for specific identifier queries
* @returns Count of failed login attempts, or array of attempts for aggregate queries
*/
export async function getFailedLoginAttempts(
identifierOrHours: string | number,
identifierTypeOrLimit?: "user_id" | "ip_address" | number,
withinMinutes: number = 15
): Promise<number | Array<Record<string, any>>> {
const conn = ConnectionFactory();
// Aggregate query: getFailedLoginAttempts(24, 100) - get all failed logins in last 24 hours
if (
typeof identifierOrHours === "number" &&
typeof identifierTypeOrLimit === "number"
) {
const hours = identifierOrHours;
const limit = identifierTypeOrLimit;
const result = await conn.execute({
sql: `SELECT * FROM AuditLog
WHERE event_type = 'auth.login.failed'
AND success = 0
AND created_at >= datetime('now', '-${hours} hours')
ORDER BY created_at DESC
LIMIT ?`,
args: [limit]
});
return result.rows.map((row) => ({
id: row.id,
user_id: row.user_id,
event_type: row.event_type,
event_data: row.event_data ? JSON.parse(row.event_data as string) : null,
ip_address: row.ip_address,
user_agent: row.user_agent,
success: row.success,
created_at: row.created_at
}));
}
// Specific identifier query: getFailedLoginAttempts("user-123", "user_id", 15)
const identifier = identifierOrHours as string;
const identifierType = identifierTypeOrLimit as "user_id" | "ip_address";
const column = identifierType === "user_id" ? "user_id" : "ip_address";
const result = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE ${column} = ?
AND event_type = 'auth.login.failed'
AND success = 0
AND created_at >= datetime('now', '-${withinMinutes} minutes')`,
args: [identifier]
});
return (result.rows[0]?.count as number) || 0;
}
/**
* Get security summary for a user
*
* @param userId - User ID to get summary for
* @param days - Number of days to look back (default: 30)
* @returns Security metrics for the user
*/
export async function getUserSecuritySummary(
userId: string,
days: number = 30
): Promise<{
totalEvents: number;
successfulEvents: number;
failedEvents: number;
eventTypes: string[];
uniqueIPs: string[];
totalLogins: number;
failedLogins: number;
lastLoginAt: string | null;
lastLoginIp: string | null;
uniqueIpCount: number;
recentSessions: number;
}> {
const conn = ConnectionFactory();
// Get total events for user in time period
const totalEventsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ?
AND created_at >= datetime('now', '-${days} days')`,
args: [userId]
});
const totalEvents = (totalEventsResult.rows[0]?.count as number) || 0;
// Get successful events
const successfulEventsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ?
AND success = 1
AND created_at >= datetime('now', '-${days} days')`,
args: [userId]
});
const successfulEvents =
(successfulEventsResult.rows[0]?.count as number) || 0;
// Get failed events
const failedEventsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ?
AND success = 0
AND created_at >= datetime('now', '-${days} days')`,
args: [userId]
});
const failedEvents = (failedEventsResult.rows[0]?.count as number) || 0;
// Get unique event types
const eventTypesResult = await conn.execute({
sql: `SELECT DISTINCT event_type FROM AuditLog
WHERE user_id = ?
AND created_at >= datetime('now', '-${days} days')`,
args: [userId]
});
const eventTypes = eventTypesResult.rows.map(
(row) => row.event_type as string
);
// Get unique IPs
const uniqueIPsResult = await conn.execute({
sql: `SELECT DISTINCT ip_address FROM AuditLog
WHERE user_id = ?
AND ip_address IS NOT NULL
AND created_at >= datetime('now', '-${days} days')`,
args: [userId]
});
const uniqueIPs = uniqueIPsResult.rows.map((row) => row.ip_address as string);
// Get total successful logins
const loginResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ?
AND event_type = 'auth.login.success'
AND success = 1`,
args: [userId]
});
const totalLogins = (loginResult.rows[0]?.count as number) || 0;
// Get failed login attempts
const failedResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ?
AND event_type = 'auth.login.failed'
AND success = 0
AND created_at >= datetime('now', '-${days} days')`,
args: [userId]
});
const failedLogins = (failedResult.rows[0]?.count as number) || 0;
// Get last login info
const lastLoginResult = await conn.execute({
sql: `SELECT created_at, ip_address FROM AuditLog
WHERE user_id = ?
AND event_type = 'auth.login.success'
AND success = 1
ORDER BY created_at DESC
LIMIT 1`,
args: [userId]
});
const lastLogin = lastLoginResult.rows[0];
// Get unique IP count
const ipResult = await conn.execute({
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM AuditLog
WHERE user_id = ?
AND event_type = 'auth.login.success'
AND success = 1
AND created_at >= datetime('now', '-${days} days')`,
args: [userId]
});
const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0;
// Get recent sessions (last 24 hours)
const sessionResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ?
AND event_type = 'auth.login.success'
AND success = 1
AND created_at >= datetime('now', '-1 day')`,
args: [userId]
});
const recentSessions = (sessionResult.rows[0]?.count as number) || 0;
return {
totalEvents,
successfulEvents,
failedEvents,
eventTypes,
uniqueIPs,
totalLogins,
failedLogins,
lastLoginAt: lastLogin?.created_at as string | null,
lastLoginIp: lastLogin?.ip_address as string | null,
uniqueIpCount,
recentSessions
};
}
/**
* Detect suspicious activity patterns
* Can detect for a specific user or aggregate suspicious IPs
*
* @param userIdOrHours - User ID or number of hours to look back for aggregate query
* @param currentIpOrMinAttempts - Current IP address or minimum attempts threshold for aggregate query
* @returns Suspicion result for user, or array of suspicious IPs for aggregate query
*/
export async function detectSuspiciousActivity(
userIdOrHours: string | number,
currentIpOrMinAttempts?: string | number
): Promise<
| {
isSuspicious: boolean;
reasons: string[];
}
| Array<{
ipAddress: string;
failedAttempts: number;
uniqueEmails: number;
}>
> {
const conn = ConnectionFactory();
// Aggregate query: detectSuspiciousActivity(24, 5) - find IPs with 5+ failed attempts in 24 hours
if (
typeof userIdOrHours === "number" &&
typeof currentIpOrMinAttempts === "number"
) {
const hours = userIdOrHours;
const minAttempts = currentIpOrMinAttempts;
const result = await conn.execute({
sql: `SELECT
ip_address,
COUNT(*) as failed_attempts,
COUNT(DISTINCT json_extract(event_data, '$.email')) as unique_emails
FROM AuditLog
WHERE event_type = 'auth.login.failed'
AND success = 0
AND ip_address IS NOT NULL
AND created_at >= datetime('now', '-${hours} hours')
GROUP BY ip_address
HAVING COUNT(*) >= ?
ORDER BY failed_attempts DESC`,
args: [minAttempts]
});
return result.rows.map((row) => ({
ipAddress: row.ip_address as string,
failedAttempts: row.failed_attempts as number,
uniqueEmails: row.unique_emails as number
}));
}
// User-specific query: detectSuspiciousActivity("user-123", "192.168.1.1")
const userId = userIdOrHours as string;
const currentIp = currentIpOrMinAttempts as string;
const reasons: string[] = [];
// Check for excessive failed logins
const failedAttempts = (await getFailedLoginAttempts(
userId,
"user_id",
15
)) as number;
if (failedAttempts >= 3) {
reasons.push(`${failedAttempts} failed login attempts in last 15 minutes`);
}
// Check for rapid location changes (different IPs in short time)
const recentIps = await conn.execute({
sql: `SELECT DISTINCT ip_address FROM AuditLog
WHERE user_id = ?
AND event_type = 'auth.login.success'
AND success = 1
AND created_at >= datetime('now', '-1 hour')`,
args: [userId]
});
if (recentIps.rows.length >= 3) {
reasons.push(
`Logins from ${recentIps.rows.length} different IPs in last hour`
);
}
// Check for new IP if user has login history
const ipHistory = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ?
AND ip_address = ?
AND event_type = 'auth.login.success'
AND success = 1`,
args: [userId, currentIp]
});
const hasUsedIpBefore = (ipHistory.rows[0]?.count as number) > 0;
if (!hasUsedIpBefore) {
const totalLogins = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ?
AND event_type = 'auth.login.success'
AND success = 1`,
args: [userId]
});
if ((totalLogins.rows[0]?.count as number) > 0) {
reasons.push("Login from new IP address");
}
}
return {
isSuspicious: reasons.length > 0,
reasons
};
}
/**
* Clean up old audit logs (for maintenance/GDPR compliance)
*
* @param olderThanDays - Delete logs older than this many days
* @returns Number of logs deleted
*/
export async function cleanupOldLogs(olderThanDays: number): Promise<number> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `DELETE FROM AuditLog
WHERE created_at < datetime('now', '-${olderThanDays} days')
RETURNING id`,
args: []
});
return result.rows.length;
}

View File

@@ -3,12 +3,111 @@ import { jwtVerify } from "jose";
import { OAuth2Client } from "google-auth-library"; import { OAuth2Client } from "google-auth-library";
import type { Row } from "@libsql/client/web"; import type { Row } from "@libsql/client/web";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { ConnectionFactory } from "./database";
/**
* Extract cookie value from H3Event (works in both production and tests)
* Falls back to manual header parsing if vinxi's getCookie fails
*/
function getCookieValue(event: H3Event, name: string): string | undefined {
try {
// Try vinxi's getCookie first
return getCookie(event, name);
} catch (e) {
// Fallback for tests: parse cookie header manually
try {
const cookieHeader =
event.headers?.get("cookie") || event.node?.req?.headers?.cookie || "";
const cookies = cookieHeader
.split(";")
.map((c) => c.trim())
.reduce(
(acc, cookie) => {
const [key, value] = cookie.split("=");
if (key && value) acc[key] = value;
return acc;
},
{} as Record<string, string>
);
return cookies[name];
} catch {
return undefined;
}
}
}
/**
* Clear cookie (works in both production and tests)
*/
function clearCookie(event: H3Event, name: string): void {
try {
setCookie(event, name, "", {
maxAge: 0,
expires: new Date("2016-10-05")
});
} catch (e) {
// In tests, setCookie might fail silently
}
}
/**
* Validate session and update last_used timestamp
* @param sessionId - Session ID from JWT
* @param userId - User ID from JWT
* @returns true if session is valid, false otherwise
*/
async function validateSession(
sessionId: string,
userId: string
): Promise<boolean> {
try {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `SELECT revoked, expires_at FROM Session
WHERE id = ? AND user_id = ?`,
args: [sessionId, userId]
});
if (result.rows.length === 0) {
// Session doesn't exist
return false;
}
const session = result.rows[0];
// Check if session is revoked
if (session.revoked === 1) {
return false;
}
// Check if session is expired
const expiresAt = new Date(session.expires_at as string);
if (expiresAt < new Date()) {
return false;
}
// Update last_used timestamp (fire and forget, don't block)
conn
.execute({
sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?",
args: [sessionId]
})
.catch((err) =>
console.error("Failed to update session last_used:", err)
);
return true;
} catch (e) {
console.error("Session validation error:", e);
return false;
}
}
export async function getPrivilegeLevel( export async function getPrivilegeLevel(
event: H3Event event: H3Event
): Promise<"anonymous" | "admin" | "user"> { ): Promise<"anonymous" | "admin" | "user"> {
try { try {
const userIDToken = getCookie(event, "userIDToken"); const userIDToken = getCookieValue(event, "userIDToken");
if (userIDToken) { if (userIDToken) {
try { try {
@@ -16,14 +115,23 @@ export async function getPrivilegeLevel(
const { payload } = await jwtVerify(userIDToken, secret); const { payload } = await jwtVerify(userIDToken, secret);
if (payload.id && typeof payload.id === "string") { if (payload.id && typeof payload.id === "string") {
// Validate session if session ID is present
if (payload.sid) {
const isValidSession = await validateSession(
payload.sid as string,
payload.id
);
if (!isValidSession) {
clearCookie(event, "userIDToken");
return "anonymous";
}
}
return payload.id === env.ADMIN_ID ? "admin" : "user"; return payload.id === env.ADMIN_ID ? "admin" : "user";
} }
} catch (err) { } catch (err) {
// Silently clear invalid token (401s are expected for non-authenticated users) // Silently clear invalid token (401s are expected for non-authenticated users)
setCookie(event, "userIDToken", "", { clearCookie(event, "userIDToken");
maxAge: 0,
expires: new Date("2016-10-05")
});
} }
} }
} catch (e) { } catch (e) {
@@ -34,7 +142,7 @@ export async function getPrivilegeLevel(
export async function getUserID(event: H3Event): Promise<string | null> { export async function getUserID(event: H3Event): Promise<string | null> {
try { try {
const userIDToken = getCookie(event, "userIDToken"); const userIDToken = getCookieValue(event, "userIDToken");
if (userIDToken) { if (userIDToken) {
try { try {
@@ -42,14 +150,23 @@ export async function getUserID(event: H3Event): Promise<string | null> {
const { payload } = await jwtVerify(userIDToken, secret); const { payload } = await jwtVerify(userIDToken, secret);
if (payload.id && typeof payload.id === "string") { if (payload.id && typeof payload.id === "string") {
// Validate session if session ID is present
if (payload.sid) {
const isValidSession = await validateSession(
payload.sid as string,
payload.id
);
if (!isValidSession) {
clearCookie(event, "userIDToken");
return null;
}
}
return payload.id; return payload.id;
} }
} catch (err) { } catch (err) {
// Silently clear invalid token (401s are expected for non-authenticated users) // Silently clear invalid token (401s are expected for non-authenticated users)
setCookie(event, "userIDToken", "", { clearCookie(event, "userIDToken");
maxAge: 0,
expires: new Date("2016-10-05")
});
} }
} }
} catch (e) { } catch (e) {

View File

@@ -0,0 +1,95 @@
/**
* Database Initialization for Audit Logging
* Run this script to create the AuditLog table in your database
*
* Usage: bun run src/server/init-audit-table.ts
*/
import { ConnectionFactory } from "./database";
async function initAuditTable() {
console.log("🔧 Initializing AuditLog table...");
try {
const conn = ConnectionFactory();
// Create AuditLog table
await conn.execute({
sql: `CREATE TABLE IF NOT EXISTS AuditLog (
id TEXT PRIMARY KEY,
user_id TEXT,
event_type TEXT NOT NULL,
event_data TEXT,
ip_address TEXT,
user_agent TEXT,
success INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL
)`
});
console.log("✅ AuditLog table created (or already exists)");
// Create indexes for performance
console.log("🔧 Creating indexes...");
await conn.execute({
sql: `CREATE INDEX IF NOT EXISTS idx_audit_user_id ON AuditLog(user_id)`
});
await conn.execute({
sql: `CREATE INDEX IF NOT EXISTS idx_audit_event_type ON AuditLog(event_type)`
});
await conn.execute({
sql: `CREATE INDEX IF NOT EXISTS idx_audit_created_at ON AuditLog(created_at)`
});
await conn.execute({
sql: `CREATE INDEX IF NOT EXISTS idx_audit_ip_address ON AuditLog(ip_address)`
});
console.log("✅ Indexes created");
// Verify table exists
const result = await conn.execute({
sql: `SELECT name FROM sqlite_master WHERE type='table' AND name='AuditLog'`
});
if (result.rows.length > 0) {
console.log("✅ AuditLog table verified - ready for use!");
// Check row count
const countResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog`
});
console.log(
`📊 Current audit log entries: ${countResult.rows[0]?.count || 0}`
);
} else {
console.error("❌ AuditLog table was not created properly");
process.exit(1);
}
console.log("\n✅ Audit logging system is ready!");
console.log("💡 You can now use the audit logging features");
console.log("📖 See docs/AUDIT_LOGGING.md for usage examples\n");
} catch (error) {
console.error("❌ Failed to initialize AuditLog table:");
console.error(error);
process.exit(1);
}
}
// Run if executed directly
if (import.meta.main) {
initAuditTable()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
}
export { initAuditTable };

View File

@@ -1,5 +1,13 @@
import * as bcrypt from "bcrypt"; import * as bcrypt from "bcrypt";
/**
* Dummy hash for timing attack prevention
* This is a pre-computed bcrypt hash that will be used when a user doesn't exist
* to maintain constant-time behavior
*/
const DUMMY_HASH =
"$2b$10$YxVvS6L6HhS1pVBP6nZK0.9r0xwN8xvvzX7GwL5xvKJ6xvS6L6HhS1";
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
const saltRounds = 10; const saltRounds = 10;
const salt = await bcrypt.genSalt(saltRounds); const salt = await bcrypt.genSalt(saltRounds);
@@ -14,3 +22,19 @@ export async function checkPassword(
const match = await bcrypt.compare(password, hash); const match = await bcrypt.compare(password, hash);
return match; return match;
} }
/**
* Check password with timing attack protection
* Always runs bcrypt comparison even if user doesn't exist
*/
export async function checkPasswordSafe(
password: string,
hash: string | null | undefined
): Promise<boolean> {
// If no hash provided, use dummy hash to maintain constant timing
const hashToCompare = hash || DUMMY_HASH;
const match = await bcrypt.compare(password, hashToCompare);
// Return false if no real hash was provided
return hash ? match : false;
}

402
src/server/security.ts Normal file
View File

@@ -0,0 +1,402 @@
import { TRPCError } from "@trpc/server";
import { getCookie, setCookie } from "vinxi/http";
import type { H3Event } from "vinxi/http";
import { t } from "~/server/api/utils";
import { logAuditEvent } from "~/server/audit";
import { env } from "~/env/server";
/**
* Extract cookie value from H3Event (works in both production and tests)
*/
function getCookieValue(event: H3Event, name: string): string | undefined {
try {
// Try vinxi's getCookie first
const value = getCookie(event, name);
if (value) return value;
} catch (e) {
// vinxi's getCookie failed, will use fallback
}
// Fallback for tests: parse cookie header manually
try {
const cookieHeader =
event.headers?.get?.("cookie") ||
(event.headers as any)?.cookie ||
event.node?.req?.headers?.cookie ||
"";
const cookies = cookieHeader
.split(";")
.map((c) => c.trim())
.reduce(
(acc, cookie) => {
const [key, value] = cookie.split("=");
if (key && value) acc[key] = value;
return acc;
},
{} as Record<string, string>
);
return cookies[name];
} catch {
return undefined;
}
}
/**
* Set cookie (works in both production and tests)
*/
function setCookieValue(
event: H3Event,
name: string,
value: string,
options: any
): void {
try {
setCookie(event, name, value, options);
} catch (e) {
// In tests, setCookie might fail - store in mock object
if (!event.node) event.node = { req: { headers: {} } } as any;
if (!event.node.res) event.node.res = {} as any;
if (!event.node.res.cookies) event.node.res.cookies = {} as any;
event.node.res.cookies[name] = value;
}
}
/**
* Extract header value from H3Event (works in both production and tests)
*/
function getHeaderValue(event: H3Event, name: string): string | null {
try {
// Try various header access patterns
if (event.request?.headers?.get) {
const val = event.request.headers.get(name);
if (val !== null && val !== undefined) return val;
}
if (event.headers) {
// Check if it's a Headers object with .get method
if (typeof (event.headers as any).get === "function") {
const val = (event.headers as any).get(name);
if (val !== null && val !== undefined) return val;
}
// Or a plain object
if (typeof event.headers === "object") {
const val = (event.headers as any)[name];
if (val !== undefined) return val;
}
}
if (event.node?.req?.headers) {
const val = event.node.req.headers[name];
if (val !== undefined) return val;
}
return null;
} catch {
return null;
}
}
/**
* Generate a cryptographically secure CSRF token
*/
export function generateCSRFToken(): string {
return crypto.randomUUID();
}
/**
* Set CSRF token cookie
*/
export function setCSRFToken(event: H3Event): string {
const token = generateCSRFToken();
setCookieValue(event, "csrf-token", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days - same as session
path: "/",
httpOnly: false, // Must be readable by client JS
secure: env.NODE_ENV === "production",
sameSite: "lax"
});
return token;
}
/**
* Validate CSRF token from request header against cookie
*/
export function validateCSRFToken(event: H3Event): boolean {
const headerToken = getHeaderValue(event, "x-csrf-token");
const cookieToken = getCookieValue(event, "csrf-token");
if (!headerToken || !cookieToken) {
return false;
}
// Constant-time comparison to prevent timing attacks
return timingSafeEqual(headerToken, cookieToken);
}
/**
* Timing-safe string comparison
*/
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
/**
* CSRF protection middleware for tRPC
* Use this on all mutation procedures that modify state
*/
export const csrfProtection = t.middleware(async ({ ctx, next }) => {
const isValid = validateCSRFToken(ctx.event.nativeEvent);
if (!isValid) {
// Log CSRF failure
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
eventType: "security.csrf.failed",
eventData: {
headerToken: getHeaderValue(ctx.event.nativeEvent, "x-csrf-token")
? "present"
: "missing",
cookieToken: getCookieValue(ctx.event.nativeEvent, "csrf-token")
? "present"
: "missing"
},
ipAddress,
userAgent,
success: false
});
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid CSRF token"
});
}
return next();
});
/**
* Protected procedure with CSRF validation
*/
export const csrfProtectedProcedure = t.procedure.use(csrfProtection);
// ========== Rate Limiting ==========
interface RateLimitRecord {
count: number;
resetAt: number;
}
/**
* In-memory rate limit store
* In production, consider using Redis for distributed rate limiting
*/
const rateLimitStore = new Map<string, RateLimitRecord>();
/**
* Clear rate limit store (for testing only)
*/
export function clearRateLimitStore(): void {
rateLimitStore.clear();
}
/**
* Cleanup expired rate limit entries every 5 minutes
*/
setInterval(
() => {
const now = Date.now();
for (const [key, record] of rateLimitStore.entries()) {
if (now > record.resetAt) {
rateLimitStore.delete(key);
}
}
},
5 * 60 * 1000
);
/**
* Get client IP address from request headers
*/
export function getClientIP(event: H3Event): string {
const forwarded = getHeaderValue(event, "x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0].trim();
}
const realIP = getHeaderValue(event, "x-real-ip");
if (realIP) {
return realIP;
}
return "unknown";
}
/**
* Get user agent from request headers
*/
export function getUserAgent(event: H3Event): string {
return getHeaderValue(event, "user-agent") || "unknown";
}
/**
* Extract audit context from H3Event
* Convenience function for logging
*/
export function getAuditContext(event: H3Event): {
ipAddress: string;
userAgent: string;
} {
return {
ipAddress: getClientIP(event),
userAgent: getUserAgent(event)
};
}
/**
* Check rate limit for a given identifier
* @param identifier - Unique identifier (e.g., "login:ip:192.168.1.1")
* @param maxAttempts - Maximum number of attempts allowed
* @param windowMs - Time window in milliseconds
* @param event - H3Event for audit logging (optional)
* @returns Remaining attempts before limit is hit
* @throws TRPCError if rate limit exceeded
*/
export function checkRateLimit(
identifier: string,
maxAttempts: number,
windowMs: number,
event?: H3Event
): number {
const now = Date.now();
const record = rateLimitStore.get(identifier);
if (!record || now > record.resetAt) {
// Create new record
rateLimitStore.set(identifier, {
count: 1,
resetAt: now + windowMs
});
return maxAttempts - 1;
}
if (record.count >= maxAttempts) {
const remainingMs = record.resetAt - now;
const remainingSec = Math.ceil(remainingMs / 1000);
// Log rate limit exceeded (fire-and-forget)
if (event) {
const { ipAddress, userAgent } = getAuditContext(event);
logAuditEvent({
eventType: "security.rate_limit.exceeded",
eventData: {
identifier,
maxAttempts,
windowMs,
remainingSec
},
ipAddress,
userAgent,
success: false
}).catch(() => {
// Ignore logging errors
});
}
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Too many attempts. Try again in ${remainingSec} seconds`
});
}
// Increment count
record.count++;
return maxAttempts - record.count;
}
/**
* Rate limit configuration for different operations
*/
export const RATE_LIMITS = {
// Login: 5 attempts per 15 minutes per IP
LOGIN_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 },
// Login: 3 attempts per hour per email
LOGIN_EMAIL: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
// Password reset: 3 attempts per hour per IP
PASSWORD_RESET_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
// Registration: 3 attempts per hour per IP
REGISTRATION_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
// Email verification: 5 attempts per 15 minutes per IP
EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
} as const;
/**
* Rate limiting middleware for login operations
*/
export function rateLimitLogin(
email: string,
clientIP: string,
event?: H3Event
): void {
// Rate limit by IP
checkRateLimit(
`login:ip:${clientIP}`,
RATE_LIMITS.LOGIN_IP.maxAttempts,
RATE_LIMITS.LOGIN_IP.windowMs,
event
);
// Rate limit by email
checkRateLimit(
`login:email:${email}`,
RATE_LIMITS.LOGIN_EMAIL.maxAttempts,
RATE_LIMITS.LOGIN_EMAIL.windowMs,
event
);
}
/**
* Rate limiting middleware for password reset
*/
export function rateLimitPasswordReset(
clientIP: string,
event?: H3Event
): void {
checkRateLimit(
`password-reset:ip:${clientIP}`,
RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts,
RATE_LIMITS.PASSWORD_RESET_IP.windowMs,
event
);
}
/**
* Rate limiting middleware for registration
*/
export function rateLimitRegistration(clientIP: string, event?: H3Event): void {
checkRateLimit(
`registration:ip:${clientIP}`,
RATE_LIMITS.REGISTRATION_IP.maxAttempts,
RATE_LIMITS.REGISTRATION_IP.windowMs,
event
);
}
/**
* Rate limiting middleware for email verification
*/
export function rateLimitEmailVerification(
clientIP: string,
event?: H3Event
): void {
checkRateLimit(
`email-verification:ip:${clientIP}`,
RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts,
RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs,
event
);
}

View File

@@ -0,0 +1,486 @@
/**
* Authentication Security Tests
* Tests for authentication mechanisms including JWT, session management, and timing attacks
*/
import { describe, it, expect, beforeEach } from "bun:test";
import { getUserID, getPrivilegeLevel, checkAuthStatus } from "~/server/auth";
import {
createMockEvent,
createTestJWT,
createExpiredJWT,
createInvalidSignatureJWT,
measureTime
} from "./test-utils";
import { jwtVerify, SignJWT } from "jose";
import { env } from "~/env/server";
describe("Authentication Security", () => {
describe("JWT Token Validation", () => {
it("should validate correct JWT tokens", async () => {
const userId = "test-user-123";
const token = await createTestJWT(userId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBe(userId);
});
it("should reject expired JWT tokens", async () => {
const userId = "test-user-123";
const expiredToken = await createExpiredJWT(userId);
const event = createMockEvent({
cookies: { userIDToken: expiredToken }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
it("should reject JWT tokens with invalid signature", async () => {
const userId = "test-user-123";
const invalidToken = await createInvalidSignatureJWT(userId);
const event = createMockEvent({
cookies: { userIDToken: invalidToken }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
it("should reject malformed JWT tokens", async () => {
const event = createMockEvent({
cookies: { userIDToken: "not-a-valid-jwt" }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
it("should reject empty JWT tokens", async () => {
const event = createMockEvent({
cookies: { userIDToken: "" }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
it("should reject JWT tokens with missing user ID", async () => {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const tokenWithoutId = await new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);
const event = createMockEvent({
cookies: { userIDToken: tokenWithoutId }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
it("should reject JWT tokens with invalid user ID type", async () => {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const tokenWithNumberId = await new SignJWT({ id: 12345 })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);
const event = createMockEvent({
cookies: { userIDToken: tokenWithNumberId }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
it("should handle missing cookie gracefully", async () => {
const event = createMockEvent({});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
});
describe("JWT Token Tampering", () => {
it("should detect modified JWT payload", async () => {
const userId = "test-user-123";
const token = await createTestJWT(userId);
// Tamper with the payload (middle part of JWT)
const parts = token.split(".");
const tamperedPayload = Buffer.from(
JSON.stringify({ id: "attacker-id" })
).toString("base64url");
const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
const event = createMockEvent({
cookies: { userIDToken: tamperedToken }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
it("should detect modified JWT signature", async () => {
const userId = "test-user-123";
const token = await createTestJWT(userId);
// Tamper with the signature (last part of JWT)
const parts = token.split(".");
const tamperedToken = `${parts[0]}.${parts[1]}.modified-signature`;
const event = createMockEvent({
cookies: { userIDToken: tamperedToken }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
it("should reject none algorithm JWT tokens", async () => {
// Try to create a token with 'none' algorithm (security vulnerability)
const payload = Buffer.from(
JSON.stringify({ id: "attacker-id", exp: Date.now() / 1000 + 3600 })
).toString("base64url");
const header = Buffer.from(
JSON.stringify({ alg: "none", typ: "JWT" })
).toString("base64url");
const noneToken = `${header}.${payload}.`;
const event = createMockEvent({
cookies: { userIDToken: noneToken }
});
const extractedUserId = await getUserID(event);
expect(extractedUserId).toBeNull();
});
});
describe("Privilege Level Security", () => {
it("should return admin privilege for admin user", async () => {
const adminId = env.ADMIN_ID;
const token = await createTestJWT(adminId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("admin");
});
it("should return user privilege for regular user", async () => {
const userId = "regular-user-123";
const token = await createTestJWT(userId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("user");
});
it("should return anonymous privilege for unauthenticated request", async () => {
const event = createMockEvent({});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous");
});
it("should return anonymous privilege for invalid token", async () => {
const event = createMockEvent({
cookies: { userIDToken: "invalid-token" }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous");
});
it("should not allow privilege escalation through token manipulation", async () => {
const userId = "regular-user-123";
const token = await createTestJWT(userId);
// Even if attacker modifies the token, signature verification will fail
const parts = token.split(".");
const fakeAdminPayload = Buffer.from(
JSON.stringify({ id: env.ADMIN_ID })
).toString("base64url");
const fakeAdminToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`;
const event = createMockEvent({
cookies: { userIDToken: fakeAdminToken }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous"); // Token validation fails
});
});
describe("Session Management", () => {
it("should identify authenticated sessions correctly", async () => {
const userId = "test-user-123";
const token = await createTestJWT(userId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const authStatus = await checkAuthStatus(event);
expect(authStatus.isAuthenticated).toBe(true);
expect(authStatus.userId).toBe(userId);
});
it("should identify unauthenticated sessions correctly", async () => {
const event = createMockEvent({});
const authStatus = await checkAuthStatus(event);
expect(authStatus.isAuthenticated).toBe(false);
expect(authStatus.userId).toBeNull();
});
it("should handle session with expired token", async () => {
const userId = "test-user-123";
const expiredToken = await createExpiredJWT(userId);
const event = createMockEvent({
cookies: { userIDToken: expiredToken }
});
const authStatus = await checkAuthStatus(event);
expect(authStatus.isAuthenticated).toBe(false);
expect(authStatus.userId).toBeNull();
});
});
describe("Timing Attack Prevention", () => {
it("should have consistent timing for valid and invalid tokens", async () => {
const userId = "test-user-123";
const validToken = await createTestJWT(userId);
const invalidToken = "invalid-token";
// Measure time for valid token
const validEvent = createMockEvent({
cookies: { userIDToken: validToken }
});
const { duration: validDuration } = await measureTime(() =>
getUserID(validEvent)
);
// Measure time for invalid token
const invalidEvent = createMockEvent({
cookies: { userIDToken: invalidToken }
});
const { duration: invalidDuration } = await measureTime(() =>
getUserID(invalidEvent)
);
// Timing difference should be minimal (within reasonable variance)
// This helps prevent timing attacks to enumerate valid tokens
const timingDifference = Math.abs(validDuration - invalidDuration);
// Allow up to 5ms variance (accounts for system variations)
expect(timingDifference).toBeLessThan(5);
});
it("should have consistent timing for different user privilege levels", async () => {
const adminId = env.ADMIN_ID;
const userId = "regular-user-123";
const adminToken = await createTestJWT(adminId);
const userToken = await createTestJWT(userId);
// Measure time for admin privilege check
const adminEvent = createMockEvent({
cookies: { userIDToken: adminToken }
});
const { duration: adminDuration } = await measureTime(() =>
getPrivilegeLevel(adminEvent)
);
// Measure time for user privilege check
const userEvent = createMockEvent({
cookies: { userIDToken: userToken }
});
const { duration: userDuration } = await measureTime(() =>
getPrivilegeLevel(userEvent)
);
const timingDifference = Math.abs(adminDuration - userDuration);
expect(timingDifference).toBeLessThan(5);
});
});
describe("Token Expiration", () => {
it("should respect token expiration time", async () => {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const userId = "test-user-123";
// Create token expiring in 1 second
const shortLivedToken = await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1s")
.sign(secret);
// Should work immediately
const event1 = createMockEvent({
cookies: { userIDToken: shortLivedToken }
});
const id1 = await getUserID(event1);
expect(id1).toBe(userId);
// Wait for token to expire
await new Promise((resolve) => setTimeout(resolve, 1500));
// Should fail after expiration
const event2 = createMockEvent({
cookies: { userIDToken: shortLivedToken }
});
const id2 = await getUserID(event2);
expect(id2).toBeNull();
});
it("should handle tokens with very long expiration", async () => {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const userId = "test-user-123";
const longLivedToken = await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("365d") // 1 year
.sign(secret);
const event = createMockEvent({
cookies: { userIDToken: longLivedToken }
});
const extractedId = await getUserID(event);
expect(extractedId).toBe(userId);
});
it("should reject tokens with past expiration timestamps", async () => {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const userId = "test-user-123";
const pastToken = await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(Math.floor(Date.now() / 1000) - 3600) // 1 hour ago
.sign(secret);
const event = createMockEvent({
cookies: { userIDToken: pastToken }
});
const extractedId = await getUserID(event);
expect(extractedId).toBeNull();
});
});
describe("Edge Cases", () => {
it("should handle very long JWT tokens", async () => {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const largePayload = {
id: "test-user-123",
extraData: "x".repeat(10000) // 10KB of extra data
};
const largeToken = await new SignJWT(largePayload)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);
const event = createMockEvent({
cookies: { userIDToken: largeToken }
});
const extractedId = await getUserID(event);
expect(extractedId).toBe("test-user-123");
});
it("should handle special characters in user IDs", async () => {
const specialUserId = "user-with-special-!@#$%^&*()";
const token = await createTestJWT(specialUserId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const extractedId = await getUserID(event);
expect(extractedId).toBe(specialUserId);
});
it("should handle unicode user IDs", async () => {
const unicodeUserId = "user-with-unicode-🔐🛡️";
const token = await createTestJWT(unicodeUserId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const extractedId = await getUserID(event);
expect(extractedId).toBe(unicodeUserId);
});
it("should reject JWT with future issued-at time", async () => {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const futureToken = await new SignJWT({ id: "test-user-123" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(Math.floor(Date.now() / 1000) + 3600) // 1 hour in future
.setExpirationTime("2h")
.sign(secret);
const event = createMockEvent({
cookies: { userIDToken: futureToken }
});
// Some JWT libraries reject future iat, some don't
// This test documents the behavior
const extractedId = await getUserID(event);
// Behavior may vary - just ensure no crash
expect(extractedId === null || extractedId === "test-user-123").toBe(
true
);
});
});
describe("Performance", () => {
it("should validate tokens efficiently", async () => {
const userId = "test-user-123";
const token = await createTestJWT(userId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const start = performance.now();
for (let i = 0; i < 1000; i++) {
await getUserID(event);
}
const duration = performance.now() - start;
// Should validate 1000 tokens in less than 100ms
expect(duration).toBeLessThan(100);
});
it("should check privilege levels efficiently", async () => {
const userId = "test-user-123";
const token = await createTestJWT(userId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const start = performance.now();
for (let i = 0; i < 1000; i++) {
await getPrivilegeLevel(event);
}
const duration = performance.now() - start;
// Should check 1000 privileges in less than 100ms
expect(duration).toBeLessThan(100);
});
});
});

View File

@@ -0,0 +1,417 @@
/**
* Authorization Tests
* Tests for access control, privilege escalation prevention, and admin access
*/
import { describe, it, expect } from "bun:test";
import { getUserID, getPrivilegeLevel } from "~/server/auth";
import { createMockEvent, createTestJWT } from "./test-utils";
import { env } from "~/env/server";
describe("Authorization", () => {
describe("Admin Access Control", () => {
it("should grant admin access to configured admin user", async () => {
const adminToken = await createTestJWT(env.ADMIN_ID);
const event = createMockEvent({
cookies: { userIDToken: adminToken }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("admin");
});
it("should deny admin access to regular users", async () => {
const userToken = await createTestJWT("regular-user-123");
const event = createMockEvent({
cookies: { userIDToken: userToken }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("user");
expect(privilege).not.toBe("admin");
});
it("should deny admin access to anonymous users", async () => {
const event = createMockEvent({});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous");
expect(privilege).not.toBe("admin");
});
it("should not allow privilege escalation through token tampering", async () => {
// Create a regular user token
const regularToken = await createTestJWT("regular-user-123");
// Attacker tries to modify token to include admin ID
// This should fail signature verification
const parts = regularToken.split(".");
const fakeAdminPayload = Buffer.from(
JSON.stringify({ id: env.ADMIN_ID })
).toString("base64url");
const tamperedToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`;
const event = createMockEvent({
cookies: { userIDToken: tamperedToken }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous"); // Invalid token = anonymous
});
it("should handle malformed admin ID gracefully", async () => {
const invalidIds = ["", null, undefined, " ", "admin'--"];
for (const invalidId of invalidIds) {
const token = await createTestJWT(invalidId as string);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const privilege = await getPrivilegeLevel(event);
// Should not grant admin access for invalid IDs
expect(privilege).not.toBe("admin");
}
});
});
describe("User Access Control", () => {
it("should grant user access to authenticated users", async () => {
const userToken = await createTestJWT("user-123");
const event = createMockEvent({
cookies: { userIDToken: userToken }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("user");
});
it("should deny user access to anonymous requests", async () => {
const event = createMockEvent({});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous");
expect(privilege).not.toBe("user");
});
it("should maintain user access with valid token", async () => {
const userToken = await createTestJWT("user-456");
const event = createMockEvent({
cookies: { userIDToken: userToken }
});
const userId = await getUserID(event);
expect(userId).toBe("user-456");
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("user");
});
});
describe("Privilege Escalation Prevention", () => {
it("should prevent horizontal privilege escalation", async () => {
const user1Token = await createTestJWT("user-1");
const user2Token = await createTestJWT("user-2");
const event1 = createMockEvent({
cookies: { userIDToken: user1Token }
});
const event2 = createMockEvent({
cookies: { userIDToken: user2Token }
});
const user1Id = await getUserID(event1);
const user2Id = await getUserID(event2);
expect(user1Id).toBe("user-1");
expect(user2Id).toBe("user-2");
expect(user1Id).not.toBe(user2Id);
});
it("should prevent vertical privilege escalation", async () => {
// Regular user should not be able to become admin
const userToken = await createTestJWT("regular-user");
const event = createMockEvent({
cookies: { userIDToken: userToken }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("user");
// Even with multiple checks, privilege should remain the same
const privilege2 = await getPrivilegeLevel(event);
expect(privilege2).toBe("user");
});
it("should not allow session hijacking through token reuse", async () => {
const user1Token = await createTestJWT("user-1");
// User 1's token should always return user 1's ID
const event1 = createMockEvent({
cookies: { userIDToken: user1Token }
});
const id1 = await getUserID(event1);
// Even if attacker captures token, it still identifies as user 1
const event2 = createMockEvent({
cookies: { userIDToken: user1Token }
});
const id2 = await getUserID(event2);
expect(id1).toBe("user-1");
expect(id2).toBe("user-1");
});
it("should prevent privilege escalation via race conditions", async () => {
const userToken = await createTestJWT("concurrent-user");
const event = createMockEvent({
cookies: { userIDToken: userToken }
});
// Simulate concurrent privilege checks
const results = await Promise.all([
getPrivilegeLevel(event),
getPrivilegeLevel(event),
getPrivilegeLevel(event),
getPrivilegeLevel(event),
getPrivilegeLevel(event)
]);
// All results should be the same
expect(results.every((r) => r === "user")).toBe(true);
});
});
describe("Anonymous Access", () => {
it("should handle missing authentication token", async () => {
const event = createMockEvent({});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous");
});
it("should handle empty authentication token", async () => {
const event = createMockEvent({
cookies: { userIDToken: "" }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous");
});
it("should handle invalid token format", async () => {
const event = createMockEvent({
cookies: { userIDToken: "not-a-jwt-token" }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous");
});
it("should return null user ID for anonymous users", async () => {
const event = createMockEvent({});
const userId = await getUserID(event);
expect(userId).toBeNull();
});
});
describe("Access Control Edge Cases", () => {
it("should handle user ID with special characters", async () => {
const specialUserId = "user-with-special-!@#$%";
const token = await createTestJWT(specialUserId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const userId = await getUserID(event);
expect(userId).toBe(specialUserId);
});
it("should handle very long user IDs", async () => {
const longUserId = "user-" + "x".repeat(1000);
const token = await createTestJWT(longUserId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const userId = await getUserID(event);
expect(userId).toBe(longUserId);
});
it("should handle user ID with unicode characters", async () => {
const unicodeUserId = "user-with-unicode-🔐";
const token = await createTestJWT(unicodeUserId);
const event = createMockEvent({
cookies: { userIDToken: token }
});
const userId = await getUserID(event);
expect(userId).toBe(unicodeUserId);
});
it("should handle admin ID case sensitivity", async () => {
const adminId = env.ADMIN_ID;
const wrongCaseId = adminId.toUpperCase();
// Exact match required
const correctToken = await createTestJWT(adminId);
const wrongCaseToken = await createTestJWT(wrongCaseId);
const correctEvent = createMockEvent({
cookies: { userIDToken: correctToken }
});
const wrongCaseEvent = createMockEvent({
cookies: { userIDToken: wrongCaseToken }
});
const correctPrivilege = await getPrivilegeLevel(correctEvent);
const wrongCasePrivilege = await getPrivilegeLevel(wrongCaseEvent);
expect(correctPrivilege).toBe("admin");
// Wrong case should not get admin access (unless IDs match)
if (adminId !== wrongCaseId) {
expect(wrongCasePrivilege).toBe("user");
}
});
});
describe("Authorization Attack Scenarios", () => {
it("should prevent session fixation attacks", async () => {
// Attacker cannot predict or fix session tokens
const token1 = await createTestJWT("user-1");
const token2 = await createTestJWT("user-1");
// Tokens should be different even for same user
// (Due to different timestamps, though payload is same)
expect(token1).toBeDefined();
expect(token2).toBeDefined();
});
it("should prevent parameter pollution attacks", async () => {
// Multiple cookie values should not cause confusion
const token1 = await createTestJWT("user-1");
const token2 = await createTestJWT("user-2");
// Only first cookie should be used
const event = createMockEvent({
cookies: {
userIDToken: token1
// In practice, duplicate cookies are handled by the framework
}
});
const userId = await getUserID(event);
expect(userId).toBe("user-1");
});
it("should prevent token substitution attacks", async () => {
const legitimateToken = await createTestJWT("victim-user");
const attackerToken = await createTestJWT("attacker-user");
// Each token should only authenticate its respective user
const legitimateEvent = createMockEvent({
cookies: { userIDToken: legitimateToken }
});
const attackerEvent = createMockEvent({
cookies: { userIDToken: attackerToken }
});
const legitimateId = await getUserID(legitimateEvent);
const attackerId = await getUserID(attackerEvent);
expect(legitimateId).toBe("victim-user");
expect(attackerId).toBe("attacker-user");
expect(legitimateId).not.toBe(attackerId);
});
it("should prevent authorization bypass through empty checks", async () => {
const emptyChecks = [null, undefined, "", " ", "null", "undefined"];
for (const check of emptyChecks) {
const event = createMockEvent({
cookies: { userIDToken: check as string }
});
const privilege = await getPrivilegeLevel(event);
expect(privilege).toBe("anonymous");
}
});
});
describe("Multi-User Scenarios", () => {
it("should handle multiple concurrent user sessions", async () => {
const users = ["user-1", "user-2", "user-3", "user-4", "user-5"];
const tokens = await Promise.all(users.map((u) => createTestJWT(u)));
const events = tokens.map((token) =>
createMockEvent({ cookies: { userIDToken: token } })
);
const userIds = await Promise.all(events.map(getUserID));
// All users should be correctly identified
expect(userIds).toEqual(users);
});
it("should maintain separate privileges for different users", async () => {
const adminToken = await createTestJWT(env.ADMIN_ID);
const user1Token = await createTestJWT("user-1");
const user2Token = await createTestJWT("user-2");
const adminEvent = createMockEvent({
cookies: { userIDToken: adminToken }
});
const user1Event = createMockEvent({
cookies: { userIDToken: user1Token }
});
const user2Event = createMockEvent({
cookies: { userIDToken: user2Token }
});
const [adminPriv, user1Priv, user2Priv] = await Promise.all([
getPrivilegeLevel(adminEvent),
getPrivilegeLevel(user1Event),
getPrivilegeLevel(user2Event)
]);
expect(adminPriv).toBe("admin");
expect(user1Priv).toBe("user");
expect(user2Priv).toBe("user");
});
});
describe("Performance", () => {
it("should check privileges efficiently", async () => {
const userToken = await createTestJWT("perf-test-user");
const event = createMockEvent({
cookies: { userIDToken: userToken }
});
const start = performance.now();
for (let i = 0; i < 1000; i++) {
await getPrivilegeLevel(event);
}
const duration = performance.now() - start;
// Should complete 1000 checks in less than 100ms
expect(duration).toBeLessThan(100);
});
it("should extract user IDs efficiently", async () => {
const userToken = await createTestJWT("perf-test-user");
const event = createMockEvent({
cookies: { userIDToken: userToken }
});
const start = performance.now();
for (let i = 0; i < 1000; i++) {
await getUserID(event);
}
const duration = performance.now() - start;
// Should complete 1000 extractions in less than 100ms
expect(duration).toBeLessThan(100);
});
});
});

View File

@@ -0,0 +1,320 @@
/**
* CSRF Protection Tests
* Tests for Cross-Site Request Forgery protection mechanisms
*/
import { describe, it, expect, beforeEach } from "bun:test";
import {
generateCSRFToken,
setCSRFToken,
validateCSRFToken,
csrfProtection
} from "~/server/security";
import { createMockEvent } from "./test-utils";
describe("CSRF Protection", () => {
describe("generateCSRFToken", () => {
it("should generate a valid UUID token", () => {
const token = generateCSRFToken();
expect(token).toBeDefined();
expect(typeof token).toBe("string");
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
expect(token).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
});
it("should generate unique tokens", () => {
const token1 = generateCSRFToken();
const token2 = generateCSRFToken();
expect(token1).not.toBe(token2);
});
it("should generate cryptographically secure tokens", () => {
// Generate multiple tokens and ensure no collisions
const tokens = new Set<string>();
for (let i = 0; i < 1000; i++) {
tokens.add(generateCSRFToken());
}
expect(tokens.size).toBe(1000);
});
});
describe("setCSRFToken", () => {
it("should set CSRF token cookie with correct attributes", () => {
const event = createMockEvent({});
const token = setCSRFToken(event);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
// Token should be a UUID
expect(token).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
});
it("should generate different tokens on subsequent calls", () => {
const event1 = createMockEvent({});
const event2 = createMockEvent({});
const token1 = setCSRFToken(event1);
const token2 = setCSRFToken(event2);
expect(token1).not.toBe(token2);
});
});
describe("validateCSRFToken", () => {
it("should validate matching tokens", () => {
const token = generateCSRFToken();
const event = createMockEvent({
headers: { "x-csrf-token": token },
cookies: { "csrf-token": token }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(true);
});
it("should reject mismatched tokens", () => {
const event = createMockEvent({
headers: { "x-csrf-token": "token1" },
cookies: { "csrf-token": "token2" }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(false);
});
it("should reject missing header token", () => {
const event = createMockEvent({
cookies: { "csrf-token": "token" }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(false);
});
it("should reject missing cookie token", () => {
const event = createMockEvent({
headers: { "x-csrf-token": "token" }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(false);
});
it("should reject empty tokens", () => {
const event = createMockEvent({
headers: { "x-csrf-token": "" },
cookies: { "csrf-token": "" }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(false);
});
it("should use constant-time comparison", async () => {
const validToken = "a".repeat(36);
const invalidToken1 = "b".repeat(36);
const invalidToken2 = "b".repeat(35) + "a";
// Test timing for completely different tokens
const event1 = createMockEvent({
headers: { "x-csrf-token": invalidToken1 },
cookies: { "csrf-token": validToken }
});
const start1 = performance.now();
validateCSRFToken(event1);
const time1 = performance.now() - start1;
// Test timing for tokens that differ only at the end
const event2 = createMockEvent({
headers: { "x-csrf-token": invalidToken2 },
cookies: { "csrf-token": validToken }
});
const start2 = performance.now();
validateCSRFToken(event2);
const time2 = performance.now() - start2;
// Timing difference should be minimal (less than 1ms)
// This tests for constant-time comparison
const timeDiff = Math.abs(time1 - time2);
expect(timeDiff).toBeLessThan(1);
});
it("should reject tokens with different lengths", () => {
const event = createMockEvent({
headers: { "x-csrf-token": "short" },
cookies: { "csrf-token": "much-longer-token" }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(false);
});
});
describe("CSRF Attack Scenarios", () => {
it("should prevent basic CSRF attack", () => {
// Attacker doesn't have access to the CSRF token cookie
const attackEvent = createMockEvent({
headers: { "x-csrf-token": "attacker-guessed-token" }
});
const isValid = validateCSRFToken(attackEvent);
expect(isValid).toBe(false);
});
it("should prevent token reuse from different session", () => {
const token1 = generateCSRFToken();
const token2 = generateCSRFToken();
// User has token1, attacker tries to use token2
const event = createMockEvent({
headers: { "x-csrf-token": token2 },
cookies: { "csrf-token": token1 }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(false);
});
it("should prevent token modification", () => {
const token = generateCSRFToken();
const modifiedToken = token.slice(0, -1) + "x";
const event = createMockEvent({
headers: { "x-csrf-token": modifiedToken },
cookies: { "csrf-token": token }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(false);
});
it("should prevent replay attacks with old tokens", () => {
// Simulate an old token that was captured
const oldToken = "old-captured-token-12345";
const event = createMockEvent({
headers: { "x-csrf-token": oldToken },
cookies: { "csrf-token": oldToken }
});
// Even if tokens match, they should be validated by the system
// This test validates the structure works correctly
const isValid = validateCSRFToken(event);
expect(isValid).toBe(true); // Matches are valid
});
});
describe("Edge Cases", () => {
it("should handle null tokens", () => {
const event = createMockEvent({});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(false);
});
it("should handle undefined tokens", () => {
const event = createMockEvent({
headers: {},
cookies: {}
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(false);
});
it("should handle special characters in tokens", () => {
const token = "token-with-special-!@#$%^&*()";
const event = createMockEvent({
headers: { "x-csrf-token": token },
cookies: { "csrf-token": token }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(true);
});
it("should handle very long tokens", () => {
const longToken = "a".repeat(1000);
const event = createMockEvent({
headers: { "x-csrf-token": longToken },
cookies: { "csrf-token": longToken }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(true);
});
it("should handle unicode tokens", () => {
const unicodeToken = "token-with-unicode-🔒🛡️";
const event = createMockEvent({
headers: { "x-csrf-token": unicodeToken },
cookies: { "csrf-token": unicodeToken }
});
const isValid = validateCSRFToken(event);
expect(isValid).toBe(true);
});
});
describe("Token Generation Security", () => {
it("should not generate predictable tokens", () => {
const tokens: string[] = [];
for (let i = 0; i < 100; i++) {
tokens.push(generateCSRFToken());
}
// Check for sequential patterns
for (let i = 1; i < tokens.length; i++) {
// Tokens should not be incrementing
expect(tokens[i]).not.toBe(
String(Number(tokens[i - 1].replace(/-/g, "")) + 1)
);
}
});
it("should generate tokens with sufficient entropy", () => {
const token = generateCSRFToken();
// UUID without dashes should be 32 hex characters
const hexString = token.replace(/-/g, "");
expect(hexString).toMatch(/^[0-9a-f]{32}$/i);
// Check that not all characters are the same
const uniqueChars = new Set(hexString.split(""));
expect(uniqueChars.size).toBeGreaterThan(5);
});
});
describe("Performance", () => {
it("should generate tokens quickly", () => {
const start = performance.now();
for (let i = 0; i < 1000; i++) {
generateCSRFToken();
}
const duration = performance.now() - start;
// Should generate 1000 tokens in less than 100ms
expect(duration).toBeLessThan(100);
});
it("should validate tokens quickly", () => {
const token = generateCSRFToken();
const event = createMockEvent({
headers: { "x-csrf-token": token },
cookies: { "csrf-token": token }
});
const start = performance.now();
for (let i = 0; i < 10000; i++) {
validateCSRFToken(event);
}
const duration = performance.now() - start;
// Should validate 10000 tokens in less than 100ms
expect(duration).toBeLessThan(100);
});
});
});

View File

@@ -0,0 +1,522 @@
/**
* Input Validation and Injection Tests
* Tests for SQL injection, XSS, and other injection attack prevention
*/
import { describe, it, expect } from "bun:test";
import {
isValidEmail,
validatePassword,
isValidDisplayName
} from "~/lib/validation";
import { SQL_INJECTION_PAYLOADS, XSS_PAYLOADS } from "./test-utils";
import { ConnectionFactory } from "~/server/database";
describe("Input Validation and Injection Prevention", () => {
describe("Email Validation", () => {
it("should accept valid email addresses", () => {
const validEmails = [
"user@example.com",
"test.user@example.com",
"user+tag@example.co.uk",
"user123@test-domain.com",
"first.last@subdomain.example.com"
];
for (const email of validEmails) {
expect(isValidEmail(email)).toBe(true);
}
});
it("should reject invalid email addresses", () => {
const invalidEmails = [
"not-an-email",
"@example.com",
"user@",
"user @example.com",
"user@example",
"user..name@example.com",
"user@.com",
"",
" ",
"user@domain@domain.com"
];
for (const email of invalidEmails) {
expect(isValidEmail(email)).toBe(false);
}
});
it("should reject SQL injection attempts in emails", () => {
const sqlEmails = [
"admin'--@example.com",
"user@example.com'; DROP TABLE User--",
"' OR '1'='1@example.com",
"user@example.com' UNION SELECT",
"admin@example.com'--"
];
for (const email of sqlEmails) {
// Either reject as invalid, or it's properly escaped in queries
const isValid = isValidEmail(email);
// Test documents the behavior
expect(typeof isValid).toBe("boolean");
}
});
it("should handle very long email addresses", () => {
const longEmail = "a".repeat(1000) + "@example.com";
const result = isValidEmail(longEmail);
// Should handle gracefully
expect(typeof result).toBe("boolean");
});
it("should handle email with unicode characters", () => {
const unicodeEmail = "üser@exämple.com";
const result = isValidEmail(unicodeEmail);
expect(typeof result).toBe("boolean");
});
});
describe("Display Name Validation", () => {
it("should accept valid display names", () => {
const validNames = [
"John Doe",
"Alice",
"Bob Smith Jr.",
"李明",
"José García",
"123User"
];
for (const name of validNames) {
expect(isValidDisplayName(name)).toBe(true);
}
});
it("should reject empty display names", () => {
const invalidNames = ["", " ", "\t", "\n"];
for (const name of invalidNames) {
expect(isValidDisplayName(name)).toBe(false);
}
});
it("should reject excessively long display names", () => {
const longName = "a".repeat(51);
expect(isValidDisplayName(longName)).toBe(false);
});
it("should handle display names with special characters", () => {
const specialNames = [
"User<script>",
"User'--",
'User"OR"1"="1',
"User & Co",
"User@123"
];
for (const name of specialNames) {
const isValid = isValidDisplayName(name);
// Should either accept and sanitize, or reject
expect(typeof isValid).toBe("boolean");
}
});
});
describe("SQL Injection Prevention", () => {
it("should use parameterized queries for user authentication", async () => {
const conn = ConnectionFactory();
// Test that SQL injection attempts don't work
const maliciousEmail = "admin'--";
try {
// This query uses parameterized args (safe)
const result = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [maliciousEmail]
});
// Should return no results (no user with that exact email)
expect(result.rows.length).toBe(0);
} catch (error) {
// If error, ensure it's not a SQL error
expect(error).toBeDefined();
}
});
it("should prevent SQL injection in all query parameters", async () => {
const conn = ConnectionFactory();
for (const payload of SQL_INJECTION_PAYLOADS) {
try {
// Test various injection points
await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [payload]
});
await conn.execute({
sql: "SELECT * FROM User WHERE display_name = ?",
args: [payload]
});
// Queries should complete without SQL errors
expect(true).toBe(true);
} catch (error: any) {
// If error occurs, should not be SQL injection syntax error
expect(error.message).not.toContain("syntax error");
expect(error.message).not.toContain("SQL");
}
}
});
it("should prevent UNION-based SQL injection", async () => {
const conn = ConnectionFactory();
const unionPayload = "' UNION SELECT password_hash FROM User--";
try {
const result = await conn.execute({
sql: "SELECT email FROM User WHERE email = ?",
args: [unionPayload]
});
// Should not return password hashes
if (result.rows.length > 0) {
for (const row of result.rows) {
// Ensure we don't get password_hash column
expect(row).not.toHaveProperty("password_hash");
}
}
} catch (error) {
// Error is acceptable, SQL injection is not
expect(error).toBeDefined();
}
});
it("should prevent blind SQL injection timing attacks", async () => {
const conn = ConnectionFactory();
// Timing-based payload
const timingPayload = "admin' AND SLEEP(5)--";
const start = performance.now();
try {
await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [timingPayload]
});
} catch (error) {
// Ignore errors
}
const duration = performance.now() - start;
// Should not delay for 5 seconds
expect(duration).toBeLessThan(1000);
});
it("should prevent second-order SQL injection", async () => {
const conn = ConnectionFactory();
// Store malicious data
const maliciousName = "admin'--";
try {
// Insert with parameterized query (safe)
await conn.execute({
sql: "INSERT INTO User (id, email, display_name, provider) VALUES (?, ?, ?, ?)",
args: [
"test-user-sqli",
"test-sqli@example.com",
maliciousName,
"email"
]
});
// Retrieve and use (should still be safe with parameterized queries)
const result = await conn.execute({
sql: "SELECT display_name FROM User WHERE email = ?",
args: ["test-sqli@example.com"]
});
expect(result.rows.length).toBeGreaterThanOrEqual(0);
// Cleanup
await conn.execute({
sql: "DELETE FROM User WHERE email = ?",
args: ["test-sqli@example.com"]
});
} catch (error) {
// Should not have SQL syntax errors
expect(error).toBeDefined();
}
});
});
describe("XSS Prevention", () => {
it("should identify potentially dangerous XSS patterns", () => {
// These payloads should be handled by frontend sanitization
for (const payload of XSS_PAYLOADS) {
// Document that these patterns exist
expect(payload).toBeDefined();
expect(typeof payload).toBe("string");
// In practice, these should be sanitized before rendering
// or stored as-is and sanitized on output
}
});
it("should handle script tags in user input", () => {
const scriptInput = "<script>alert('XSS')</script>";
// Validation should not crash
const nameValid = isValidDisplayName(scriptInput);
expect(typeof nameValid).toBe("boolean");
// Email validation
const emailValid = isValidEmail(scriptInput);
expect(typeof emailValid).toBe("boolean");
});
it("should handle event handler attributes", () => {
const eventHandlers = [
"onclick=alert('XSS')",
"onerror=alert('XSS')",
"onload=alert('XSS')",
"onfocus=alert('XSS')"
];
for (const handler of eventHandlers) {
const result = isValidDisplayName(handler);
expect(typeof result).toBe("boolean");
}
});
it("should handle javascript: protocol", () => {
const jsProtocol = "javascript:alert('XSS')";
const displayNameValid = isValidDisplayName(jsProtocol);
const emailValid = isValidEmail(jsProtocol);
expect(typeof displayNameValid).toBe("boolean");
expect(typeof emailValid).toBe("boolean");
});
});
describe("Command Injection Prevention", () => {
it("should not execute shell commands from user input", () => {
const commandPayloads = [
"; ls -la",
"| cat /etc/passwd",
"&& rm -rf /",
"`whoami`",
"$(whoami)",
"; DROP TABLE User;--"
];
for (const payload of commandPayloads) {
// These should be treated as strings, not executed
const emailValid = isValidEmail(payload);
const nameValid = isValidDisplayName(payload);
expect(typeof emailValid).toBe("boolean");
expect(typeof nameValid).toBe("boolean");
}
});
});
describe("Path Traversal Prevention", () => {
it("should not allow directory traversal in inputs", () => {
const traversalPayloads = [
"../../../etc/passwd",
"..\\..\\..\\windows\\system32",
"....//....//....//etc/passwd",
"%2e%2e%2f",
"..;/..;/"
];
for (const payload of traversalPayloads) {
const emailValid = isValidEmail(payload);
const nameValid = isValidDisplayName(payload);
expect(typeof emailValid).toBe("boolean");
expect(typeof nameValid).toBe("boolean");
}
});
});
describe("LDAP Injection Prevention", () => {
it("should handle LDAP injection patterns", () => {
const ldapPayloads = [
"*)(uid=*))(|(uid=*",
"admin*",
"*)(&(password=*))",
"*))%00"
];
for (const payload of ldapPayloads) {
const emailValid = isValidEmail(payload);
const nameValid = isValidDisplayName(payload);
expect(typeof emailValid).toBe("boolean");
expect(typeof nameValid).toBe("boolean");
}
});
});
describe("XML Injection Prevention", () => {
it("should handle XML special characters", () => {
const xmlPayloads = [
"<![CDATA[attack]]>",
'<?xml version="1.0"?>',
'<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>',
"&lt;script&gt;alert('XSS')&lt;/script&gt;"
];
for (const payload of xmlPayloads) {
const emailValid = isValidEmail(payload);
const nameValid = isValidDisplayName(payload);
expect(typeof emailValid).toBe("boolean");
expect(typeof nameValid).toBe("boolean");
}
});
});
describe("NoSQL Injection Prevention", () => {
it("should handle MongoDB-style injection attempts", () => {
const nosqlPayloads = [
'{"$gt": ""}',
'{"$ne": null}',
'{"$regex": ".*"}',
'{"$where": "sleep(1000)"}',
'{"username": {"$gt": ""}}'
];
for (const payload of nosqlPayloads) {
const emailValid = isValidEmail(payload);
const nameValid = isValidDisplayName(payload);
expect(typeof emailValid).toBe("boolean");
expect(typeof nameValid).toBe("boolean");
}
});
});
describe("Header Injection Prevention", () => {
it("should reject inputs with newline characters", () => {
const headerInjection = [
"user@example.com\r\nBcc: attacker@evil.com",
"test\nSet-Cookie: admin=true",
"user\r\nLocation: http://evil.com"
];
for (const payload of headerInjection) {
const emailValid = isValidEmail(payload);
expect(emailValid).toBe(false);
}
});
});
describe("Null Byte Injection Prevention", () => {
it("should handle null bytes in input", () => {
const nullBytePayloads = [
"admin\x00.jpg",
"user@example.com\x00admin",
"test\0injection"
];
for (const payload of nullBytePayloads) {
const emailValid = isValidEmail(payload);
const nameValid = isValidDisplayName(payload);
expect(typeof emailValid).toBe("boolean");
expect(typeof nameValid).toBe("boolean");
}
});
});
describe("Edge Cases", () => {
it("should handle extremely long inputs", () => {
const longInput = "a".repeat(100000);
const start = performance.now();
const emailValid = isValidEmail(longInput);
const nameValid = isValidDisplayName(longInput);
const duration = performance.now() - start;
expect(typeof emailValid).toBe("boolean");
expect(typeof nameValid).toBe("boolean");
// Should complete quickly (no ReDoS)
expect(duration).toBeLessThan(100);
});
it("should handle repeated characters", () => {
const repeated = "a".repeat(10000) + "@example.com";
const emailValid = isValidEmail(repeated);
expect(typeof emailValid).toBe("boolean");
});
it("should handle mixed encoding", () => {
const mixedEncoding = "test%40example.com";
const emailValid = isValidEmail(mixedEncoding);
expect(typeof emailValid).toBe("boolean");
});
it("should handle unicode normalization issues", () => {
const unicodePayloads = [
"admin\u0041", // 'A' in unicode
"test\u200B@example.com", // zero-width space
"user\uFEFF@example.com" // zero-width no-break space
];
for (const payload of unicodePayloads) {
const emailValid = isValidEmail(payload);
expect(typeof emailValid).toBe("boolean");
}
});
});
describe("Performance", () => {
it("should validate emails efficiently", () => {
const email = "test@example.com";
const start = performance.now();
for (let i = 0; i < 10000; i++) {
isValidEmail(email);
}
const duration = performance.now() - start;
expect(duration).toBeLessThan(100);
});
it("should validate display names efficiently", () => {
const name = "Test User";
const start = performance.now();
for (let i = 0; i < 10000; i++) {
isValidDisplayName(name);
}
const duration = performance.now() - start;
expect(duration).toBeLessThan(100);
});
it("should not be vulnerable to ReDoS attacks", () => {
// ReDoS payload with many repetitions
const redosPayload = "a".repeat(1000) + "!";
const start = performance.now();
validatePassword(redosPayload);
const duration = performance.now() - start;
// Should complete quickly
expect(duration).toBeLessThan(100);
});
});
});

View File

@@ -0,0 +1,529 @@
/**
* Password Security Tests
* Tests for password hashing, validation, strength requirements, and timing attacks
*/
import { describe, it, expect } from "bun:test";
import {
hashPassword,
checkPassword,
checkPasswordSafe
} from "~/server/password";
import { validatePassword, passwordsMatch } from "~/lib/validation";
import { measureTime } from "./test-utils";
describe("Password Security", () => {
describe("Password Hashing", () => {
it("should hash passwords using bcrypt", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
expect(hash).toBeDefined();
expect(typeof hash).toBe("string");
// Bcrypt hashes start with $2b$ or $2a$
expect(hash).toMatch(/^\$2[ab]\$/);
});
it("should generate unique hashes for same password", async () => {
const password = "TestPassword123!";
const hash1 = await hashPassword(password);
const hash2 = await hashPassword(password);
expect(hash1).not.toBe(hash2);
});
it("should generate hashes with sufficient length", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
// Bcrypt hashes are 60 characters long
expect(hash.length).toBe(60);
});
it("should handle very long passwords", async () => {
const longPassword = "a".repeat(1000);
const hash = await hashPassword(longPassword);
expect(hash).toBeDefined();
expect(hash.length).toBe(60);
});
it("should handle passwords with special characters", async () => {
const specialPassword = "P@ssw0rd!#$%^&*()_+-=[]{}|;:',.<>?/~`";
const hash = await hashPassword(specialPassword);
expect(hash).toBeDefined();
const match = await checkPassword(specialPassword, hash);
expect(match).toBe(true);
});
it("should handle passwords with unicode characters", async () => {
const unicodePassword = "Pässwörd123🔐🛡";
const hash = await hashPassword(unicodePassword);
expect(hash).toBeDefined();
const match = await checkPassword(unicodePassword, hash);
expect(match).toBe(true);
});
it("should handle empty passwords", async () => {
const emptyPassword = "";
const hash = await hashPassword(emptyPassword);
expect(hash).toBeDefined();
expect(hash.length).toBe(60);
});
});
describe("Password Verification", () => {
it("should verify correct password", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
const match = await checkPassword(password, hash);
expect(match).toBe(true);
});
it("should reject incorrect password", async () => {
const password = "TestPassword123!";
const wrongPassword = "WrongPassword123!";
const hash = await hashPassword(password);
const match = await checkPassword(wrongPassword, hash);
expect(match).toBe(false);
});
it("should be case-sensitive", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
const match = await checkPassword("testpassword123!", hash);
expect(match).toBe(false);
});
it("should detect single character differences", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
const almostMatch = "TestPassword124!";
const match = await checkPassword(almostMatch, hash);
expect(match).toBe(false);
});
it("should reject password with extra characters", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
const match = await checkPassword(password + "x", hash);
expect(match).toBe(false);
});
it("should reject password missing characters", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
const match = await checkPassword(password.slice(0, -1), hash);
expect(match).toBe(false);
});
});
describe("Timing Attack Prevention", () => {
it("should use constant time comparison in checkPasswordSafe", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
// Measure time for correct password
const { duration: correctDuration } = await measureTime(() =>
checkPasswordSafe(password, hash)
);
// Measure time for incorrect password
const { duration: incorrectDuration } = await measureTime(() =>
checkPasswordSafe("WrongPassword123!", hash)
);
// Bcrypt comparison should take similar time regardless
const timingDifference = Math.abs(correctDuration - incorrectDuration);
// Allow reasonable variance (bcrypt is inherently slow)
expect(timingDifference).toBeLessThan(50);
});
it("should handle null hash without timing leak", async () => {
const password = "TestPassword123!";
// Measure time for null hash
const { result: result1, duration: duration1 } = await measureTime(() =>
checkPasswordSafe(password, null)
);
// Measure time for undefined hash
const { result: result2, duration: duration2 } = await measureTime(() =>
checkPasswordSafe(password, undefined)
);
expect(result1).toBe(false);
expect(result2).toBe(false);
// Should take similar time
const timingDifference = Math.abs(duration1 - duration2);
expect(timingDifference).toBeLessThan(50);
});
it("should run bcrypt even when user doesn't exist", async () => {
const password = "TestPassword123!";
// checkPasswordSafe should always run bcrypt to prevent timing attacks
const { duration } = await measureTime(() =>
checkPasswordSafe(password, null)
);
// Should take at least a few milliseconds (bcrypt is slow)
expect(duration).toBeGreaterThan(1);
});
it("should have consistent timing for user exists vs not exists", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
// User exists
const { duration: existsDuration } = await measureTime(() =>
checkPasswordSafe("WrongPassword", hash)
);
// User doesn't exist (null hash)
const { duration: notExistsDuration } = await measureTime(() =>
checkPasswordSafe("WrongPassword", null)
);
// Timing should be similar to prevent user enumeration
const timingDifference = Math.abs(existsDuration - notExistsDuration);
expect(timingDifference).toBeLessThan(50);
});
});
describe("Password Validation", () => {
it("should accept strong passwords", () => {
const strongPassword = "MyStr0ng!P@ssw0rd";
const result = validatePassword(strongPassword);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.strength).toBe("good");
});
it("should reject passwords shorter than 12 characters", () => {
const shortPassword = "Short1!";
const result = validatePassword(shortPassword);
expect(result.isValid).toBe(false);
expect(result.errors).toContain(
"Password must be at least 12 characters long"
);
});
it("should reject passwords without uppercase letters", () => {
const noUppercase = "lowercase123!@#";
const result = validatePassword(noUppercase);
expect(result.isValid).toBe(false);
expect(result.errors).toContain(
"Password must contain at least one uppercase letter"
);
});
it("should reject passwords without lowercase letters", () => {
const noLowercase = "UPPERCASE123!@#";
const result = validatePassword(noLowercase);
expect(result.isValid).toBe(false);
expect(result.errors).toContain(
"Password must contain at least one lowercase letter"
);
});
it("should reject passwords without numbers", () => {
const noNumbers = "NoNumbersHere!@#";
const result = validatePassword(noNumbers);
expect(result.isValid).toBe(false);
expect(result.errors).toContain(
"Password must contain at least one number"
);
});
it("should reject passwords without special characters", () => {
const noSpecial = "NoSpecialChars123";
const result = validatePassword(noSpecial);
expect(result.isValid).toBe(false);
expect(result.errors).toContain(
"Password must contain at least one special character"
);
});
it("should reject common weak passwords", () => {
const commonPatterns = [
"Password123!",
"Qwerty123456!",
"Letmein12345!",
"Welcome123!@"
];
for (const password of commonPatterns) {
const result = validatePassword(password);
expect(result.isValid).toBe(false);
expect(result.errors.some((e) => e.includes("common patterns"))).toBe(
true
);
}
});
it("should calculate password strength correctly", () => {
const fairPassword = "MyP@ssw0rd12"; // 12 chars
const goodPassword = "MyStr0ng!P@ssw0rd"; // 17 chars
const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; // 25 chars
expect(validatePassword(fairPassword).strength).toBe("fair");
expect(validatePassword(goodPassword).strength).toBe("good");
expect(validatePassword(strongPassword).strength).toBe("strong");
});
it("should mark weak passwords appropriately", () => {
const weakPassword = "weak";
const result = validatePassword(weakPassword);
expect(result.strength).toBe("weak");
expect(result.isValid).toBe(false);
});
});
describe("Password Matching", () => {
it("should confirm matching passwords", () => {
const password = "TestPassword123!";
const confirmation = "TestPassword123!";
expect(passwordsMatch(password, confirmation)).toBe(true);
});
it("should reject non-matching passwords", () => {
const password = "TestPassword123!";
const confirmation = "DifferentPassword123!";
expect(passwordsMatch(password, confirmation)).toBe(false);
});
it("should reject empty passwords", () => {
expect(passwordsMatch("", "")).toBe(false);
});
it("should be case-sensitive", () => {
const password = "TestPassword123!";
const confirmation = "testpassword123!";
expect(passwordsMatch(password, confirmation)).toBe(false);
});
it("should detect single character differences", () => {
const password = "TestPassword123!";
const confirmation = "TestPassword124!";
expect(passwordsMatch(password, confirmation)).toBe(false);
});
});
describe("Password Attack Scenarios", () => {
it("should resist brute force attacks with bcrypt slowness", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
// Measure time for multiple checks (simulating brute force)
const start = performance.now();
const attempts = 10;
for (let i = 0; i < attempts; i++) {
await checkPassword(`attempt${i}`, hash);
}
const duration = performance.now() - start;
const avgPerAttempt = duration / attempts;
// Each attempt should take significant time (bcrypt is slow)
// This makes brute force impractical
expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt
});
it("should prevent rainbow table attacks with unique salts", async () => {
const password = "CommonPassword123!";
// Generate multiple hashes for same password
const hashes = await Promise.all(
Array.from({ length: 10 }, () => hashPassword(password))
);
// All hashes should be unique (different salts)
const uniqueHashes = new Set(hashes);
expect(uniqueHashes.size).toBe(10);
});
it("should prevent password spraying with validation", () => {
// Common passwords that should be rejected
const commonPasswords = [
"Password123!",
"Welcome123!",
"Admin123!@#",
"Letmein123!"
];
for (const password of commonPasswords) {
const result = validatePassword(password);
expect(result.isValid).toBe(false);
}
});
it("should resist dictionary attacks", () => {
// Dictionary words that should be caught
const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"];
for (const password of dictionaryBased) {
const result = validatePassword(password);
expect(result.isValid).toBe(false);
}
});
});
describe("Edge Cases", () => {
it("should handle very long passwords", async () => {
const longPassword = "A1!a" + "x".repeat(1000); // Very long but valid
const hash = await hashPassword(longPassword);
const match = await checkPassword(longPassword, hash);
expect(match).toBe(true);
});
it("should handle passwords with only whitespace", async () => {
const whitespacePassword = " ";
const result = validatePassword(whitespacePassword);
expect(result.isValid).toBe(false);
});
it("should handle null bytes in passwords", async () => {
const nullBytePassword = "Test\0Password123!";
const hash = await hashPassword(nullBytePassword);
const match = await checkPassword(nullBytePassword, hash);
// Behavior may vary - just ensure no crash
expect(typeof match).toBe("boolean");
});
it("should handle passwords with emoji", () => {
const emojiPassword = "MyP@ssw0rd🔐🛡123";
const result = validatePassword(emojiPassword);
expect(result.isValid).toBe(true);
});
it("should handle passwords with newlines", async () => {
const newlinePassword = "Test\nPassword123!";
const hash = await hashPassword(newlinePassword);
const match = await checkPassword(newlinePassword, hash);
expect(match).toBe(true);
});
it("should handle passwords with tabs", async () => {
const tabPassword = "Test\tPassword123!";
const hash = await hashPassword(tabPassword);
const match = await checkPassword(tabPassword, hash);
expect(match).toBe(true);
});
});
describe("Performance", () => {
it("should hash passwords with appropriate slowness", async () => {
const password = "TestPassword123!";
const start = performance.now();
await hashPassword(password);
const duration = performance.now() - start;
// Bcrypt should be slow enough to deter brute force
// With 10 rounds, should take at least a few milliseconds
expect(duration).toBeGreaterThan(5);
// But not too slow for normal operation
expect(duration).toBeLessThan(500);
});
it("should verify passwords with consistent timing", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
const durations: number[] = [];
for (let i = 0; i < 5; i++) {
const start = performance.now();
await checkPassword(password, hash);
durations.push(performance.now() - start);
}
// Timing should be relatively consistent
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg)));
// Allow reasonable variance
expect(maxDeviation).toBeLessThan(avg * 0.5);
});
it("should validate passwords quickly", () => {
const password = "TestPassword123!";
const start = performance.now();
for (let i = 0; i < 10000; i++) {
validatePassword(password);
}
const duration = performance.now() - start;
// Validation is CPU-bound but should be fast
expect(duration).toBeLessThan(100);
});
});
describe("bcrypt Salt Rounds", () => {
it("should use appropriate salt rounds for security", async () => {
const password = "TestPassword123!";
const hash = await hashPassword(password);
// Check that hash uses correct salt rounds
// Bcrypt format: $2b$rounds$salthash
const parts = hash.split("$");
const rounds = parseInt(parts[2]);
// Should use 10 rounds (from password.ts)
expect(rounds).toBe(10);
});
it("should generate cryptographically random salts", async () => {
const password = "TestPassword123!";
const hashes = await Promise.all(
Array.from({ length: 100 }, () => hashPassword(password))
);
// Extract salts from hashes
const salts = hashes.map((hash) => {
const parts = hash.split("$");
return parts[3].substring(0, 22); // Salt is 22 characters
});
// All salts should be unique
const uniqueSalts = new Set(salts);
expect(uniqueSalts.size).toBe(100);
// Check for patterns in salts (should be random)
for (let i = 1; i < salts.length; i++) {
// Salts should not be sequential or predictable
expect(salts[i]).not.toBe(salts[i - 1]);
}
});
});
});

View File

@@ -0,0 +1,443 @@
/**
* Rate Limiting Tests
* Tests for rate limiting mechanisms on authentication endpoints
*/
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import {
checkRateLimit,
getClientIP,
rateLimitLogin,
rateLimitPasswordReset,
rateLimitRegistration,
rateLimitEmailVerification,
clearRateLimitStore,
RATE_LIMITS
} from "~/server/security";
import { createMockEvent, randomIP } from "./test-utils";
import { TRPCError } from "@trpc/server";
describe("Rate Limiting", () => {
// Clear rate limit store before each test to ensure isolation
beforeEach(() => {
clearRateLimitStore();
});
describe("checkRateLimit", () => {
it("should allow requests within rate limit", () => {
const identifier = `test-${Date.now()}`;
const maxAttempts = 5;
const windowMs = 60000;
for (let i = 0; i < maxAttempts; i++) {
const remaining = checkRateLimit(identifier, maxAttempts, windowMs);
expect(remaining).toBe(maxAttempts - i - 1);
}
});
it("should block requests exceeding rate limit", () => {
const identifier = `test-${Date.now()}`;
const maxAttempts = 3;
const windowMs = 60000;
// Use up all attempts
for (let i = 0; i < maxAttempts; i++) {
checkRateLimit(identifier, maxAttempts, windowMs);
}
// Next attempt should throw
expect(() => {
checkRateLimit(identifier, maxAttempts, windowMs);
}).toThrow(TRPCError);
});
it("should include remaining time in error message", () => {
const identifier = `test-${Date.now()}`;
const maxAttempts = 2;
const windowMs = 60000;
// Use up all attempts
checkRateLimit(identifier, maxAttempts, windowMs);
checkRateLimit(identifier, maxAttempts, windowMs);
try {
checkRateLimit(identifier, maxAttempts, windowMs);
expect.unreachable("Should have thrown TRPCError");
} catch (error) {
expect(error).toBeInstanceOf(TRPCError);
const trpcError = error as TRPCError;
expect(trpcError.code).toBe("TOO_MANY_REQUESTS");
expect(trpcError.message).toMatch(/Try again in \d+ seconds/);
}
});
it("should reset after time window expires", async () => {
const identifier = `test-${Date.now()}`;
const maxAttempts = 3;
const windowMs = 100; // 100ms window for fast testing
// Use up all attempts
for (let i = 0; i < maxAttempts; i++) {
checkRateLimit(identifier, maxAttempts, windowMs);
}
// Should be blocked
expect(() => {
checkRateLimit(identifier, maxAttempts, windowMs);
}).toThrow(TRPCError);
// Wait for window to expire
await new Promise((resolve) => setTimeout(resolve, 150));
// Should be allowed again
const remaining = checkRateLimit(identifier, maxAttempts, windowMs);
expect(remaining).toBe(maxAttempts - 1);
});
it("should handle concurrent requests correctly", () => {
const identifier = `test-${Date.now()}`;
const maxAttempts = 10;
const windowMs = 60000;
// Simulate concurrent requests
const results: number[] = [];
for (let i = 0; i < maxAttempts; i++) {
results.push(checkRateLimit(identifier, maxAttempts, windowMs));
}
// All should succeed with decreasing remaining counts
expect(results).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
});
it("should isolate different identifiers", () => {
const maxAttempts = 3;
const windowMs = 60000;
const id1 = `test1-${Date.now()}`;
const id2 = `test2-${Date.now()}`;
// Use up attempts for id1
for (let i = 0; i < maxAttempts; i++) {
checkRateLimit(id1, maxAttempts, windowMs);
}
// id1 should be blocked
expect(() => {
checkRateLimit(id1, maxAttempts, windowMs);
}).toThrow(TRPCError);
// id2 should still work
const remaining = checkRateLimit(id2, maxAttempts, windowMs);
expect(remaining).toBe(maxAttempts - 1);
});
});
describe("getClientIP", () => {
it("should extract IP from x-forwarded-for header", () => {
const event = createMockEvent({
headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" }
});
const ip = getClientIP(event);
expect(ip).toBe("192.168.1.1");
});
it("should extract IP from x-real-ip header", () => {
const event = createMockEvent({
headers: { "x-real-ip": "192.168.1.2" }
});
const ip = getClientIP(event);
expect(ip).toBe("192.168.1.2");
});
it("should prefer x-forwarded-for over x-real-ip", () => {
const event = createMockEvent({
headers: {
"x-forwarded-for": "192.168.1.1",
"x-real-ip": "192.168.1.2"
}
});
const ip = getClientIP(event);
expect(ip).toBe("192.168.1.1");
});
it("should return unknown when no IP headers present", () => {
const event = createMockEvent({});
const ip = getClientIP(event);
expect(ip).toBe("unknown");
});
it("should trim whitespace from IP addresses", () => {
const event = createMockEvent({
headers: { "x-forwarded-for": " 192.168.1.1 , 10.0.0.1" }
});
const ip = getClientIP(event);
expect(ip).toBe("192.168.1.1");
});
it("should handle IPv6 addresses", () => {
const event = createMockEvent({
headers: {
"x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
}
});
const ip = getClientIP(event);
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
});
});
describe("rateLimitLogin", () => {
it("should enforce both IP and email rate limits", () => {
const ip = randomIP();
// Should allow up to LOGIN_IP max attempts (5) with different emails
// Use different emails to avoid hitting email rate limit
for (let i = 0; i < RATE_LIMITS.LOGIN_IP.maxAttempts; i++) {
const email = `test-${Date.now()}-${i}@example.com`;
rateLimitLogin(email, ip);
}
// Next attempt should fail due to IP limit
expect(() => {
const email = `test-${Date.now()}-final@example.com`;
rateLimitLogin(email, ip);
}).toThrow(TRPCError);
});
it("should limit by email independently of IP", () => {
const email = `test-${Date.now()}@example.com`;
// Use different IPs but same email
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
rateLimitLogin(email, randomIP());
}
// Next attempt with different IP should still fail due to email limit
expect(() => {
rateLimitLogin(email, randomIP());
}).toThrow(TRPCError);
});
it("should allow different emails from same IP within IP limit", () => {
const ip = randomIP();
// Use different emails but same IP
for (let i = 0; i < RATE_LIMITS.LOGIN_IP.maxAttempts; i++) {
const email = `test${i}-${Date.now()}@example.com`;
rateLimitLogin(email, ip);
}
// Next attempt should fail due to IP limit
expect(() => {
rateLimitLogin(`new-${Date.now()}@example.com`, ip);
}).toThrow(TRPCError);
});
});
describe("rateLimitPasswordReset", () => {
it("should enforce password reset rate limit", () => {
const ip = randomIP();
// Should allow up to PASSWORD_RESET_IP max attempts (3)
for (let i = 0; i < RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts; i++) {
rateLimitPasswordReset(ip);
}
// Next attempt should fail
expect(() => {
rateLimitPasswordReset(ip);
}).toThrow(TRPCError);
});
it("should isolate password reset limits from login limits", () => {
const ip = randomIP();
const email = `test-${Date.now()}@example.com`;
// Use up password reset limit
for (let i = 0; i < RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts; i++) {
rateLimitPasswordReset(ip);
}
// Should still be able to login (different limit)
rateLimitLogin(email, ip);
});
});
describe("rateLimitRegistration", () => {
it("should enforce registration rate limit", () => {
const ip = randomIP();
// Should allow up to REGISTRATION_IP max attempts (3)
for (let i = 0; i < RATE_LIMITS.REGISTRATION_IP.maxAttempts; i++) {
rateLimitRegistration(ip);
}
// Next attempt should fail
expect(() => {
rateLimitRegistration(ip);
}).toThrow(TRPCError);
});
});
describe("rateLimitEmailVerification", () => {
it("should enforce email verification rate limit", () => {
const ip = randomIP();
// Should allow up to EMAIL_VERIFICATION_IP max attempts (5)
for (let i = 0; i < RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts; i++) {
rateLimitEmailVerification(ip);
}
// Next attempt should fail
expect(() => {
rateLimitEmailVerification(ip);
}).toThrow(TRPCError);
});
});
describe("Rate Limit Attack Scenarios", () => {
it("should prevent brute force login attacks", () => {
const email = "victim@example.com";
const attackerIP = "1.2.3.4";
// Simulate brute force attack
let blockedAtAttempt = 0;
for (let i = 0; i < 10; i++) {
try {
rateLimitLogin(email, attackerIP);
} catch (error) {
if (error instanceof TRPCError) {
blockedAtAttempt = i;
break;
}
}
}
// Should be blocked before 10 attempts
expect(blockedAtAttempt).toBeLessThan(10);
expect(blockedAtAttempt).toBeGreaterThan(0);
});
it("should prevent distributed brute force from multiple IPs", () => {
const email = "victim@example.com";
// Simulate distributed attack from different IPs
let blockedAtAttempt = 0;
for (let i = 0; i < 10; i++) {
try {
rateLimitLogin(email, randomIP());
} catch (error) {
if (error instanceof TRPCError) {
blockedAtAttempt = i;
break;
}
}
}
// Should be blocked at email limit (3 attempts)
expect(blockedAtAttempt).toBeLessThanOrEqual(
RATE_LIMITS.LOGIN_EMAIL.maxAttempts
);
});
it("should prevent account enumeration via registration spam", () => {
const attackerIP = randomIP();
// Try to register many accounts to enumerate valid emails
let blockedAtAttempt = 0;
for (let i = 0; i < 10; i++) {
try {
rateLimitRegistration(attackerIP);
} catch (error) {
if (error instanceof TRPCError) {
blockedAtAttempt = i;
break;
}
}
}
// Should be blocked at registration limit (3 attempts)
expect(blockedAtAttempt).toBe(RATE_LIMITS.REGISTRATION_IP.maxAttempts);
});
it("should prevent password reset spam attacks", () => {
const attackerIP = randomIP();
// Try to spam password resets
let blockedAtAttempt = 0;
for (let i = 0; i < 10; i++) {
try {
rateLimitPasswordReset(attackerIP);
} catch (error) {
if (error instanceof TRPCError) {
blockedAtAttempt = i;
break;
}
}
}
// Should be blocked at password reset limit (3 attempts)
expect(blockedAtAttempt).toBe(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts);
});
});
describe("Rate Limit Configuration", () => {
it("should have reasonable limits configured", () => {
// Login should be more permissive than registration
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(
RATE_LIMITS.REGISTRATION_IP.maxAttempts
);
// All limits should be positive
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0);
expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0);
expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0);
expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0);
expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts).toBeGreaterThan(0);
// All windows should be at least 1 minute
expect(RATE_LIMITS.LOGIN_IP.windowMs).toBeGreaterThanOrEqual(60000);
expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000);
expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual(
60000
);
expect(RATE_LIMITS.REGISTRATION_IP.windowMs).toBeGreaterThanOrEqual(
60000
);
expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs).toBeGreaterThanOrEqual(
60000
);
});
});
describe("Performance", () => {
it("should handle high volume of rate limit checks efficiently", () => {
const start = performance.now();
// Check 1000 different identifiers
for (let i = 0; i < 1000; i++) {
checkRateLimit(`test-${i}`, 5, 60000);
}
const duration = performance.now() - start;
// Should complete in less than 100ms
expect(duration).toBeLessThan(100);
});
it("should not leak memory with many identifiers", () => {
// Create many rate limit entries
for (let i = 0; i < 10000; i++) {
checkRateLimit(`test-${i}`, 5, 60000);
}
// This test mainly ensures no crashes occur
// Memory cleanup is tested by the cleanup interval in security.ts
expect(true).toBe(true);
});
});
});

View File

@@ -0,0 +1,188 @@
/**
* Security Test Utilities
* Shared helpers for security-related tests
*/
import type { H3Event } from "vinxi/http";
import { SignJWT } from "jose";
import { env } from "~/env/server";
/**
* Create a mock H3Event for testing
* Creates a minimal structure that works with our cookie/header fallback logic
*/
export function createMockEvent(options: {
headers?: Record<string, string>;
cookies?: Record<string, string>;
method?: string;
url?: string;
}): H3Event {
const {
headers = {},
cookies = {},
method = "POST",
url = "http://localhost:3000/"
} = options;
const cookieString = Object.entries(cookies)
.map(([key, value]) => `${key}=${value}`)
.join("; ");
const allHeaders = {
...headers,
...(cookieString ? { cookie: cookieString } : {})
};
// Try to create Headers object, fall back to plain object if headers contain invalid values
let headersObj: Headers | Record<string, string>;
try {
headersObj = new Headers(allHeaders);
} catch (e) {
// If Headers constructor fails (e.g., unicode in headers), use plain object
headersObj = allHeaders;
}
// Create mock event with headers accessible via .headers.get() and .node.req.headers
const mockEvent = {
headers: headersObj,
node: {
req: {
headers: allHeaders
},
res: {
cookies: {}
}
}
} as unknown as H3Event;
return mockEvent;
}
/**
* Generate a valid JWT token for testing
*/
export async function createTestJWT(
userId: string,
expiresIn: string = "1h"
): Promise<string> {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
return await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(expiresIn)
.sign(secret);
}
/**
* Generate an expired JWT token for testing
*/
export async function createExpiredJWT(userId: string): Promise<string> {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
return await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("-1h") // Expired 1 hour ago
.sign(secret);
}
/**
* Generate a JWT with invalid signature
*/
export async function createInvalidSignatureJWT(
userId: string
): Promise<string> {
const wrongSecret = new TextEncoder().encode("wrong-secret-key");
return await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(wrongSecret);
}
/**
* Generate test credentials
*/
export function createTestCredentials() {
return {
email: `test-${Date.now()}@example.com`,
password: "TestPass123!@#",
passwordConfirmation: "TestPass123!@#"
};
}
/**
* Common SQL injection payloads
*/
export const SQL_INJECTION_PAYLOADS = [
"' OR '1'='1",
"'; DROP TABLE User; --",
"admin'--",
"' UNION SELECT * FROM User--",
"1' OR 1=1--",
"' OR 'x'='x",
"1; DELETE FROM User WHERE 1=1--",
"' AND 1=0 UNION ALL SELECT * FROM User--"
];
/**
* Common XSS payloads
*/
export const XSS_PAYLOADS = [
"<script>alert('XSS')</script>",
"<img src=x onerror=alert('XSS')>",
"javascript:alert('XSS')",
"<svg onload=alert('XSS')>",
"<iframe src='javascript:alert(\"XSS\")'></iframe>",
"<body onload=alert('XSS')>",
"<input onfocus=alert('XSS') autofocus>"
];
/**
* Wait for async operations with timeout
*/
export async function waitFor(
condition: () => boolean | Promise<boolean>,
timeout: number = 5000,
interval: number = 100
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (await condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
}
/**
* Measure execution time
*/
export async function measureTime<T>(
fn: () => Promise<T>
): Promise<{ result: T; duration: number }> {
const start = Date.now();
const result = await fn();
const duration = Date.now() - start;
return { result, duration };
}
/**
* Generate random string for testing
*/
export function randomString(length: number = 10): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
return Array.from(
{ length },
() => chars[Math.floor(Math.random() * chars.length)]
).join("");
}
/**
* Generate random IP address
*/
export function randomIP(): string {
return Array.from({ length: 4 }, () => Math.floor(Math.random() * 256)).join(
"."
);
}

View File

@@ -14,6 +14,6 @@ export {
getUserBasicInfo getUserBasicInfo
} from "./database"; } from "./database";
export { hashPassword, checkPassword } from "./password"; export { hashPassword, checkPassword, checkPasswordSafe } from "./password";
export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email"; export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email";