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
This commit is contained in:
1 parent
5628e7d6b7
commit
10523e58ce
9 files changed
+52
-29
No files matched your search
+1
-1
@@ -15,7 +15,7 @@ const securityHeaders = [
|
|||||||
key: "Content-Security-Policy",
|
key: "Content-Security-Policy",
|
||||||
value: [
|
value: [
|
||||||
"default-src 'self'",
|
"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'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob: https:",
|
"img-src 'self' data: blob: https:",
|
||||||
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
|
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
|
||||||
|
|||||||
@@ -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){}
|
||||||
|
})();
|
||||||
@@ -423,8 +423,13 @@ export default async function CommandoCentrum() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{errors.map((e) => {
|
{errors.map((e) => {
|
||||||
const trace = decodeStacktrace(e.stacktrace);
|
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 (
|
return (
|
||||||
<tr key={e.id}>
|
<tr key={e.id}>
|
||||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
@@ -436,7 +441,7 @@ export default async function CommandoCentrum() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<pre className="text-xs p-2 bg-gray-50 dark:bg-black/20 rounded overflow-auto max-h-[160px]">
|
<pre className="text-xs p-2 bg-gray-50 dark:bg-black/20 rounded overflow-auto max-h-[160px]">
|
||||||
{trace || "(empty)"}
|
{snippet}
|
||||||
</pre>
|
</pre>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
||||||
|
|
||||||
@@ -13,14 +14,18 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = Number(session.user.id);
|
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([
|
const [hotelName, clientUrl] = await Promise.all([
|
||||||
siteSettings.get("hotel_name", "Atom"),
|
siteSettings.get("hotel_name", "Atom"),
|
||||||
siteSettings.get("nitro_client_url", ""),
|
siteSettings.get("nitro_client_url", ""),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hdrs = await headers();
|
const ip = await clientIp();
|
||||||
const ip =
|
|
||||||
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
|
|
||||||
|
|
||||||
const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip);
|
const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sanitize } from "@/lib/sanitize";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -116,11 +117,10 @@ export default async function HelpCategoryPage({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* content is author-supplied HTML in AtomCMS (rendered raw with {!! !!}). */}
|
{/* content is author-supplied HTML — sanitised server-side. */}
|
||||||
<div
|
<div
|
||||||
style={{ lineHeight: 1.7 }}
|
style={{ lineHeight: 1.7 }}
|
||||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: author-supplied help content, matches AtomCMS Blade
|
dangerouslySetInnerHTML={{ __html: sanitize(content) }}
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasButton ? (
|
{hasButton ? (
|
||||||
|
|||||||
+2
-6
@@ -51,12 +51,8 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|||||||
return (
|
return (
|
||||||
<html lang={locale} className={`app ${nunito.variable} ${pixelFont.variable}`}>
|
<html lang={locale} className={`app ${nunito.variable} ${pixelFont.variable}`}>
|
||||||
<head>
|
<head>
|
||||||
{/* Apply the saved/default theme before first paint to avoid a flash. */}
|
<meta name="theme-default-dark" content={String(defaultDark)} />
|
||||||
<script
|
<script src="/scripts/theme-init.js" />
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `try{var s=localStorage.getItem('theme');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){}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className="flex min-h-screen flex-col site-bg"
|
className="flex min-h-screen flex-col site-bg"
|
||||||
|
|||||||
+1
-14
@@ -29,20 +29,7 @@ const schema = z.object({
|
|||||||
// NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing.
|
// NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing.
|
||||||
AUTH_SECRET: z.string().min(1).optional(),
|
AUTH_SECRET: z.string().min(1).optional(),
|
||||||
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
|
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
|
||||||
APP_KEY: z.string().optional().refine(
|
APP_KEY: z.string().optional(),
|
||||||
(v) => {
|
|
||||||
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)" },
|
|
||||||
),
|
|
||||||
// Optional OAuth providers (enabled only when both id+secret are set).
|
// Optional OAuth providers (enabled only when both id+secret are set).
|
||||||
DISCORD_CLIENT_ID: z.string().optional(),
|
DISCORD_CLIENT_ID: z.string().optional(),
|
||||||
DISCORD_CLIENT_SECRET: z.string().optional(),
|
DISCORD_CLIENT_SECRET: z.string().optional(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default getRequestConfig(async () => {
|
|||||||
const cookieLocale = store.get("NEXT_LOCALE")?.value;
|
const cookieLocale = store.get("NEXT_LOCALE")?.value;
|
||||||
const locale: AppLocale = isSupportedLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE;
|
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;
|
const messages = (await import(`../messages/${locale}.json`)).default;
|
||||||
// English is the source of truth; fall back to it for any key missing from a
|
// 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.
|
// translation so the UI never shows a raw key path.
|
||||||
|
|||||||
@@ -6,12 +6,29 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
* website_settings (AzureCast / Icecast / Shoutcast all differ), parsing the
|
* website_settings (AzureCast / Icecast / Shoutcast all differ), parsing the
|
||||||
* common shapes. Everything fails soft (returns null) on error/missing config.
|
* 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 {
|
export interface NowPlaying {
|
||||||
title: string;
|
title: string;
|
||||||
artist: string | null;
|
artist: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url: string, ms = 4000): Promise<unknown> {
|
async function fetchJson(url: string, ms = 4000): Promise<unknown> {
|
||||||
|
if (!isSafeUrl(url)) return null;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), ms);
|
const timer = setTimeout(() => controller.abort(), ms);
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user