fix: security hardening, validation, cache and analytics fixes
Build & Push Docker Image / build (push) Successful in 4m32s

Backend security:
- Admin-gate /admin/redundancy (GET+POST) with zod validation and tool existence checks
- Restrict CORS to same-origin (plus CORS_ORIGIN allowlist) and SameSite=Lax cookie
- Validate returnTo to prevent open redirect in the OIDC flow
- Validate/coerce relations body, reject self-relations and non-admin 'recommended'
- Add central JSON error middleware (no more Express HTML 500s)
- Fail fast at startup when SESSION_SECRET/VOTER_SECRET missing in production

Backend correctness:
- Stop leaking voterToken in the create-rating response
- Allow clearing websiteUrl/iconUrl (nullable in UpdateToolBody, frontend sends null)
- Regenerate session after login/callback (session fixation) and add OIDC state check
- Block self-demotion and last-admin demotion in user PATCH
- Set created_by to NULL on user delete (FK-safe)
- Validate cost create/update bodies with zod
- Unique index (tool_id, voter_token) + 409 on race duplicate ratings
- Clamp audit limit, escape ilike wildcards in search, O(N) analytics queries

Frontend:
- tools-browse reads and syncs URL query params (fixes home 'View all' links)
- Invalidate analytics/top-tools/categories/features caches after mutations
- Sync category combobox input when the value changes externally
- Hide Write a Review for anonymous users, drop unreachable rating guard
This commit is contained in:
opencode
2026-08-01 19:11:00 +02:00
parent 0c6a35e841
commit db397a14bc
21 changed files with 378 additions and 138 deletions
+27 -6
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter } from "express";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { db, usersTable } from "@workspace/db";
import { requireAdmin } from "../middleware/auth";
@@ -85,6 +85,32 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
return;
}
const [target] = await db
.select({ id: usersTable.id, role: usersTable.role })
.from(usersTable)
.where(eq(usersTable.id, id))
.limit(1);
if (!target) {
res.status(404).json({ error: "User not found" });
return;
}
if (target.role === "admin" && parsed.data.role === "user") {
if (req.session.user?.sub === String(id)) {
res.status(400).json({ error: "Cannot demote your own account" });
return;
}
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(usersTable)
.where(eq(usersTable.role, "admin"));
if (count <= 1) {
res.status(400).json({ error: "Cannot demote the last admin" });
return;
}
}
const [user] = await db
.update(usersTable)
.set({ role: parsed.data.role })
@@ -97,11 +123,6 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
createdAt: usersTable.createdAt,
});
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
await writeAuditLog(req, "user", id, "update", { role: parsed.data.role });
res.json(user);
});