From 10523e58cef163ffdf3b90b938cd7dc62e1516ca Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 4 Jul 2026 19:10:43 +0200 Subject: [PATCH] Fix remaining security vulnerabilities - H1: Add missing sanitize() to help center content rendering - H2: Tighten CSP by removing unsafe-inline/unsafe-eval from script-src; move theme init to external JS file with meta tag for defaultDark - M1: Add SSRF protection for radio API URLs (block private IPs) - M2: Add rate limiting to SSO ticket endpoint (5 req/30s per user) - M4: Document locale validation safety in i18n dynamic import - L1: Truncate stacktraces in admin commandocentrum to first 20 lines --- next.config.ts | 2 +- public/scripts/theme-init.js | 12 ++++++++++++ src/app/admin/commandocentrum/page.tsx | 9 +++++++-- src/app/api/client/sso/route.ts | 11 ++++++++--- src/app/help/[category]/page.tsx | 6 +++--- src/app/layout.tsx | 8 ++------ src/env.ts | 15 +-------------- src/i18n/request.ts | 1 + src/lib/services/radio.ts | 17 +++++++++++++++++ 9 files changed, 52 insertions(+), 29 deletions(-) create mode 100644 public/scripts/theme-init.js diff --git a/next.config.ts b/next.config.ts index bdac7dfc..8e424f83 100644 --- a/next.config.ts +++ b/next.config.ts @@ -15,7 +15,7 @@ const securityHeaders = [ key: "Content-Security-Policy", value: [ "default-src 'self'", - "script-src 'self' 'unsafe-eval' 'unsafe-inline' https://challenges.cloudflare.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/", + "script-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https:", "frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/", diff --git a/public/scripts/theme-init.js b/public/scripts/theme-init.js new file mode 100644 index 00000000..9c6c4b54 --- /dev/null +++ b/public/scripts/theme-init.js @@ -0,0 +1,12 @@ +(function(){ + try { + var s=localStorage.getItem('theme'); + var dd=document.querySelector('meta[name="theme-default-dark"]'); + var defaultDark=dd?dd.getAttribute('content')==='true':false; + if(s==='dark'||(!s&&defaultDark))document.documentElement.classList.add('dark'); + var nc=localStorage.getItem('navbarColor'), + nt=localStorage.getItem('navbarTextColor'); + if(nc)document.documentElement.style.setProperty('--color-navbar',nc); + if(nt)document.documentElement.style.setProperty('--color-navbar-text',nt); + }catch(e){} +})(); diff --git a/src/app/admin/commandocentrum/page.tsx b/src/app/admin/commandocentrum/page.tsx index 60ebeb83..f1890afa 100644 --- a/src/app/admin/commandocentrum/page.tsx +++ b/src/app/admin/commandocentrum/page.tsx @@ -423,8 +423,13 @@ export default async function CommandoCentrum() { - {errors.map((e) => { + {errors.map((e) => { const trace = decodeStacktrace(e.stacktrace); + // Truncate stacktraces to first 20 lines to avoid info disclosure. + const snippet = trace + ? trace.split("\n").slice(0, 20).join("\n") + + (trace.split("\n").length > 20 ? "\n… (truncated)" : "") + : "(empty)"; return ( @@ -436,7 +441,7 @@ export default async function CommandoCentrum() {
-                          {trace || "(empty)"}
+                          {snippet}
                         
diff --git a/src/app/api/client/sso/route.ts b/src/app/api/client/sso/route.ts index 9e189ddf..f3778293 100644 --- a/src/app/api/client/sso/route.ts +++ b/src/app/api/client/sso/route.ts @@ -1,6 +1,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; import { siteSettings } from "@/lib/services/site-settings"; import { issueSsoTicket } from "@/lib/auth/sso-ticket"; @@ -13,14 +14,18 @@ export async function GET() { } const userId = Number(session.user.id); + + // Throttle SSO ticket generation (5 per 30s per user) — prevent ticket spam. + if (!rateLimit(`sso:${userId}`, 5, 30_000).ok) { + return new Response(JSON.stringify({ error: "Rate limited" }), { status: 429 }); + } + const [hotelName, clientUrl] = await Promise.all([ siteSettings.get("hotel_name", "Atom"), siteSettings.get("nitro_client_url", ""), ]); - const hdrs = await headers(); - const ip = - hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0"; + const ip = await clientIp(); const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip); diff --git a/src/app/help/[category]/page.tsx b/src/app/help/[category]/page.tsx index ea7a7625..a5623261 100644 --- a/src/app/help/[category]/page.tsx +++ b/src/app/help/[category]/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { ContentCard } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; +import { sanitize } from "@/lib/sanitize"; import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; @@ -116,11 +117,10 @@ export default async function HelpCategoryPage({ /> ) : null} - {/* content is author-supplied HTML in AtomCMS (rendered raw with {!! !!}). */} + {/* content is author-supplied HTML — sanitised server-side. */}
{hasButton ? ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d1f0f014..2afd8ceb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,12 +51,8 @@ export default async function RootLayout({ children }: { children: ReactNode }) return ( - {/* Apply the saved/default theme before first paint to avoid a flash. */} -