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
Binary file added packages/chronicle/src/fonts/Inter-Regular.ttf
Binary file not shown.
3 changes: 2 additions & 1 deletion packages/chronicle/src/pages/DocsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export function DocsPage({ slug }: DocsPageProps) {
'@type': 'Article',
headline: page.frontmatter.title,
description: page.frontmatter.description,
...(pageUrl && { url: pageUrl })
...(pageUrl && { url: pageUrl }),
...(page.frontmatter.lastModified && { dateModified: new Date(page.frontmatter.lastModified).toISOString() }),
}}
/>
<Page
Expand Down
8 changes: 8 additions & 0 deletions packages/chronicle/src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FolderIcon } from '@heroicons/react/24/outline';
import { Link as RouterLink } from 'react-router';
import { getLandingEntries } from '@/lib/config';
import { Head } from '@/lib/head';
import { usePageContext } from '@/lib/page-context';
import styles from './LandingPage.module.css';

Expand All @@ -13,6 +14,12 @@ export function LandingPage() {
: `${config.site.title} — ${versionLabel(config, version.dir)}`;

return (
<>
<Head
title={version.dir ? `${config.site.title} — ${versionLabel(config, version.dir)}` : 'Documentation'}
description={config.site.description}
config={config}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<div className={styles.root}>
<div className={styles.header}>
<h1 className={styles.title}>{heading}</h1>
Expand Down Expand Up @@ -42,6 +49,7 @@ export function LandingPage() {
))}
</div>
</div>
</>
);
}

Expand Down
36 changes: 19 additions & 17 deletions packages/chronicle/src/server/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara';
import { lazy, Suspense } from 'react';
import { Navigate, useLocation } from 'react-router';
import { AnalyticsProvider } from '@/components/analytics/AnalyticsProvider';
import { Head } from '@/lib/head';
import { usePageContext } from '@/lib/page-context';
import { resolveRoute, RouteType } from '@/lib/route-resolver';
import type { ChronicleConfig } from '@/types';
Expand Down Expand Up @@ -69,22 +68,25 @@ function PageFallback() {
}

function RootHead({ config }: { config: ChronicleConfig }) {
return (
<Head
title={config.site.title}
description={config.site.description}
config={config}
jsonLd={
config.url
? {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: config.site.title,
description: config.site.description,
url: config.url
}
: undefined
const siteJsonLd = config.url
? {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: config.site.title,
description: config.site.description,
url: config.url,
}
/>
: null;

return (
<>
<title>{config.site.title}</title>
{siteJsonLd && (
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(siteJsonLd, null, 2) }}
Comment on lines +85 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
import json
payload = {"name": "</script><script>alert(1)</script>"}
rendered = json.dumps(payload, indent=2)
print(rendered)
assert "</script>" in rendered, "Expected raw closing script tag in JSON.stringify-equivalent output"
PY

rg -n -C2 "dangerouslySetInnerHTML=\{\{ __html: JSON.stringify" packages/chronicle/src

Repository: raystack/chronicle

Length of output: 826


Security: escape JSON-LD output before injecting into the application/ld+json <script> tag.

dangerouslySetInnerHTML is populated with JSON.stringify(...) in both packages/chronicle/src/server/App.tsx and packages/chronicle/src/lib/head.tsx, so any config-derived value containing </script> (via unescaped <) can break out of the JSON-LD script block and enable script injection. Centralize an escaped serializer and use it in both places.

🔒 Suggested fix
+function serializeJsonLd(value: unknown) {
+  return JSON.stringify(value, null, 2)
+    .replace(/</g, '\\u003c')
+    .replace(/\u2028/g, '\\u2028')
+    .replace(/\u2029/g, '\\u2029');
+}
+
 function RootHead({ config }: { config: ChronicleConfig }) {
   const siteJsonLd = config.url
     ? {
         '`@context`': 'https://schema.org',
         '`@type`': 'WebSite',
         name: config.site.title,
         description: config.site.description,
         url: config.url,
       }
     : null;

   return (
     <>
       <title>{config.site.title}</title>
       {siteJsonLd && (
         <script
           type='application/ld+json'
-          dangerouslySetInnerHTML={{ __html: JSON.stringify(siteJsonLd, null, 2) }}
+          dangerouslySetInnerHTML={{ __html: serializeJsonLd(siteJsonLd) }}
         />
       )}
     </>
   );
 }

Apply the same serializeJsonLd(...) usage to packages/chronicle/src/lib/head.tsx (preferably via a shared helper used by both files).

🧰 Tools
🪛 ast-grep (0.43.0)

[warning] 86-86: 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)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/server/App.tsx` around lines 85 - 87, Replace raw
JSON.stringify usage for JSON-LD injection with a shared escaped serializer:
implement and export a serializeJsonLd(value: unknown) function that calls
JSON.stringify(value, null, 2) and then escapes problematic sequences (at
minimum replace "</" with "<\/" and the Unicode U+2028/U+2029 characters) to
prevent breaking out of the <script type="application/ld+json"> block; update
the App component's siteJsonLd injection (dangerouslySetInnerHTML) and the Head
component's JSON-LD injection to use serializeJsonLd(...) instead of
JSON.stringify(...), and import the shared helper where needed.

/>
)}
</>
);
}
32 changes: 32 additions & 0 deletions packages/chronicle/src/server/routes/og-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import fs from 'node:fs/promises';
import path from 'node:path';

const MIME_MAP: Record<string, string> = {
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
};

export function getLogoDataUri(data: Buffer, filePath: string): string | null {
const ext = path.extname(filePath).toLowerCase();
const mime = MIME_MAP[ext];
if (!mime) return null;
return `data:${mime};base64,${data.toString('base64')}`;
}

export async function loadLogo(projectRoot: string, logoPath: string): Promise<string | null> {
try {
const filePath = path.resolve(projectRoot, 'public', logoPath.replace(/^\//, ''));
const data = await fs.readFile(filePath);
return getLogoDataUri(data, filePath);
} catch {
return null;
}
}

export async function loadFont(packageRoot: string): Promise<ArrayBuffer> {
const fontPath = path.resolve(packageRoot, 'src/fonts/Inter-Regular.ttf');
const buffer = await fs.readFile(fontPath);
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
63 changes: 63 additions & 0 deletions packages/chronicle/src/server/routes/og.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test } from 'bun:test';
import path from 'node:path';
import { getLogoDataUri, loadLogo, loadFont } from './og-utils';

const PACKAGE_ROOT = path.resolve(__dirname, '../../..');
const FIXTURES = path.resolve(__dirname, '__fixtures__');

describe('getLogoDataUri', () => {
test('svg file returns svg mime type', () => {
const data = Buffer.from('<svg></svg>');
const result = getLogoDataUri(data, '/logo.svg');
expect(result).toStartWith('data:image/svg+xml;base64,');
});

test('png file returns png mime type', () => {
const data = Buffer.from('fake-png');
const result = getLogoDataUri(data, '/logo.png');
expect(result).toStartWith('data:image/png;base64,');
});

test('jpg file returns jpeg mime type', () => {
const data = Buffer.from('fake-jpg');
const result = getLogoDataUri(data, '/photo.jpg');
expect(result).toStartWith('data:image/jpeg;base64,');
});

test('returns null for unsupported format', () => {
const data = Buffer.from('fake-webp');
const result = getLogoDataUri(data, '/logo.webp');
expect(result).toBeNull();
});

test('encodes data as base64', () => {
const content = '<svg xmlns="http://www.w3.org/2000/svg"></svg>';
const data = Buffer.from(content);
const result = getLogoDataUri(data, '/icon.svg');
const base64 = result!.split(',')[1];
expect(Buffer.from(base64, 'base64').toString()).toBe(content);
});
});

describe('loadLogo', () => {
test('returns null for nonexistent file', async () => {
const result = await loadLogo('/nonexistent', '/logo.svg');
expect(result).toBeNull();
});

test('strips leading slash from logo path', async () => {
const result = await loadLogo('/nonexistent', '/nested/logo.svg');
expect(result).toBeNull();
});
});

describe('loadFont', () => {
test('loads Inter font from package', async () => {
const font = await loadFont(PACKAGE_ROOT);
expect(font.byteLength).toBeGreaterThan(0);
});

test('throws for invalid path', async () => {
expect(loadFont('/nonexistent')).rejects.toThrow();
});
});
40 changes: 21 additions & 19 deletions packages/chronicle/src/server/routes/og.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,23 @@ import { defineHandler } from 'nitro';
import React from 'react';
import satori from 'satori';
import { loadConfig } from '@/lib/config';
import { loadFont, loadLogo } from './og-utils';

let fontData: ArrayBuffer | null = null;

async function loadFont(): Promise<ArrayBuffer> {
if (fontData) return fontData;

try {
const response = await fetch(
'https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2'
);
fontData = await response.arrayBuffer();
} catch {
fontData = new ArrayBuffer(0);
}

return fontData;
}
let cachedLogo: string | null | undefined;

export default defineHandler(async event => {
const config = loadConfig();
const title = event.url.searchParams.get('title') ?? config.site.title;
const description = event.url.searchParams.get('description') ?? '';
const siteName = config.site.title;

const font = await loadFont();
if (!fontData) fontData = await loadFont(__CHRONICLE_PACKAGE_ROOT__);
if (cachedLogo === undefined) {
cachedLogo = config.logo?.dark
? await loadLogo(__CHRONICLE_PROJECT_ROOT__, config.logo.dark)
: null;
}

const svg = await satori(
<div
Expand All @@ -41,8 +33,18 @@ export default defineHandler(async event => {
color: '#fafafa',
}}
>
<div style={{ fontSize: 24, color: '#888', marginBottom: 16 }}>
{siteName}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
{cachedLogo && (
<img
src={cachedLogo}
width={48}
height={48}
style={{ marginRight: 16 }}
/>
)}
<div style={{ fontSize: 32, color: '#888' }}>
{siteName}
</div>
</div>
<div
style={{
Expand All @@ -64,7 +66,7 @@ export default defineHandler(async event => {
width: 1200,
height: 630,
fonts: [
{ name: 'Inter', data: font, weight: 400, style: 'normal' as const },
{ name: 'Inter', data: fontData, weight: 400, style: 'normal' as const },
Comment thread
rsbh marked this conversation as resolved.
],
},
);
Expand Down
1 change: 1 addition & 0 deletions packages/chronicle/src/server/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export async function createViteConfig(
define: {
__CHRONICLE_CONTENT_DIR__: JSON.stringify(contentMirror),
__CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
__CHRONICLE_PACKAGE_ROOT__: JSON.stringify(packageRoot),
__CHRONICLE_CONFIG_RAW__: JSON.stringify(rawConfig),
},
css: {
Expand Down
1 change: 1 addition & 0 deletions packages/chronicle/src/types/globals.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Vite build-time constants (injected via define in vite-config.ts)
declare const __CHRONICLE_CONTENT_DIR__: string
declare const __CHRONICLE_PROJECT_ROOT__: string
declare const __CHRONICLE_PACKAGE_ROOT__: string
declare const __CHRONICLE_CONFIG_RAW__: string | null
Loading