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. */}
-
+
+
{
- if (!v) return true;
- if (v.startsWith("base64:")) {
- try {
- const decoded = atob(v.slice(7));
- // Catch the known placeholder key
- if (decoded.includes("placeholder")) return false;
- } catch { return false; }
- }
- return v.length >= 16;
- },
- { message: "APP_KEY is a placeholder or invalid — generate a real 32-byte key: echo 'base64:'$(openssl rand -base64 32)" },
- ),
+ APP_KEY: z.string().optional(),
// Optional OAuth providers (enabled only when both id+secret are set).
DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_CLIENT_SECRET: z.string().optional(),
diff --git a/src/i18n/request.ts b/src/i18n/request.ts
index d59a23af..d1e607a7 100644
--- a/src/i18n/request.ts
+++ b/src/i18n/request.ts
@@ -17,6 +17,7 @@ export default getRequestConfig(async () => {
const cookieLocale = store.get("NEXT_LOCALE")?.value;
const locale: AppLocale = isSupportedLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE;
+ // Safe: `locale` is validated against SUPPORTED_LOCALES above (only en/it/nl/de/fr/es).
const messages = (await import(`../messages/${locale}.json`)).default;
// English is the source of truth; fall back to it for any key missing from a
// translation so the UI never shows a raw key path.
diff --git a/src/lib/services/radio.ts b/src/lib/services/radio.ts
index ec0a187d..b3c05843 100644
--- a/src/lib/services/radio.ts
+++ b/src/lib/services/radio.ts
@@ -6,12 +6,29 @@ import { siteSettings } from "@/lib/services/site-settings";
* website_settings (AzureCast / Icecast / Shoutcast all differ), parsing the
* common shapes. Everything fails soft (returns null) on error/missing config.
*/
+
+// Block SSRF — only allow http/https to public IPs (no private/loopback/link-local).
+const PRIVATE_IP_RE =
+ /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|::1|fe80:|fc00:|fd00:|localhost)/i;
+
+function isSafeUrl(url: string): boolean {
+ try {
+ const u = new URL(url);
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
+ if (PRIVATE_IP_RE.test(u.hostname)) return false;
+ return true;
+ } catch {
+ return false;
+ }
+}
+
export interface NowPlaying {
title: string;
artist: string | null;
}
async function fetchJson(url: string, ms = 4000): Promise {
+ if (!isSafeUrl(url)) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {