security: WS membership check, rate limiting, Zod WS validation, remove /repair
- WebSocket upgrade now verifies user is member of the household (prevents cross-household access) - Rate limiting: invite/join 10/h, scanner 50/h, auth sign-in 10/min - WebSocket commands validated via Zod discriminatedUnion (no unsafe cast) - Removed /repair endpoint (dev artifact, bypassed tenant middleware) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
28
apps/server/src/middleware/rate-limit.middleware.ts
Normal file
28
apps/server/src/middleware/rate-limit.middleware.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { createMiddleware } from "hono/factory";
|
||||||
|
|
||||||
|
type Store = Map<string, { count: number; resetAt: number }>;
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { auth } from "@haushaltsApp/auth";
|
import { auth } from "@haushaltsApp/auth";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { authLimiter } from "../middleware/rate-limit.middleware";
|
||||||
|
|
||||||
export const authRoutes = new Hono();
|
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));
|
authRoutes.on(["GET", "POST"], "/*", (c) => auth.handler(c.req.raw));
|
||||||
|
|||||||
@@ -31,31 +31,6 @@ householdRoutes.post("/setup", tenantMiddleware, requireHousehold, async (c) =>
|
|||||||
return c.json({ categories: cats }, 201);
|
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
|
// GET /api/households/categories — list categories for current household
|
||||||
householdRoutes.get("/categories", tenantMiddleware, requireHousehold, async (c) => {
|
householdRoutes.get("/categories", tenantMiddleware, requireHousehold, async (c) => {
|
||||||
const householdId = c.get("householdId") as string;
|
const householdId = c.get("householdId") as string;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { joinWithCodeSchema } from "@haushaltsApp/shared/schemas/invite.schema";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||||
|
import { inviteJoinLimiter } from "../middleware/rate-limit.middleware";
|
||||||
|
|
||||||
type Variables = AuthVariables & TenantVariables;
|
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
|
// 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 currentUser = c.get("user") as { id: string };
|
||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { zValidator } from "@hono/zod-validator";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||||
import { scanReceiptSchema } from "@haushaltsApp/shared/schemas/scanner.schema";
|
import { scanReceiptSchema } from "@haushaltsApp/shared/schemas/scanner.schema";
|
||||||
|
import { scannerLimiter } from "../middleware/rate-limit.middleware";
|
||||||
|
|
||||||
type Variables = AuthVariables;
|
type Variables = AuthVariables;
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ scannerRoutes.use("/*", authMiddleware, requireAuth);
|
|||||||
// POST /receipt — scan a receipt image via Claude Vision
|
// POST /receipt — scan a receipt image via Claude Vision
|
||||||
scannerRoutes.post(
|
scannerRoutes.post(
|
||||||
"/receipt",
|
"/receipt",
|
||||||
|
scannerLimiter,
|
||||||
zValidator("json", scanReceiptSchema),
|
zValidator("json", scanReceiptSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { db, eq, and, isNotNull } from "@haushaltsApp/db";
|
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 { Hono } from "hono";
|
||||||
import type { ShoppingServerEvent } from "@haushaltsApp/shared/schemas/shopping.schema";
|
import type { ShoppingServerEvent } from "@haushaltsApp/shared/schemas/shopping.schema";
|
||||||
import { addShoppingItemSchema } 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"); } };
|
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);
|
return createShoppingWsHandler(householdId, sessionRow.user.id);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db, eq, and, isNotNull } from "@haushaltsApp/db";
|
|||||||
import { shoppingItems } from "@haushaltsApp/db/schema";
|
import { shoppingItems } from "@haushaltsApp/db/schema";
|
||||||
import type { WSContext } from "hono/ws";
|
import type { WSContext } from "hono/ws";
|
||||||
import type { ShoppingServerEvent, ShoppingClientCommand } from "@haushaltsApp/shared/schemas/shopping.schema";
|
import type { ShoppingServerEvent, ShoppingClientCommand } from "@haushaltsApp/shared/schemas/shopping.schema";
|
||||||
|
import { shoppingClientCommandSchema } from "@haushaltsApp/shared/schemas/shopping.schema";
|
||||||
import {
|
import {
|
||||||
getShoppingItems,
|
getShoppingItems,
|
||||||
addShoppingItem,
|
addShoppingItem,
|
||||||
@@ -57,9 +58,11 @@ export function createShoppingWsHandler(householdId: string, userId: string) {
|
|||||||
async onMessage(event: MessageEvent, ws: WSContext<WsData>) {
|
async onMessage(event: MessageEvent, ws: WSContext<WsData>) {
|
||||||
let cmd: ShoppingClientCommand;
|
let cmd: ShoppingClientCommand;
|
||||||
try {
|
try {
|
||||||
cmd = JSON.parse(
|
const parsed = shoppingClientCommandSchema.safeParse(
|
||||||
typeof event.data === "string" ? event.data : event.data.toString(),
|
JSON.parse(typeof event.data === "string" ? event.data : event.data.toString()),
|
||||||
) as ShoppingClientCommand;
|
);
|
||||||
|
if (!parsed.success) return;
|
||||||
|
cmd = parsed.data;
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ export type ShoppingServerEvent =
|
|||||||
| { type: "sync"; items: ShoppingItem[] };
|
| { type: "sync"; items: ShoppingItem[] };
|
||||||
|
|
||||||
// WebSocket command types sent from client → server
|
// WebSocket command types sent from client → server
|
||||||
export type ShoppingClientCommand =
|
export const shoppingClientCommandSchema = z.discriminatedUnion("type", [
|
||||||
| { type: "item:add"; label: string; quantity?: string }
|
z.object({ type: z.literal("item:add"), label: z.string().min(1), quantity: z.string().optional() }),
|
||||||
| { type: "item:check"; itemId: string }
|
z.object({ type: z.literal("item:check"), itemId: z.string() }),
|
||||||
| { type: "item:uncheck"; itemId: string }
|
z.object({ type: z.literal("item:uncheck"), itemId: z.string() }),
|
||||||
| { type: "item:delete"; itemId: string }
|
z.object({ type: z.literal("item:delete"), itemId: z.string() }),
|
||||||
| { type: "item:clear" };
|
z.object({ type: z.literal("item:clear") }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ShoppingClientCommand = z.infer<typeof shoppingClientCommandSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user