Skip to content

Commit faf4175

Browse files
committed
security: audit rate limits, fix IDOR on deleteMemory, clean up phantom reads
Three-vulnerability audit based on common automated security mistakes: 1. RATE LIMITING - every route now has explicit per-route limits: Memory writes: 30/min, reads: 30-60/min, restore: 10/min, whoami/chat-models: 30/min, Stripe webhook: 100 to 30/min. 2. IDOR FIX - deleteMemory now verifies user_id ownership BEFORE deleting memory_versions. Previously an attacker could wipe another users version history by guessing a UUID. 3. ROW-LEVEL SECURITY - full audit confirmed all queries enforce WHERE user_id = ?. No cross-user data access possible. 4. MIGRATION 017 - resets phantom read counts for users with 0 writes and 0 memories (inflated by idle dashboard polling).
1 parent e43ef51 commit faf4175

3 files changed

Lines changed: 58 additions & 4 deletions

File tree

src/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,43 @@ if (!db.prepare(`SELECT 1 FROM _migrations WHERE name = ?`).get(adminPlanMigrati
513513
console.log("[migration] Set admin users to admin plan (unlimited)");
514514
}
515515

516+
const phantomReadsMigration = "017_reset_phantom_reads";
517+
if (!db.prepare(`SELECT 1 FROM _migrations WHERE name = ?`).get(phantomReadsMigration)) {
518+
// Users with 0 writes and 0 memories had all reads generated by idle
519+
// dashboard polling against empty accounts. Reset to accurate counts.
520+
const result = db.prepare(`
521+
UPDATE monthly_usage
522+
SET reads = 0, total_ops = writes + queries
523+
WHERE user_id IN (
524+
SELECT mu.user_id FROM monthly_usage mu
525+
JOIN users u ON u.id = mu.user_id
526+
WHERE mu.writes = 0
527+
AND (SELECT COUNT(*) FROM memories m WHERE m.user_id = mu.user_id AND m.deleted_at IS NULL) = 0
528+
AND mu.reads > 0
529+
)
530+
`).run();
531+
if (result.changes > 0) {
532+
console.log(`[migration] Reset phantom reads for ${result.changes} monthly_usage rows`);
533+
}
534+
// Also clean the usage_events table for accuracy
535+
const evtResult = db.prepare(`
536+
DELETE FROM usage_events
537+
WHERE operation = 'memory_read'
538+
AND user_id IN (
539+
SELECT u.id FROM users u
540+
WHERE (SELECT COUNT(*) FROM memories m WHERE m.user_id = u.id AND m.deleted_at IS NULL) = 0
541+
AND (SELECT COUNT(*) FROM usage_events ue WHERE ue.user_id = u.id AND ue.operation = 'memory_write') = 0
542+
)
543+
`).run();
544+
if (evtResult.changes > 0) {
545+
console.log(`[migration] Deleted ${evtResult.changes} phantom read events`);
546+
}
547+
db.prepare(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`).run(
548+
phantomReadsMigration,
549+
new Date().toISOString(),
550+
);
551+
}
552+
516553
const ownerEmail = optionalEnv("RM_OWNER_EMAIL", "").toLowerCase();
517554

518555
let userId: string;

src/memory-service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,13 @@ export function deleteMemory(
668668
memoryId: string,
669669
): boolean {
670670
const txn = db.transaction(() => {
671+
// Verify ownership BEFORE touching memory_versions to prevent
672+
// cross-user version deletion via guessed memory_id (IDOR).
673+
const owns = db
674+
.prepare(`SELECT 1 FROM memories WHERE id = ? AND user_id = ?`)
675+
.get(memoryId, userId);
676+
if (!owns) return false;
677+
671678
db.prepare(`DELETE FROM memory_versions WHERE memory_id = ?`).run(memoryId);
672679
const result = db
673680
.prepare(`DELETE FROM memories WHERE id = ? AND user_id = ?`)

src/server.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
835835
// No sensitive data. Just what the server sees for this key.
836836
// ===========================================================================
837837

838-
server.get("/whoami", async (request) => {
838+
server.get("/whoami", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (request) => {
839839
return {
840840
role: request.role,
841841
vendor: request.vendor,
@@ -1138,6 +1138,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
11381138
schema: {
11391139
body: memoryBodySchema,
11401140
},
1141+
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
11411142
},
11421143
async (request, reply) => {
11431144
const body = request.body as {
@@ -1185,6 +1186,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
11851186
schema: {
11861187
body: agentMemoryBodySchema,
11871188
},
1189+
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
11881190
},
11891191
async (request, reply) => {
11901192
const body = request.body as {
@@ -1232,7 +1234,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
12321234
// agent's own writes.
12331235
// ===========================================================================
12341236

1235-
server.get("/agent/memories/latest", async (request, reply) => {
1237+
server.get("/agent/memories/latest", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (request, reply) => {
12361238
const vendorFilter = request.vendor;
12371239
const query = request.query as { tag?: string; origin?: string };
12381240
const tag = query.tag?.trim();
@@ -1278,6 +1280,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
12781280
schema: {
12791281
params: memoryIdParamSchema,
12801282
},
1283+
config: { rateLimit: { max: 60, timeWindow: "1 minute" } },
12811284
},
12821285
async (request, reply) => {
12831286
const { id } = request.params as { id: string };
@@ -1328,6 +1331,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
13281331
},
13291332
},
13301333
},
1334+
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
13311335
},
13321336
async (request) => {
13331337
const { tags, limit, offset } = request.body as {
@@ -1381,6 +1385,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
13811385
schema: {
13821386
body: browseBodySchema,
13831387
},
1388+
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
13841389
},
13851390
async (request) => {
13861391
const { filter, limit, offset } = request.body as {
@@ -1419,6 +1424,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
14191424
schema: {
14201425
params: memoryIdParamSchema,
14211426
},
1427+
config: { rateLimit: { max: 60, timeWindow: "1 minute" } },
14221428
},
14231429
async (request, reply) => {
14241430
const { id } = request.params as { id: string };
@@ -1442,6 +1448,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
14421448
schema: {
14431449
body: listFilterBodySchema,
14441450
},
1451+
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
14451452
},
14461453
async (request) => {
14471454
const body = request.body as {
@@ -1478,6 +1485,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
14781485
params: memoryIdParamSchema,
14791486
body: updateMemoryBodySchema,
14801487
},
1488+
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
14811489
},
14821490
async (request, reply) => {
14831491
const { id } = request.params as { id: string };
@@ -1522,6 +1530,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
15221530
schema: {
15231531
params: memoryIdParamSchema,
15241532
},
1533+
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
15251534
},
15261535
async (request, reply) => {
15271536
const { id } = request.params as { id: string };
@@ -1544,6 +1553,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
15441553
schema: {
15451554
params: memoryIdParamSchema,
15461555
},
1556+
config: { rateLimit: { max: 10, timeWindow: "1 minute" } },
15471557
},
15481558
async (request, reply) => {
15491559
const { id } = request.params as { id: string };
@@ -1640,7 +1650,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
16401650
// populate the model selector.
16411651
// ===========================================================================
16421652

1643-
server.get("/chat/models", async () => {
1653+
server.get("/chat/models", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async () => {
16441654
const available = AVAILABLE_MODELS.filter((m) => {
16451655
switch (m.id) {
16461656
case "gpt-4o":
@@ -2304,7 +2314,7 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
23042314
"/webhooks/stripe",
23052315
{
23062316
config: {
2307-
rateLimit: { max: 100, timeWindow: "1 minute" },
2317+
rateLimit: { max: 30, timeWindow: "1 minute" },
23082318
rawBody: true,
23092319
},
23102320
},

0 commit comments

Comments
 (0)