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
+36 -5
View File
@@ -1,5 +1,6 @@
import { Router, type IRouter } from "express";
import { eq, ilike, desc, sql, and, not } from "drizzle-orm";
import { eq, desc, sql, and, not } from "drizzle-orm";
import { z } from "zod";
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
import {
ListToolsQueryParams,
@@ -15,6 +16,14 @@ import { writeAuditLog } from "../lib/audit";
const router: IRouter = Router();
const PUBLIC_RELATION_TYPES = ["similar", "replaces", "superseded_by"] as const;
const RELATION_TYPES = [...PUBLIC_RELATION_TYPES, "recommended"] as const;
const RelationBody = z.object({
relatedToolId: z.coerce.number().int().positive(),
relationType: z.enum(RELATION_TYPES).optional(),
notes: z.string().optional(),
});
function buildToolWithStats(tool: typeof toolsTable.$inferSelect, ratings: { usefulness: number; usability: number }[]) {
const ratingCount = ratings.length;
const avgUsefulness = ratingCount > 0
@@ -49,7 +58,8 @@ router.get("/tools", async (req, res): Promise<void> => {
query = query.where(eq(toolsTable.category, category));
}
if (search) {
query = query.where(ilike(toolsTable.name, `%${search}%`));
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
}
const tools = await query.orderBy(desc(toolsTable.createdAt));
@@ -326,8 +336,29 @@ router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools")
const toolId = Number(req.params.id);
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
const { relatedToolId, relationType, notes } = req.body;
if (!relatedToolId) { res.status(400).json({ error: "relatedToolId is required" }); return; }
const parsed = RelationBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { relatedToolId, notes } = parsed.data;
const relationType = parsed.data.relationType ?? "similar";
if (relationType === "recommended" && req.session.user?.role !== "admin") {
res.status(403).json({ error: "Only admins can create recommended relations" });
return;
}
if (toolId === relatedToolId) {
res.status(400).json({ error: "A tool cannot be related to itself" });
return;
}
const [toolA] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, toolId)).limit(1);
const [toolB] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, relatedToolId)).limit(1);
if (!toolA || !toolB) {
res.status(404).json({ error: "One or both tools not found" });
return;
}
const [existing] = await db
.select()
@@ -340,7 +371,7 @@ router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools")
const [relation] = await db.insert(toolRelationsTable).values({
toolId,
relatedToolId,
relationType: relationType ?? "similar",
relationType,
notes: notes ?? null,
createdBy: Number(req.session.user!.sub),
}).returning();