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
+53 -4
View File
@@ -1,5 +1,4 @@
import express, { type Express } from "express";
import cors from "cors";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import pinoHttp from "pino-http";
@@ -11,6 +10,15 @@ import "./types/session.d.ts";
const PgStore = ConnectPgSimple(session);
const isProd = process.env.NODE_ENV === "production";
if (isProd && !process.env.SESSION_SECRET) {
throw new Error("SESSION_SECRET must be set in production");
}
if (isProd && !process.env.VOTER_SECRET) {
throw new Error("VOTER_SECRET must be set in production");
}
const app: Express = express();
app.set("trust proxy", 1);
@@ -35,7 +43,36 @@ app.use(
}),
);
app.use(cors({ origin: true, credentials: true }));
const allowedOrigins = (process.env.CORS_ORIGIN ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
app.use((req, res, next) => {
const origin = req.headers.origin;
const host = req.headers.host;
let sameOrigin = false;
if (origin && host) {
try {
sameOrigin = new URL(origin).host === host;
} catch {
sameOrigin = false;
}
}
const allow = !origin || sameOrigin || allowedOrigins.includes(origin);
if (allow) {
if (origin) res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.sendStatus(204);
return;
}
}
next();
});
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@ -50,10 +87,10 @@ app.use(
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
secure: isProd,
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
sameSite: "lax",
},
}),
);
@@ -66,6 +103,18 @@ app.use("/api", (_req, res) => {
res.status(404).json({ error: "Not found" });
});
// Central JSON error handler (Express 5 forwards rejected async handlers here).
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const statusCode = (err as { status?: unknown })?.status ?? (err as { statusCode?: unknown })?.statusCode;
const status = typeof statusCode === "number" && statusCode >= 400 && statusCode < 600 ? statusCode : 500;
if (isProd) {
logger.error({ err: (err as Error)?.message }, "Unhandled error");
} else {
logger.error({ err }, "Unhandled error");
}
res.status(status).json({ error: isProd ? "Internal server error" : (err as Error)?.message ?? "Internal server error" });
});
const staticDir = process.env.STATIC_DIR;
if (staticDir && existsSync(staticDir)) {
app.use(express.static(staticDir));