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 { 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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user