Skip to content
Open
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
10 changes: 6 additions & 4 deletions app/api/og.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ function generateOgHtml(metadata: OgMetadata, url: string): string {
const twitterHandle = '@ThePolicyEngine';
const safeTitle = escapeHtml(metadata.title);
const safeDescription = escapeHtml(metadata.description);
const safeUrl = escapeHtml(url);
const safeImage = escapeHtml(metadata.image);

return `<!DOCTYPE html>
<html lang="en">
Expand All @@ -91,20 +93,20 @@ function generateOgHtml(metadata: OgMetadata, url: string): string {
<meta name="description" content="${safeDescription}" />
<meta property="og:title" content="${safeTitle}" />
<meta property="og:description" content="${safeDescription}" />
<meta property="og:image" content="${metadata.image}" />
<meta property="og:url" content="${url}" />
<meta property="og:image" content="${safeImage}" />
<meta property="og:url" content="${safeUrl}" />
<meta property="og:type" content="${metadata.type}" />
<meta property="og:site_name" content="${siteName}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="${twitterHandle}" />
<meta name="twitter:title" content="${safeTitle}" />
<meta name="twitter:description" content="${safeDescription}" />
<meta name="twitter:image" content="${metadata.image}" />
<meta name="twitter:image" content="${safeImage}" />
</head>
<body>
<h1>${safeTitle}</h1>
<p>${safeDescription}</p>
<p><a href="${url}">View on PolicyEngine</a></p>
<p><a href="${safeUrl}">View on PolicyEngine</a></p>
</body>
</html>`;
}
Expand Down
57 changes: 42 additions & 15 deletions app/src/components/blog/MarkdownFormatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

import React, { useEffect, useRef, useState } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import OptimisedImage from '@/components/ui/OptimisedImage';
import type { MarkdownFormatterProps } from '@/types/blog';
Expand Down Expand Up @@ -56,6 +55,30 @@ function safeJsonParse(data: string | string[]): any {
}
}

function isSafeHref(href: string): boolean {
const trimmed = href.trim();

if (!trimmed) {
return false;
}

if (
trimmed.startsWith('#') ||
trimmed.startsWith('/') ||
trimmed.startsWith('?') ||
trimmed.startsWith('//')
) {
return true;
}

try {
const url = new URL(trimmed);
return ['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol);
} catch {
return false;
}
}

/**
* Custom Table Cell Component
*/
Expand Down Expand Up @@ -889,7 +912,7 @@ export function MarkdownFormatter({
};

return (
<ReactMarkdown rehypePlugins={[rehypeRaw]} remarkPlugins={[remarkGfm]} components={components}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{markdown}
</ReactMarkdown>
);
Expand All @@ -912,19 +935,23 @@ function parseInlineLinks(text: string): React.ReactNode[] {
}
// Add the link
parts.push(
<a
key={match.index}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
style={{
color: blogColors.link,
textDecoration: 'none',
borderBottom: `1px solid ${blogColors.link}`,
}}
>
{match[1]}
</a>
isSafeHref(match[2]) ? (
<a
key={match.index}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
style={{
color: blogColors.link,
textDecoration: 'none',
borderBottom: `1px solid ${blogColors.link}`,
}}
>
{match[1]}
</a>
) : (
<span key={match.index}>{match[1]}</span>
)
);
lastIndex = match.index + match[0].length;
}
Expand Down
33 changes: 33 additions & 0 deletions app/src/tests/unit/api/og.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test, vi } from 'vitest';

import {
MOCK_APP_WITH_IMAGE,
MOCK_APP_WITHOUT_IMAGE,
MOCK_POST,
MOCK_POST_WITHOUT_IMAGE,
} from '@/tests/fixtures/middleware/ogMocks';

vi.mock('../../../../src/data/posts/posts.json', () => ({
default: [MOCK_POST, MOCK_POST_WITHOUT_IMAGE],
}));

vi.mock('../../../../src/data/apps/apps.json', () => ({
default: [MOCK_APP_WITH_IMAGE, MOCK_APP_WITHOUT_IMAGE],
}));

import ogHandler from '../../../../api/og';

describe('app/api/og', () => {
test('escapes reflected url content in og html', async () => {
const request = new Request(
'https://policyengine.org/api/og?path=/us%22%20onmouseover%3D%22alert(1)'
);

const response = await ogHandler(request);
const html = await response.text();

expect(response.status).toBe(200);
expect(html).toContain('https://policyengine.org/us&quot; onmouseover=&quot;alert(1)');
expect(html).not.toContain('onmouseover="alert(1)');
});
});
32 changes: 32 additions & 0 deletions app/src/tests/unit/components/blog/MarkdownFormatter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ describe('MarkdownFormatter', () => {
// Then
expect(screen.getByRole('list')).toBeInTheDocument();
});

test('given raw HTML markdown then does not render raw elements', () => {
// Given
const markdown = 'Paragraph before HTML.\n\n<img src="x" alt="malicious" onerror="alert(1)" />';

// When
render(<MarkdownFormatter markdown={markdown} />);

// Then
expect(screen.getByText('Paragraph before HTML.')).toBeInTheDocument();
expect(screen.queryByRole('img', { name: 'malicious' })).toBeNull();
});
});

describe('FootnotesSection component', () => {
Expand Down Expand Up @@ -138,5 +150,25 @@ describe('MarkdownFormatter', () => {
'https://two.com'
);
});

test('given footnote with javascript href then keeps text but not a link', () => {
// Given
const footnotes = {
1: 'Read the [source](javascript:alert) before trusting it.',
};

// When
render(<FootnotesSection footnotes={footnotes} />);

// Then
expect(
screen.getByText(
(_, element) =>
element?.tagName.toLowerCase() === 'li' &&
element.textContent?.includes('Read the source before trusting it.') === true
)
).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'source' })).toBeNull();
});
});
});
10 changes: 6 additions & 4 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ function generateOgHtml(metadata: OgMetadata, url: string): string {
const twitterHandle = "@ThePolicyEngine";
const safeTitle = escapeHtml(metadata.title);
const safeDescription = escapeHtml(metadata.description);
const safeUrl = escapeHtml(url);
const safeImage = escapeHtml(metadata.image);
const jsonLd = generateJsonLd(metadata, url);

return `<!DOCTYPE html>
Expand All @@ -220,8 +222,8 @@ function generateOgHtml(metadata: OgMetadata, url: string): string {
<!-- Open Graph -->
<meta property="og:title" content="${safeTitle}" />
<meta property="og:description" content="${safeDescription}" />
<meta property="og:image" content="${metadata.image}" />
<meta property="og:url" content="${url}" />
<meta property="og:image" content="${safeImage}" />
<meta property="og:url" content="${safeUrl}" />
<meta property="og:type" content="${metadata.type}" />
<meta property="og:site_name" content="${siteName}" />

Expand All @@ -230,15 +232,15 @@ function generateOgHtml(metadata: OgMetadata, url: string): string {
<meta name="twitter:site" content="${twitterHandle}" />
<meta name="twitter:title" content="${safeTitle}" />
<meta name="twitter:description" content="${safeDescription}" />
<meta name="twitter:image" content="${metadata.image}" />
<meta name="twitter:image" content="${safeImage}" />

<!-- Structured Data -->
<script type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, "\\u003c")}</script>
</head>
<body>
<h1>${safeTitle}</h1>
<p>${safeDescription}</p>
<p><a href="${url}">View on PolicyEngine</a></p>
<p><a href="${safeUrl}">View on PolicyEngine</a></p>
</body>
</html>`;
}
Expand Down
61 changes: 42 additions & 19 deletions website/src/components/blog/MarkdownFormatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import {
blogColors,
Expand All @@ -35,6 +34,30 @@ function safeJsonParse(
}
}

function isSafeHref(href: string): boolean {
const trimmed = href.trim();

if (!trimmed) {
return false;
}

if (
trimmed.startsWith("#") ||
trimmed.startsWith("/") ||
trimmed.startsWith("?") ||
trimmed.startsWith("//")
) {
return true;
}

try {
const url = new URL(trimmed);
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol);
} catch {
return false;
}
}

/* ------------------------------------------------------------------ */
/* Table components */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -829,11 +852,7 @@ export function MarkdownFormatter({
};

return (
<ReactMarkdown
rehypePlugins={[rehypeRaw]}
remarkPlugins={[remarkGfm]}
components={components}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{markdown}
</ReactMarkdown>
);
Expand All @@ -854,19 +873,23 @@ function parseInlineLinks(text: string): React.ReactNode[] {
parts.push(text.slice(lastIndex, match.index));
}
parts.push(
<a
key={match.index}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
style={{
color: blogColors.link,
textDecoration: "none",
borderBottom: `1px solid ${blogColors.link}`,
}}
>
{match[1]}
</a>,
isSafeHref(match[2]) ? (
<a
key={match.index}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
style={{
color: blogColors.link,
textDecoration: "none",
borderBottom: `1px solid ${blogColors.link}`,
}}
>
{match[1]}
</a>
) : (
<span key={match.index}>{match[1]}</span>
),
);
lastIndex = match.index + match[0].length;
}
Expand Down
Loading