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:
René Schober
2026-03-20 13:14:45 +01:00
parent f5c4b33f60
commit 51f0028883
8 changed files with 63 additions and 36 deletions

View 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

View File

@@ -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));

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);
}),
);

View File

@@ -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<WsData>) {
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;
}

View File

@@ -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<typeof shoppingClientCommandSchema>;