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",
|
||||
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/",
|
||||
|
||||
@@ -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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={e.id}>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
@@ -436,7 +441,7 @@ export default async function CommandoCentrum() {
|
||||
</td>
|
||||
<td>
|
||||
<pre className="text-xs p-2 bg-gray-50 dark:bg-black/20 rounded overflow-auto max-h-[160px]">
|
||||
{trace || "(empty)"}
|
||||
{snippet}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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. */}
|
||||
<div
|
||||
style={{ lineHeight: 1.7 }}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: author-supplied help content, matches AtomCMS Blade
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(content) }}
|
||||
/>
|
||||
|
||||
{hasButton ? (
|
||||
|
||||
+2
-6
@@ -51,12 +51,8 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
||||
return (
|
||||
<html lang={locale} className={`app ${nunito.variable} ${pixelFont.variable}`}>
|
||||
<head>
|
||||
{/* Apply the saved/default theme before first paint to avoid a flash. */}
|
||||
<script
|
||||
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){}`,
|
||||
}}
|
||||
/>
|
||||
<meta name="theme-default-dark" content={String(defaultDark)} />
|
||||
<script src="/scripts/theme-init.js" />
|
||||
</head>
|
||||
<body
|
||||
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.
|
||||
AUTH_SECRET: z.string().min(1).optional(),
|
||||
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
|
||||
APP_KEY: z.string().optional().refine(
|
||||
(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)" },
|
||||
),
|
||||
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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<unknown> {
|
||||
if (!isSafeUrl(url)) return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user