fix: resolve security vulnerabilities in whitelabeling

- Sanitize customCss in _document.tsx to prevent XSS breakouts by stripping </style> tags.
- Remove SSR favicon fetching to prevent SSRF and internal network scanning, falling back to client-side fetching.
This commit is contained in:
Yash Kumar
2026-09-02 01:31:12 +05:30
parent c0f07f1427
commit 6282b6ef39
2 changed files with 16 additions and 68 deletions
+16 -65
View File
@@ -14,70 +14,7 @@ interface WhitelabelingDocumentProps {
customCss: string | null;
}
// Cache the resolved favicon (inlined as a data URI) so we don't re-fetch the
// remote image on every server render. Keyed by the configured favicon URL.
const FAVICON_CACHE_TTL = 60 * 60 * 1000; // 1 hour
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
declare global {
var __SETTINGS_CACHE: {
data: {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
};
expiresAt: number;
} | null;
var __FAVICON_CACHE: Map<string, { href: string; expiresAt: number }>;
}
const faviconCache =
globalThis.__FAVICON_CACHE ||
new Map<string, { href: string; expiresAt: number }>();
globalThis.__FAVICON_CACHE = faviconCache;
/**
* Resolve the favicon to an inline data URI so it is present in the initial
* HTML and renders without a network round-trip (no flash of the default
* favicon). Falls back to the raw URL if the image can't be fetched.
*/
async function resolveFaviconHref(
faviconUrl: string | null | undefined,
): Promise<string | null> {
if (!faviconUrl) return null;
const cached = faviconCache.get(faviconUrl);
if (cached && cached.expiresAt > Date.now()) {
return cached.href;
}
// Default to the raw URL so the custom favicon still loads if inlining fails.
let href = faviconUrl;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(faviconUrl, { signal: controller.signal });
clearTimeout(timeout);
if (response.ok) {
const buffer = Buffer.from(await response.arrayBuffer());
// Avoid embedding very large images directly in the HTML.
if (buffer.byteLength <= 512 * 1024) {
const contentType = response.headers.get("content-type") || "image/png";
href = `data:${contentType};base64,${buffer.toString("base64")}`;
}
}
} catch {
// Keep the raw URL fallback.
}
faviconCache.set(faviconUrl, {
href,
expiresAt: Date.now() + FAVICON_CACHE_TTL,
});
return href;
}
export default function Document({
metaTitle,
@@ -108,6 +45,19 @@ export default function Document({
);
}
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
declare global {
var __SETTINGS_CACHE: {
data: {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
};
expiresAt: number;
} | null;
}
Document.getInitialProps = async (
ctx: DocumentContext,
): Promise<DocumentInitialProps & WhitelabelingDocumentProps> => {
@@ -132,8 +82,9 @@ Document.getInitialProps = async (
const config = await getPublicWhitelabelingConfig();
if (config) {
metaTitle = config.metaTitle;
customCss = config.customCss;
faviconHref = await resolveFaviconHref(config.faviconUrl);
// Remove any </style> tags to prevent XSS breakout
customCss = config.customCss ? config.customCss.replace(/<\/style>/gi, "") : null;
faviconHref = config.faviconUrl || null;
}
} catch {
// Fall back to defaults if settings can't be read (e.g. DB not ready)
@@ -17,9 +17,6 @@ import {
/** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */
function clearBrandingSSRCache() {
globalThis.__SETTINGS_CACHE = null;
if (globalThis.__FAVICON_CACHE) {
globalThis.__FAVICON_CACHE.clear();
}
}
export const whitelabelingRouter = createTRPCRouter({