From f6d66c3650c60347c4c86ae45be1eda21d6c2115 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Sat, 24 Jan 2026 18:45:00 +0000 Subject: [PATCH] feat(security): add phase8 hardening with rate limits, audit logs, and auth-first simulator flow --- Backend/db/schema.ts | 13 +++ Backend/drizzle/0011_security_audit_logs.sql | 14 +++ Backend/drizzle/meta/_journal.json | 7 ++ Backend/index.ts | 26 +++++- Backend/middleware/security.ts | 31 +++++++ Backend/public/mobile-sim.html | 98 ++++++++++++++++++++ Backend/routes/audit.ts | 66 +++++++++++++ Backend/routes/events.ts | 21 +++++ Backend/routes/recordings.ts | 21 +++++ Backend/routes/streams.ts | 41 ++++++++ Backend/services/audit.ts | 22 +++++ 11 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 Backend/drizzle/0011_security_audit_logs.sql create mode 100644 Backend/middleware/security.ts create mode 100644 Backend/routes/audit.ts create mode 100644 Backend/services/audit.ts diff --git a/Backend/db/schema.ts b/Backend/db/schema.ts index 0216851..b43e6e0 100644 --- a/Backend/db/schema.ts +++ b/Backend/db/schema.ts @@ -150,6 +150,18 @@ export const pushNotifications = pgTable('push_notifications', { updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }); +export const auditLogs = pgTable('audit_logs', { + id: uuid('id').defaultRandom().primaryKey(), + ownerUserId: uuid('owner_user_id').notNull().references(() => users.id), + actorDeviceId: uuid('actor_device_id').references(() => devices.id), + action: varchar('action', { length: 128 }).notNull(), + targetType: varchar('target_type', { length: 64 }).notNull(), + targetId: varchar('target_id', { length: 255 }).notNull(), + metadata: jsonb('metadata').$type | null>().default(null), + ipAddress: text('ip_address'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + export const accounts = pgTable('account', { id: uuid('id').defaultRandom().primaryKey(), userId: uuid('user_id').notNull().references(() => users.id), @@ -197,6 +209,7 @@ export const schema = { videos, notifications, pushNotifications, + auditLogs, accounts, sessions, verifications, diff --git a/Backend/drizzle/0011_security_audit_logs.sql b/Backend/drizzle/0011_security_audit_logs.sql new file mode 100644 index 0000000..6648ee7 --- /dev/null +++ b/Backend/drizzle/0011_security_audit_logs.sql @@ -0,0 +1,14 @@ +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "owner_user_id" uuid NOT NULL, + "actor_device_id" uuid, + "action" varchar(128) NOT NULL, + "target_type" varchar(64) NOT NULL, + "target_id" varchar(255) NOT NULL, + "metadata" jsonb DEFAULT 'null'::jsonb, + "ip_address" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_device_id_devices_id_fk" FOREIGN KEY ("actor_device_id") REFERENCES "public"."devices"("id") ON DELETE no action ON UPDATE no action; diff --git a/Backend/drizzle/meta/_journal.json b/Backend/drizzle/meta/_journal.json index d851cfa..893a40c 100644 --- a/Backend/drizzle/meta/_journal.json +++ b/Backend/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1770417956419, "tag": "0010_push_notifications_queue", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1770418956419, + "tag": "0011_security_audit_logs", + "breakpoints": true } ] } diff --git a/Backend/index.ts b/Backend/index.ts index f7edd61..541138a 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -1,6 +1,8 @@ import express from 'express'; import { createServer } from 'http'; import { toNodeHandler } from 'better-auth/node'; +import cors from 'cors'; +import helmet from 'helmet'; import swaggerUi from 'swagger-ui-express'; import { auth } from './auth'; @@ -14,6 +16,8 @@ import eventsRoutes from './routes/events'; import streamsRoutes from './routes/streams'; import recordingsRoutes from './routes/recordings'; import pushNotificationsRoutes from './routes/push-notifications'; +import auditRoutes from './routes/audit'; +import { rateLimit } from './middleware/security'; import { setupRealtimeGateway } from './realtime/gateway'; import { ensureMinioBucket } from './utils/minio'; import { startRecordingsWorker } from './workers/recordings'; @@ -21,6 +25,9 @@ import { startPushWorker } from './services/push'; const app = express(); const openApiDocument = buildOpenApiDocument(); +const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS + ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean) + : []; app.get('/', (_req, res) => { res.send('API is running'); @@ -34,17 +41,26 @@ app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument)); app.all('/api/auth/*splat', toNodeHandler(auth)); +app.use(helmet()); +app.use( + cors({ + origin: trustedOrigins.length > 0 ? trustedOrigins : true, + credentials: true, + }), +); +app.use(rateLimit({ keyPrefix: 'global', windowMs: 60_000, max: 400 })); app.use(express.json()); app.use('/sim', express.static('public')); app.use('/videos', videosRoutes); app.use('/admin', adminRoutes); -app.use('/devices', devicesRoutes); +app.use('/devices', rateLimit({ keyPrefix: 'devices', windowMs: 60_000, max: 120 }), devicesRoutes); app.use('/device-links', deviceLinksRoutes); -app.use('/commands', commandsRoutes); -app.use('/events', eventsRoutes); -app.use('/streams', streamsRoutes); -app.use('/recordings', recordingsRoutes); +app.use('/commands', rateLimit({ keyPrefix: 'commands', windowMs: 60_000, max: 120 }), commandsRoutes); +app.use('/events', rateLimit({ keyPrefix: 'events', windowMs: 60_000, max: 120 }), eventsRoutes); +app.use('/streams', rateLimit({ keyPrefix: 'streams', windowMs: 60_000, max: 120 }), streamsRoutes); +app.use('/recordings', rateLimit({ keyPrefix: 'recordings', windowMs: 60_000, max: 120 }), recordingsRoutes); app.use('/push-notifications', pushNotificationsRoutes); +app.use('/audit', auditRoutes); app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(err); diff --git a/Backend/middleware/security.ts b/Backend/middleware/security.ts new file mode 100644 index 0000000..519ff2e --- /dev/null +++ b/Backend/middleware/security.ts @@ -0,0 +1,31 @@ +import type { NextFunction, Request, Response } from 'express'; + +type Bucket = { + count: number; + windowStart: number; +}; + +const buckets = new Map(); + +export const rateLimit = (options: { keyPrefix: string; windowMs: number; max: number }) => { + return (req: Request, res: Response, next: NextFunction): void => { + const key = `${options.keyPrefix}:${req.ip ?? 'unknown'}`; + const now = Date.now(); + const current = buckets.get(key); + + if (!current || now - current.windowStart > options.windowMs) { + buckets.set(key, { count: 1, windowStart: now }); + next(); + return; + } + + if (current.count >= options.max) { + res.status(429).json({ message: 'Rate limit exceeded. Try again later.' }); + return; + } + + current.count += 1; + buckets.set(key, current); + next(); + }; +}; diff --git a/Backend/public/mobile-sim.html b/Backend/public/mobile-sim.html index d9b429f..3d48dd5 100644 --- a/Backend/public/mobile-sim.html +++ b/Backend/public/mobile-sim.html @@ -123,6 +123,25 @@

+
+

Auth

+ + + + + + +
+ + +
+
+ + +
+

+        
+

Device Bootstrap

@@ -186,6 +205,7 @@