Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 2 additions & 14 deletions packages/fern-docs/bundle/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Providers } from "@fern-docs/components/providers/providers";
import type { Metadata, Viewport } from "next/types";
import { experimental_taintUniqueValue } from "react";

import { AsyncStylesheet } from "@/components/AsyncStylesheet";
import { ConsoleMessage } from "@/components/console-message";
import { WebSocketRefresh } from "@/components/websocket-refresh";

Expand Down Expand Up @@ -56,23 +57,10 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />

{/* Non-blocking KaTeX CSS loading */}
<link
rel="preload"
as="style"
<AsyncStylesheet
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css"
crossOrigin="anonymous"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css"
media="print"
onload="this.media='all'"
/>
<noscript
dangerouslySetInnerHTML={{
__html: '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css" />'
}}
/>
</head>
);

Expand Down
41 changes: 41 additions & 0 deletions packages/fern-docs/bundle/src/components/AsyncStylesheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

/**
* AsyncStylesheet - Non-blocking CSS loading component
*
* Loads a stylesheet asynchronously without blocking page render.
* Uses the media="print" + onLoad trick for optimal performance while
* maintaining React/TypeScript compatibility.
*/

interface AsyncStylesheetProps {
href: string;
crossOrigin?: "anonymous" | "use-credentials";
}

export function AsyncStylesheet({ href, crossOrigin = "anonymous" }: AsyncStylesheetProps) {
return (
<>
{/* Preload hint for early resource discovery */}
<link rel="preload" as="style" href={href} crossOrigin={crossOrigin} />

{/* Async loading with media swap trick - React's onLoad works here */}
<link
rel="stylesheet"
href={href}
media="print"
crossOrigin={crossOrigin}
onLoad={(e) => {
(e.target as HTMLLinkElement).media = "all";
}}
/>

{/* Fallback for users with JavaScript disabled */}
<noscript
dangerouslySetInnerHTML={{
__html: `<link rel="stylesheet" href="${href}" crossorigin="${crossOrigin}" />`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The href and crossOrigin props are directly interpolated into HTML without escaping, creating an XSS vulnerability through dangerouslySetInnerHTML.

View Details
📝 Patch Details
diff --git a/packages/fern-docs/bundle/src/components/AsyncStylesheet.tsx b/packages/fern-docs/bundle/src/components/AsyncStylesheet.tsx
index dd91da19d..785fd357e 100644
--- a/packages/fern-docs/bundle/src/components/AsyncStylesheet.tsx
+++ b/packages/fern-docs/bundle/src/components/AsyncStylesheet.tsx
@@ -8,32 +8,69 @@
  * maintaining React/TypeScript compatibility.
  */
 
+/**
+ * Validates and sanitizes a URL to prevent XSS attacks
+ * Returns null if the URL is invalid or potentially malicious
+ */
+function validateStylesheetUrl(href: string): string | null {
+    try {
+        // Only allow http/https URLs
+        const url = new URL(href, typeof window !== 'undefined' ? window.location.href : 'http://localhost');
+        if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+            return null;
+        }
+        return url.toString();
+    } catch {
+        return null;
+    }
+}
+
+/**
+ * Validates crossOrigin value to ensure it's one of the allowed values
+ */
+function validateCrossOrigin(crossOrigin: string): "anonymous" | "use-credentials" | null {
+    if (crossOrigin === "anonymous" || crossOrigin === "use-credentials") {
+        return crossOrigin;
+    }
+    return null;
+}
+
 interface AsyncStylesheetProps {
     href: string;
     crossOrigin?: "anonymous" | "use-credentials";
 }
 
 export function AsyncStylesheet({ href, crossOrigin = "anonymous" }: AsyncStylesheetProps) {
+    // Validate and sanitize inputs to prevent XSS
+    const validatedHref = validateStylesheetUrl(href);
+    const validatedCrossOrigin = validateCrossOrigin(crossOrigin);
+
+    // If validation fails, don't render anything to prevent XSS
+    if (!validatedHref || !validatedCrossOrigin) {
+        console.warn('AsyncStylesheet: Invalid href or crossOrigin prop detected, skipping render for security');
+        return null;
+    }
+
     return (
         <>
             {/* Preload hint for early resource discovery */}
-            <link rel="preload" as="style" href={href} crossOrigin={crossOrigin} />
+            <link rel="preload" as="style" href={validatedHref} crossOrigin={validatedCrossOrigin} />
 
             {/* Async loading with media swap trick - React's onLoad works here */}
             <link
                 rel="stylesheet"
-                href={href}
+                href={validatedHref}
                 media="print"
-                crossOrigin={crossOrigin}
+                crossOrigin={validatedCrossOrigin}
                 onLoad={(e) => {
                     (e.target as HTMLLinkElement).media = "all";
                 }}
             />
 
-            {/* Fallback for users with JavaScript disabled */}
+            {/* Fallback for users with JavaScript disabled - now safe because we validated inputs */}
             <noscript
                 dangerouslySetInnerHTML={{
-                    __html: `<link rel="stylesheet" href="${href}" crossorigin="${crossOrigin}" />`
+                    __html: `<link rel="stylesheet" href="${validatedHref}" crossorigin="${validatedCrossOrigin}" />`
                 }}
             />
         </>

Analysis

XSS vulnerability in AsyncStylesheet component via dangerouslySetInnerHTML

What fails: AsyncStylesheet component in packages/fern-docs/bundle/src/components/AsyncStylesheet.tsx directly interpolates untrusted href and crossOrigin props into HTML via dangerouslySetInnerHTML, allowing script execution

How to reproduce:

<AsyncStylesheet 
  href='x" onload="alert(1)'
  crossOrigin="anonymous" 
/>

Result: Generated HTML contains executable JavaScript: <link rel="stylesheet" href="x" onload="alert(1)" crossorigin="anonymous" /> which executes when parsed

Expected: Input should be validated/sanitized to prevent XSS attacks per React security guidelines

}}
/>
</>
);
}