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
+31 -3
View File
@@ -25,6 +25,15 @@ function getBaseUrl(req: Request): string {
return `${proto}://${host}`;
}
function isSafeReturnTo(value: string): boolean {
if (!value.startsWith("/") || value.startsWith("//")) return false;
try {
return new URL(value, "http://localhost").origin === "http://localhost";
} catch {
return false;
}
}
async function getClient(): Promise<Client | null> {
if (cachedClient) return cachedClient;
@@ -125,6 +134,10 @@ router.post("/auth/login", async (req, res): Promise<void> => {
return;
}
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
req.session.user = {
sub: String(user.id),
name: user.username,
@@ -155,9 +168,11 @@ router.get("/auth/login", async (req, res): Promise<void> => {
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
req.session.codeVerifier = codeVerifier;
if (req.query.returnTo && typeof req.query.returnTo === "string") {
req.session.oidcState = state;
if (req.query.returnTo && typeof req.query.returnTo === "string" && isSafeReturnTo(req.query.returnTo)) {
req.session.returnTo = req.query.returnTo;
}
@@ -167,6 +182,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
code_challenge: codeChallenge,
code_challenge_method: "S256",
redirect_uri: redirectUri,
state,
});
res.redirect(url);
@@ -185,17 +201,29 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
return;
}
const state = typeof req.query.state === "string" ? req.query.state : "";
if (!state || state !== req.session.oidcState) {
res.status(400).json({ error: "Invalid OAuth state." });
return;
}
delete req.session.oidcState;
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
try {
const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params, {
code_verifier: codeVerifier,
state,
});
const userinfo = await client.userinfo(tokenSet.access_token!);
const dbUser = await upsertUserFromOidc(userinfo);
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
req.session.user = {
sub: dbUser.id.toString(),
email: typeof userinfo.email === "string" ? userinfo.email : undefined,
@@ -207,10 +235,10 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
};
delete req.session.codeVerifier;
const returnTo = req.session.returnTo || "/";
const returnTo = req.session.returnTo ?? "/";
delete req.session.returnTo;
res.redirect(returnTo);
res.redirect(isSafeReturnTo(returnTo) ? returnTo : "/");
} catch (err) {
logger.error({ err }, "Keycloak callback failed");
res.status(500).json({ error: "Authentication failed." });