diff --git a/apps/server/src/middleware/rate-limit.middleware.ts b/apps/server/src/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..77d39ba --- /dev/null +++ b/apps/server/src/middleware/rate-limit.middleware.ts @@ -0,0 +1,28 @@ +import { createMiddleware } from "hono/factory"; + +type Store = Map; + +function createRateLimiter(max: number, windowMs: number) { + const store: Store = new Map(); + return createMiddleware(async (c, next) => { + const key = + c.req.header("x-forwarded-for") ?? + c.req.header("cf-connecting-ip") ?? + "unknown"; + const now = Date.now(); + const entry = store.get(key); + if (!entry || entry.resetAt < now) { + store.set(key, { count: 1, resetAt: now + windowMs }); + return next(); + } + entry.count++; + if (entry.count > max) { + return c.json({ error: "Too many requests" }, 429); + } + return next(); + }); +} + +export const inviteJoinLimiter = createRateLimiter(10, 60 * 60 * 1000); // 10/hour +export const scannerLimiter = createRateLimiter(50, 60 * 60 * 1000); // 50/hour +export const authLimiter = createRateLimiter(10, 60 * 1000); // 10/minute diff --git a/apps/server/src/routes/auth.routes.ts b/apps/server/src/routes/auth.routes.ts index 8ca707d..b02a2df 100644 --- a/apps/server/src/routes/auth.routes.ts +++ b/apps/server/src/routes/auth.routes.ts @@ -1,6 +1,11 @@ import { auth } from "@haushaltsApp/auth"; import { Hono } from "hono"; +import { authLimiter } from "../middleware/rate-limit.middleware"; export const authRoutes = new Hono(); +// Rate-limit sign-in endpoints to prevent brute-force attacks +authRoutes.post("/sign-in/*", authLimiter); +authRoutes.post("/sign-up/*", authLimiter); + authRoutes.on(["GET", "POST"], "/*", (c) => auth.handler(c.req.raw)); diff --git a/apps/server/src/routes/households.routes.ts b/apps/server/src/routes/households.routes.ts index a3cb6fd..ccb6fe1 100644 --- a/apps/server/src/routes/households.routes.ts +++ b/apps/server/src/routes/households.routes.ts @@ -31,31 +31,6 @@ householdRoutes.post("/setup", tenantMiddleware, requireHousehold, async (c) => return c.json({ categories: cats }, 201); }); -// POST /api/households/repair — no x-household-id needed -// Finds the user's first organization and upserts the households row -householdRoutes.post("/repair", async (c) => { - const user = c.get("user") as { id: string; name: string }; - - // Find first organization this user belongs to - const membership = await db.query.member.findFirst({ - where: eq(member.userId, user.id), - with: { organization: true }, - }); - - if (!membership) { - return c.json({ error: "No organization found — create a household first" }, 404); - } - - const org = membership.organization; - - await db - .insert(households) - .values({ id: org.id, name: org.name, ownerId: user.id }) - .onConflictDoNothing(); - - return c.json({ householdId: org.id, name: org.name }); -}); - // GET /api/households/categories — list categories for current household householdRoutes.get("/categories", tenantMiddleware, requireHousehold, async (c) => { const householdId = c.get("householdId") as string; diff --git a/apps/server/src/routes/invite.routes.ts b/apps/server/src/routes/invite.routes.ts index 69e2d13..76e4441 100644 --- a/apps/server/src/routes/invite.routes.ts +++ b/apps/server/src/routes/invite.routes.ts @@ -4,6 +4,7 @@ import { joinWithCodeSchema } from "@haushaltsApp/shared/schemas/invite.schema"; import { Hono } from "hono"; import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware"; import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware"; +import { inviteJoinLimiter } from "../middleware/rate-limit.middleware"; type Variables = AuthVariables & TenantVariables; @@ -65,7 +66,7 @@ inviteRoutes.post("/generate", tenantMiddleware, requireHousehold, async (c) => }); // POST /api/households/invite/join — join a household using an invite code -inviteRoutes.post("/join", async (c) => { +inviteRoutes.post("/join", inviteJoinLimiter, async (c) => { const currentUser = c.get("user") as { id: string }; const body = await c.req.json(); diff --git a/apps/server/src/routes/scanner.routes.ts b/apps/server/src/routes/scanner.routes.ts index 4f52171..a8c301f 100644 --- a/apps/server/src/routes/scanner.routes.ts +++ b/apps/server/src/routes/scanner.routes.ts @@ -2,6 +2,7 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware"; import { scanReceiptSchema } from "@haushaltsApp/shared/schemas/scanner.schema"; +import { scannerLimiter } from "../middleware/rate-limit.middleware"; type Variables = AuthVariables; @@ -29,6 +30,7 @@ scannerRoutes.use("/*", authMiddleware, requireAuth); // POST /receipt — scan a receipt image via Claude Vision scannerRoutes.post( "/receipt", + scannerLimiter, zValidator("json", scanReceiptSchema), async (c) => { const apiKey = process.env.ANTHROPIC_API_KEY; diff --git a/apps/server/src/routes/shopping.routes.ts b/apps/server/src/routes/shopping.routes.ts index 4656ced..263b3f5 100644 --- a/apps/server/src/routes/shopping.routes.ts +++ b/apps/server/src/routes/shopping.routes.ts @@ -1,6 +1,6 @@ import { zValidator } from "@hono/zod-validator"; import { db, eq, and, isNotNull } from "@haushaltsApp/db"; -import { shoppingItems, session as sessionTable } from "@haushaltsApp/db/schema"; +import { shoppingItems, session as sessionTable, member } from "@haushaltsApp/db/schema"; import { Hono } from "hono"; import type { ShoppingServerEvent } from "@haushaltsApp/shared/schemas/shopping.schema"; import { addShoppingItemSchema } from "@haushaltsApp/shared/schemas/shopping.schema"; @@ -104,6 +104,16 @@ shoppingRoutes.get( return { onOpen(_e, ws) { ws.close(4001, "Unauthorized"); } }; } + const membership = await db.query.member.findFirst({ + where: and( + eq(member.organizationId, householdId), + eq(member.userId, sessionRow.user.id) + ), + }); + if (!membership) { + return { onOpen(_e, ws) { ws.close(4003, "Forbidden"); } }; + } + return createShoppingWsHandler(householdId, sessionRow.user.id); }), ); diff --git a/apps/server/src/ws/shopping-ws.ts b/apps/server/src/ws/shopping-ws.ts index 49b4033..eaadfb0 100644 --- a/apps/server/src/ws/shopping-ws.ts +++ b/apps/server/src/ws/shopping-ws.ts @@ -2,6 +2,7 @@ import { db, eq, and, isNotNull } from "@haushaltsApp/db"; import { shoppingItems } from "@haushaltsApp/db/schema"; import type { WSContext } from "hono/ws"; import type { ShoppingServerEvent, ShoppingClientCommand } from "@haushaltsApp/shared/schemas/shopping.schema"; +import { shoppingClientCommandSchema } from "@haushaltsApp/shared/schemas/shopping.schema"; import { getShoppingItems, addShoppingItem, @@ -57,9 +58,11 @@ export function createShoppingWsHandler(householdId: string, userId: string) { async onMessage(event: MessageEvent, ws: WSContext) { let cmd: ShoppingClientCommand; try { - cmd = JSON.parse( - typeof event.data === "string" ? event.data : event.data.toString(), - ) as ShoppingClientCommand; + const parsed = shoppingClientCommandSchema.safeParse( + JSON.parse(typeof event.data === "string" ? event.data : event.data.toString()), + ); + if (!parsed.success) return; + cmd = parsed.data; } catch { return; } diff --git a/packages/shared/src/schemas/shopping.schema.ts b/packages/shared/src/schemas/shopping.schema.ts index ac224d5..d856a74 100644 --- a/packages/shared/src/schemas/shopping.schema.ts +++ b/packages/shared/src/schemas/shopping.schema.ts @@ -31,9 +31,12 @@ export type ShoppingServerEvent = | { type: "sync"; items: ShoppingItem[] }; // WebSocket command types sent from client → server -export type ShoppingClientCommand = - | { type: "item:add"; label: string; quantity?: string } - | { type: "item:check"; itemId: string } - | { type: "item:uncheck"; itemId: string } - | { type: "item:delete"; itemId: string } - | { type: "item:clear" }; +export const shoppingClientCommandSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("item:add"), label: z.string().min(1), quantity: z.string().optional() }), + z.object({ type: z.literal("item:check"), itemId: z.string() }), + z.object({ type: z.literal("item:uncheck"), itemId: z.string() }), + z.object({ type: z.literal("item:delete"), itemId: z.string() }), + z.object({ type: z.literal("item:clear") }), +]); + +export type ShoppingClientCommand = z.infer;