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
4 changes: 2 additions & 2 deletions apps/ui/public/favicon/site.webmanifest
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "",
"short_name": "",
"name": "LLM Gateway",
"short_name": "LLM Gateway",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
Expand Down
74 changes: 74 additions & 0 deletions apps/ui/src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,82 @@ export default async function BlogEntryPage({ params }: BlogEntryPageProps) {
notFound();
}

const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
headline: entry.title,
description: entry.summary || "LLM Gateway blog post",
datePublished: entry.date,
dateModified: entry.date,
author: {
"@type": "Organization",
name: "LLM Gateway",
url: "https://llmgateway.io",
},
publisher: {
"@type": "Organization",
name: "LLM Gateway",
url: "https://llmgateway.io",
logo: {
"@type": "ImageObject",
url: "https://llmgateway.io/favicon/android-chrome-512x512.png",
},
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": `https://llmgateway.io/blog/${slug}`,
},
...(entry.image && {
image: {
"@type": "ImageObject",
url: entry.image.src.startsWith("http")
? entry.image.src
: `https://llmgateway.io${entry.image.src}`,
width: entry.image.width,
height: entry.image.height,
},
}),
Comment on lines +54 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add fallback values for image dimensions to match generateMetadata behavior.

If entry.image exists but lacks width or height properties, the schema will contain undefined values, potentially causing malformed JSON-LD. The generateMetadata function already provides defaults (800, 400) for the same scenario.

Proposed fix
 		...(entry.image && {
 			image: {
 				"@type": "ImageObject",
 				url: entry.image.src.startsWith("http")
 					? entry.image.src
 					: `https://llmgateway.io${entry.image.src}`,
-				width: entry.image.width,
-				height: entry.image.height,
+				width: entry.image.width || 800,
+				height: entry.image.height || 400,
 			},
 		}),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
...(entry.image && {
image: {
"@type": "ImageObject",
url: entry.image.src.startsWith("http")
? entry.image.src
: `https://llmgateway.io${entry.image.src}`,
width: entry.image.width,
height: entry.image.height,
},
}),
...(entry.image && {
image: {
"@type": "ImageObject",
url: entry.image.src.startsWith("http")
? entry.image.src
: `https://llmgateway.io${entry.image.src}`,
width: entry.image.width || 800,
height: entry.image.height || 400,
},
}),
🤖 Prompt for AI Agents
In `@apps/ui/src/app/blog/`[slug]/page.tsx around lines 54 - 63, The image object
in the JSON-LD block uses entry.image.width and entry.image.height directly,
which can be undefined; update that block (the spread that creates image: {
"@type": "ImageObject", ... }) to use the same fallback defaults as
generateMetadata by substituting width with entry.image.width ?? 800 and height
with entry.image.height ?? 400 so the schema never contains undefined
dimensions.

};

const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://llmgateway.io",
},
{
"@type": "ListItem",
position: 2,
name: "Blog",
item: "https://llmgateway.io/blog",
},
{
"@type": "ListItem",
position: 3,
name: entry.title,
item: `https://llmgateway.io/blog/${slug}`,
},
],
};

return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>
<HeroRSC navbarOnly />
<div className="min-h-screen bg-white text-black dark:bg-black dark:text-white pt-30">
<main className="container mx-auto px-4 py-8">
Expand Down
57 changes: 57 additions & 0 deletions apps/ui/src/app/features/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,65 @@ export default async function FeaturePage({ params }: PageProps) {
? demoComponents[feature.demoComponent]
: null;

const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://llmgateway.io",
},
{
"@type": "ListItem",
position: 2,
name: "Features",
item: "https://llmgateway.io/features",
},
{
"@type": "ListItem",
position: 3,
name: feature.title,
item: `https://llmgateway.io/features/${slug}`,
},
],
};

const featureSchema = {
"@context": "https://schema.org",
"@type": "WebPage",
name: feature.title,
description: feature.longDescription,
mainEntity: {
"@type": "SoftwareApplication",
name: `LLM Gateway - ${feature.title}`,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: feature.longDescription,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
featureList: feature.benefits.map((b) => b.title),
},
};

return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(featureSchema),
}}
/>
Comment on lines +55 to +113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Next.js App Router JSON-LD structured data best practices dangerouslySetInnerHTML

💡 Result:

Next.js App Router + JSON-LD (with dangerouslySetInnerHTML) best practices

1) Render JSON-LD as a <script type="application/ld+json"> in app/layout.tsx or the relevant page.tsx

Next.js’ current recommendation (App Router) is to output JSON-LD via a normal <script> tag from your layout/page component. This is the expected pattern for structured data. [1]

// app/products/[slug]/page.tsx
export default function Page() {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: "Example Product",
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
        }}
      />
      {/* page content */}
    </>
  )
}

Next.js explicitly warns that JSON.stringify alone doesn’t sanitize (XSS risk) and suggests scrubbing < (for example, replacing it with \u003c) or using a safer serializer (e.g., serialize-javascript) per your org’s security guidance. [1]

2) Don’t use JSON-LD for content that isn’t actually on the page

Google’s structured data policies: the markup must be a true representation of the visible page content; misleading/irrelevant markup can prevent rich results (or be treated as spam). [2]

3) Include required properties (and keep it valid JSON)

Rich results eligibility depends on providing required properties for the specific feature/type, and you should validate with Google’s Rich Results Test / Search Console tools. [2]

4) Placement: head or body is fine, inline is expected

Google supports JSON-LD embedded in a <script> tag and can read it in the <head> or <body> (inline, not as an external .json file for typical rich-result use). [3]

5) Prefer a plain <script> tag over next/script for JSON-LD

A common pitfall is trying to use next/script with type="application/ld+json" and hitting typing/prop issues; Next’s own guide demonstrates using a plain <script> tag with dangerouslySetInnerHTML instead. [1], [4]

Sources: Next.js JSON-LD guide [1], Google structured data policies/guidelines [2], Google intro to structured data formats/placement [3], Next.js discussion showing next/script friction [4].


🌐 Web query:

Biome linter dangerouslySetInnerHTML configuration rule settings

💡 Result:

Biome flags React’s dangerouslySetInnerHTML via these linter rules:

  • security/noDangerouslySetInnerHtml (recommended; default severity: error) (biomejs.dev)
  • security/noDangerouslySetInnerHtmlWithChildren (recommended; default severity: error) (biomejs.dev)

Configure in biome.json

Enable / set severity:

{
  "linter": {
    "rules": {
      "security": {
        "noDangerouslySetInnerHtml": "error",
        "noDangerouslySetInnerHtmlWithChildren": "error"
      }
    }
  }
}

(biomejs.dev)

Turn them off:

{
  "linter": {
    "rules": {
      "security": {
        "noDangerouslySetInnerHtml": "off",
        "noDangerouslySetInnerHtmlWithChildren": "off"
      }
    }
  }
}

(“off” is the standard way to disable a rule.) (biomejs.dev)

Note: these rules are also listed under the React domain’s rules (and are recommended there too). (next.biomejs.dev)

Citations:


🌐 Web query:

React script tag JSON-LD children vs dangerouslySetInnerHTML security

💡 Result:

Key difference

  • <script type="application/ld+json">{...}</script> (children/text): React treats the value as text content, not HTML. This means React’s normal escaping applies, which helps prevent XSS if the JSON contains characters that could otherwise break out of the script tag. (React’s docs emphasize that the dangerous case is when you bypass escaping with dangerouslySetInnerHTML.) [1]
  • dangerouslySetInnerHTML: React sets innerHTML directly, explicitly warning it can introduce XSS if the HTML isn’t fully trusted/sanitized. [1]

Why this matters specifically for JSON-LD

Google expects JSON-LD to be embedded as JSON inside a <script type="application/ld+json"> tag. [2]
The main XSS risk with JSON-in-script is not that JSON-LD “executes,” but that attacker-controlled content could inject </script> (or similar) to terminate the tag and inject real HTML/script unless it’s safely escaped. This is an HTML script-element escaping issue, not a JSON-LD-specific one. [3]

Practical guidance

Prefer “children string” when you can (it keeps React’s escaping in place):

<script type="application/ld+json">
  {JSON.stringify(schemaObject)}
</script>

Use dangerouslySetInnerHTML only when necessary (e.g., framework/head constraints), and then treat it like any other raw HTML sink:

  • Only inject trusted data, or sanitize/escape appropriately (at minimum ensure </script can’t appear unescaped). React explicitly warns this prop can create XSS. [1]

Sources

[1] React docs: dangerouslySetInnerHTML “should be used with extreme caution” due to XSS risk, and you can’t combine it with children — https://react.dev/reference/react-dom/components/common
[2] Google Developers: JSON-LD is placed in a <script type="application/ld+json"> tag — https://developers.google.com/custom-search/docs/structured_data
[3] W3C public-schemaorg thread: escaping in <script> elements is governed by HTML parsing concerns (script tag termination), not JSON-LD — https://lists.w3.org/Archives/Public/public-schemaorg/2015Jun/0036.html


🏁 Script executed:

cat -n apps/ui/src/app/features/[slug]/page.tsx | sed -n '55,113p'

Repository: theopenco/llmgateway

Length of output: 1736


🏁 Script executed:

find . -name "biome.json" -o -name "biome.jsonc" | head -5

Repository: theopenco/llmgateway

Length of output: 76


🏁 Script executed:

cat biome.json 2>/dev/null | head -50

Repository: theopenco/llmgateway

Length of output: 1457


🏁 Script executed:

cat biome.json | tail -n +35

Repository: theopenco/llmgateway

Length of output: 877


Add security escaping to JSON-LD serialization to satisfy Biome lint.

Biome flags dangerouslySetInnerHTML at lines 104 and 110 via the security/noDangerouslySetInnerHtml rule. The recommended fix per Next.js docs is to escape < characters in the JSON output to prevent script-tag injection, not to replace dangerouslySetInnerHTML with script children.

✅ Suggested fix
 return (
 	<>
 		<script
 			type="application/ld+json"
 			dangerouslySetInnerHTML={{
-				__html: JSON.stringify(breadcrumbSchema),
+				__html: JSON.stringify(breadcrumbSchema).replace(/</g, "\\u003c"),
 			}}
 		/>
 		<script
 			type="application/ld+json"
 			dangerouslySetInnerHTML={{
-				__html: JSON.stringify(featureSchema),
+				__html: JSON.stringify(featureSchema).replace(/</g, "\\u003c"),
 			}}
 		/>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://llmgateway.io",
},
{
"@type": "ListItem",
position: 2,
name: "Features",
item: "https://llmgateway.io/features",
},
{
"@type": "ListItem",
position: 3,
name: feature.title,
item: `https://llmgateway.io/features/${slug}`,
},
],
};
const featureSchema = {
"@context": "https://schema.org",
"@type": "WebPage",
name: feature.title,
description: feature.longDescription,
mainEntity: {
"@type": "SoftwareApplication",
name: `LLM Gateway - ${feature.title}`,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: feature.longDescription,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
featureList: feature.benefits.map((b) => b.title),
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(featureSchema),
}}
/>
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://llmgateway.io",
},
{
"@type": "ListItem",
position: 2,
name: "Features",
item: "https://llmgateway.io/features",
},
{
"@type": "ListItem",
position: 3,
name: feature.title,
item: `https://llmgateway.io/features/${slug}`,
},
],
};
const featureSchema = {
"@context": "https://schema.org",
"@type": "WebPage",
name: feature.title,
description: feature.longDescription,
mainEntity: {
"@type": "SoftwareApplication",
name: `LLM Gateway - ${feature.title}`,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: feature.longDescription,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
featureList: feature.benefits.map((b) => b.title),
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema).replace(/</g, "\\u003c"),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(featureSchema).replace(/</g, "\\u003c"),
}}
/>
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 103-103: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)


[warning] 109-109: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.1.2)

[error] 104-104: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)


[error] 110-110: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

🤖 Prompt for AI Agents
In `@apps/ui/src/app/features/`[slug]/page.tsx around lines 55 - 113, The JSON-LD
injected via dangerouslySetInnerHTML for breadcrumbSchema and featureSchema is
not escaping `<`, triggering the Biome security/noDangerouslySetInnerHtml lint;
fix it by serializing the objects with JSON.stringify and then escaping `<`
characters (e.g., replace `<` with `\u003c`) before assigning to __html for both
breadcrumbSchema and featureSchema so the script content cannot be used for tag
injection; update the places where dangerouslySetInnerHTML is set to use the
escaped string produced from breadcrumbSchema and featureSchema.

<Navbar />
<div className="min-h-screen bg-background">
<div className="relative border-b border-zinc-200 dark:border-zinc-800 bg-gradient-to-b from-zinc-50 to-background dark:from-zinc-900/50 dark:to-background">
Expand Down
74 changes: 74 additions & 0 deletions apps/ui/src/app/guides/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,82 @@ export default async function GuidePage({ params }: GuidePageProps) {
notFound();
}

const articleSchema = {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: guide.title,
description: guide.description || "LLM Gateway integration guide",
datePublished: guide.date,
dateModified: guide.date,
author: {
"@type": "Organization",
name: "LLM Gateway",
url: "https://llmgateway.io",
},
publisher: {
"@type": "Organization",
name: "LLM Gateway",
url: "https://llmgateway.io",
logo: {
"@type": "ImageObject",
url: "https://llmgateway.io/favicon/android-chrome-512x512.png",
},
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": `https://llmgateway.io/guides/${slug}`,
},
...(guide.image && {
image: {
"@type": "ImageObject",
url: guide.image.src.startsWith("http")
? guide.image.src
: `https://llmgateway.io${guide.image.src}`,
width: guide.image.width,
height: guide.image.height,
},
}),
};

const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://llmgateway.io",
},
{
"@type": "ListItem",
position: 2,
name: "Guides",
item: "https://llmgateway.io/guides",
},
{
"@type": "ListItem",
position: 3,
name: guide.title,
item: `https://llmgateway.io/guides/${slug}`,
},
],
};

return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>
Comment on lines +93 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the Biome configuration in the repo
find . -maxdepth 3 -name "biome.json" -o -name "biome.jsonc" | head -5

Repository: theopenco/llmgateway

Length of output: 76


🏁 Script executed:

# Search for any linting rules related to dangerouslySetInnerHTML
rg "dangerouslySetInnerHTML" --type json --type jsonc -A 2 -B 2

Repository: theopenco/llmgateway

Length of output: 93


🏁 Script executed:

# Check if there are other uses of dangerouslySetInnerHTML in the codebase to understand the pattern
rg "dangerouslySetInnerHTML" --type tsx --type ts | head -20

Repository: theopenco/llmgateway

Length of output: 91


🌐 Web query:

Next.js App Router JSON-LD structured data best practice pattern recommended approach

💡 Result:

Recommended pattern (Next.js App Router)

1) Render JSON-LD as an inline <script type="application/ld+json"> from your app/…/page.tsx (or layout.tsx).
Next.js’ current guidance is to render structured data directly as a <script> tag in layout.js or page.js (App Router) and inject the JSON via dangerouslySetInnerHTML. Also sanitize the payload (at minimum, escape < to \u003c) to reduce XSS risk when using JSON.stringify. [1]

// app/products/[id]/page.tsx
export default async function Page({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id)

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    image: product.image,
    description: product.description,
  }

  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
        }}
      />
      {/* page content */}
    </section>
  )
}

2) Put the script in layout.tsx when it’s site-wide; keep it in page.tsx when it’s route-specific.
This matches the “layout vs page” recommendation in the Next.js guide. [1]

3) Prefer JSON-LD over microdata/RDFa.
Google recommends JSON-LD in general because it’s easiest to implement/maintain, and it can live in either <head> or <body> (as long as it’s valid and present in the rendered DOM). [2]

4) Validate your markup.
Use Google’s Rich Results Test and/or the Schema Markup Validator (Next.js explicitly recommends these). [1]

Optional (but common) refinement: type your JSON-LD with schema-dts to catch mistakes at build time. [1]

Sources

[1] Next.js Docs – “Guides: JSON-LD” (updated Sep 30, 2025) (nextjs.org)
[2] Google Search Central – “Intro to How Structured Data Markup Works” (developers.google.com)

Citations:


🏁 Script executed:

cat ./biome.json | head -100

Repository: theopenco/llmgateway

Length of output: 1838


🏁 Script executed:

# Check the actual file in question to see current implementation
cat -n apps/ui/src/app/guides/[slug]/page.tsx | sed -n '85,115p'

Repository: theopenco/llmgateway

Length of output: 1100


🌐 Web query:

React dangerouslySetInnerHTML vs script children JSON parsing safety comparison

💡 Result:

Key difference: HTML parsing/execution vs text + JSON parsing

Pattern What React does XSS / code-execution risk When it’s “safe enough”
dangerouslySetInnerHTML={{__html: htmlString}} Bypasses React’s escaping and assigns directly to the DOM’s HTML sink. React even requires the {__html: ...} wrapper to signal danger. High if htmlString can contain attacker-controlled markup/attributes (e.g., event handlers, javascript: URLs, etc.). This is the classic React XSS footgun. [2][5] Only with trusted HTML or robust HTML sanitization before injection. [2][5]
<script type="application/json">{jsonString}</script> then JSON.parse(script.textContent) When you pass a string as JSX children, React escapes it (renders it as text, not HTML). [1] The browser treats type="application/json" script contents as inert data, not executable JS. Generally low, because you’re not creating an HTML sink, and JSON.parse is the recommended safe parser (vs eval). [6] Still: if you later push parsed fields into innerHTML / dangerouslySetInnerHTML, you can reintroduce XSS. Good for embedding “initial state” / config blobs, as long as you keep it inert (e.g., application/json) and treat parsed values safely downstream. [6]

Important gotchas (where people get hurt)

  1. A <script> without an inert type is executable by design. If you render <script>{attackerControlledString}</script>, React escaping doesn’t “save” you—the browser will execute whatever script text you put there.
  2. Breaking out of a <script> tag is a real concern when embedding JSON into HTML. If the embedded JSON contains </script>, it can terminate the tag early and inject markup/script. Libraries exist specifically to make JSON safe to embed inside script elements by ensuring substrings like </script> don’t appear. [7]
  3. JSON.parse is preferred over eval for security. OWASP explicitly recommends using JSON.parse for JSON parsing (and not eval). [6]

Practical recommendation

  • If you need to render rich HTML: avoid dangerouslySetInnerHTML unless you sanitize and understand the risk. [2][5]
  • If you only need to pass data to the client: prefer inert JSON in <script type="application/json"> + JSON.parse(textContent), and ensure your embedding strategy can’t be broken by </script> (or use a proven “safe JSON for HTML/script embedding” utility). [1][6][7]

Sources: React escaping in JSX [1]; React dangerouslySetInnerHTML XSS risk [2][5]; OWASP on JSON.parse vs eval [6]; OWASP JSON embedding safety properties [7].


🏁 Script executed:

# Check Biome's security rules more carefully
rg "dangerouslySetInnerHTML|noDangerousInnerHtml" --type json

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

# Search for any eslint/biome configurations that might override rules
rg "dangerouslySetInnerHTML" --type js --type json -i | head -20

Repository: theopenco/llmgateway

Length of output: 46


Add escaping to JSON-LD to prevent script breakout, but keep dangerouslySetInnerHTML.

The current code uses dangerouslySetInnerHTML without escaping, which leaves it vulnerable to </script> breakout if the schema contains untrusted content. However, the suggested alternative (rendering JSON as script children) won't work—React will escape the JSON string, breaking its parsing by search engines.

Per Next.js official guidance, add escaping to safely handle the < character:

✅ Correct fix (escaping, not removal)
			<script
				type="application/ld+json"
				dangerouslySetInnerHTML={{
-					__html: JSON.stringify(articleSchema),
+					__html: JSON.stringify(articleSchema).replace(/</g, "\\u003c"),
				}}
			/>
			<script
				type="application/ld+json"
				dangerouslySetInnerHTML={{
-					__html: JSON.stringify(breadcrumbSchema),
+					__html: JSON.stringify(breadcrumbSchema).replace(/</g, "\\u003c"),
				}}
			/>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleSchema).replace(/</g, "\\u003c"),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema).replace(/</g, "\\u003c"),
}}
/>
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 94-94: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)


[warning] 100-100: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.1.2)

[error] 95-95: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)


[error] 101-101: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

🤖 Prompt for AI Agents
In `@apps/ui/src/app/guides/`[slug]/page.tsx around lines 93 - 104, The JSON-LD
injected with dangerouslySetInnerHTML (used for articleSchema and
breadcrumbSchema in page.tsx) isn't escaped and can be vulnerable to </script>
breakout; fix it by serializing each schema with JSON.stringify(...) and then
replacing "<" with the escaped sequence before assigning to __html (e.g.,
JSON.stringify(articleSchema).replace(/</g, '\\u003c') and likewise for
breadcrumbSchema) so the script content remains valid JSON-LD for crawlers while
preventing script-breakout.

<HeroRSC navbarOnly />
<div className="min-h-screen bg-white text-black dark:bg-black dark:text-white pt-30">
<main className="container mx-auto px-4 py-8">
Expand Down
98 changes: 92 additions & 6 deletions apps/ui/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,101 @@ export const dynamic = "force-dynamic";

export const metadata: Metadata = {
metadataBase: new URL("https://llmgateway.io"),
title: "LLM Gateway",
title: {
default: "LLM Gateway - Unified API for Multiple LLM Providers",
template: "%s | LLM Gateway",
},
description:
"Route, manage, and analyze your LLM requests across multiple providers with a unified API interface.",
"Route, manage, and analyze your LLM requests across multiple providers with a unified API interface. Access OpenAI, Anthropic, Google, and 19+ providers through one API.",
keywords: [
"LLM",
"API Gateway",
"OpenAI",
"Anthropic",
"Claude",
"GPT-4",
"AI API",
"LLM Routing",
"Multi-provider LLM",
"AI Gateway",
],
authors: [{ name: "LLM Gateway" }],
creator: "LLM Gateway",
publisher: "LLM Gateway",
icons: {
icon: "/favicon/favicon.ico?v=2",
icon: [
{ url: "/favicon/favicon.ico", sizes: "any" },
{ url: "/favicon/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon/favicon-32x32.png", sizes: "32x32", type: "image/png" },
],
apple: [{ url: "/favicon/apple-touch-icon.png", sizes: "180x180" }],
},
manifest: "/favicon/site.webmanifest",
alternates: {
canonical: "./",
},
openGraph: {
title: "LLM Gateway",
title: "LLM Gateway - Unified API for Multiple LLM Providers",
description:
"Route, manage, and analyze your LLM requests across multiple providers with a unified API interface.",
"Route, manage, and analyze your LLM requests across multiple providers with a unified API interface. Access OpenAI, Anthropic, Google, and 19+ providers through one API.",
images: ["/opengraph.png?v=1"],
type: "website",
url: "https://llmgateway.io",
siteName: "LLM Gateway",
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: "LLM Gateway",
title: "LLM Gateway - Unified API for Multiple LLM Providers",
description:
"Route, manage, and analyze your LLM requests across multiple providers with a unified API interface.",
images: ["/opengraph.png?v=1"],
creator: "@llmgateway",
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};

const organizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
name: "LLM Gateway",
url: "https://llmgateway.io",
logo: "https://llmgateway.io/favicon/android-chrome-512x512.png",
description:
"Route, manage, and analyze your LLM requests across multiple providers with a unified API interface.",
sameAs: [
"https://twitter.com/llmgateway",
"https://github.com/llmgateway/llmgateway",
],
contactPoint: {
"@type": "ContactPoint",
email: "contact@llmgateway.io",
contactType: "customer support",
},
};

const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "LLM Gateway",
url: "https://llmgateway.io",
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: "https://llmgateway.io/models?search={search_term_string}",
},
"query-input": "required name=search_term_string",
},
};
Comment on lines +90 to 122

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check models page for search parameter handling
cat apps/ui/src/app/models/page.tsx

Repository: theopenco/llmgateway

Length of output: 1343


🏁 Script executed:

# Search for search parameter usage across the codebase
rg "searchParams|search_term" apps/ui/src/app/

Repository: theopenco/llmgateway

Length of output: 3570


🏁 Script executed:

# Check for any GitHub repository reference
rg "github.com/llmgateway" --type ts --type tsx

Repository: theopenco/llmgateway

Length of output: 91


🏁 Script executed:

# Check git remote to verify actual repository
git config --get remote.origin.url 2>/dev/null || echo "Not a git repo or no origin"

Repository: theopenco/llmgateway

Length of output: 107


🏁 Script executed:

# Check the AllModels component for search functionality
find apps/ui/src/components -name "*all-models*" -o -name "*AllModels*"

Repository: theopenco/llmgateway

Length of output: 108


🏁 Script executed:

# Search for search implementation in models components
rg -n "search" apps/ui/src/components/models/ -A 3 -B 1

Repository: theopenco/llmgateway

Length of output: 14002


Fix two issues in the JSON-LD schemas: incorrect GitHub URL and mismatched search parameter.

  1. Line 100 - GitHub URL: Change https://github.com/llmgateway/llmgateway to https://github.com/theopenco/llmgateway (the repository uses the theopenco organization).

  2. Line 118 - SearchAction parameter: Change urlTemplate: "https://llmgateway.io/models?search={search_term_string}" to use ?q= instead, since the AllModels component reads the query parameter as q (see all-models.tsx line 126: searchParams.get("q")). The template should be "https://llmgateway.io/models?q={search_term_string}" to match the actual search implementation.

The dangerouslySetInnerHTML warnings are false positives—all data is developer-controlled.

🤖 Prompt for AI Agents
In `@apps/ui/src/app/layout.tsx` around lines 90 - 122, Update the JSON-LD
objects: in organizationSchema replace the GitHub URL value
"https://github.com/llmgateway/llmgateway" with
"https://github.com/theopenco/llmgateway", and in websiteSchema (potentialAction
-> target -> urlTemplate) change the query param from
"...?search={search_term_string}" to use the q parameter
"...?q={search_term_string}" so the urlTemplate matches the AllModels search
param; keep the rest of organizationSchema and websiteSchema unchanged.


Expand All @@ -54,6 +126,20 @@ export default function RootLayout({ children }: { children: ReactNode }) {

return (
<html lang="en" suppressHydrationWarning>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(organizationSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(websiteSchema),
}}
/>
</head>
<body
className={`${inter.variable} ${geistMono.variable} min-h-screen antialiased`}
>
Expand Down
Loading