diff --git a/app/api/og.ts b/app/api/og.ts index 101a46dce..6151e4a2e 100644 --- a/app/api/og.ts +++ b/app/api/og.ts @@ -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 ` @@ -91,20 +93,20 @@ function generateOgHtml(metadata: OgMetadata, url: string): string { - - + + - +

${safeTitle}

${safeDescription}

-

View on PolicyEngine

+

View on PolicyEngine

`; } diff --git a/app/src/components/blog/MarkdownFormatter.tsx b/app/src/components/blog/MarkdownFormatter.tsx index 8d3ba1a27..0f0068da6 100644 --- a/app/src/components/blog/MarkdownFormatter.tsx +++ b/app/src/components/blog/MarkdownFormatter.tsx @@ -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'; @@ -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 */ @@ -889,7 +912,7 @@ export function MarkdownFormatter({ }; return ( - + {markdown} ); @@ -912,19 +935,23 @@ function parseInlineLinks(text: string): React.ReactNode[] { } // Add the link parts.push( - - {match[1]} - + isSafeHref(match[2]) ? ( + + {match[1]} + + ) : ( + {match[1]} + ) ); lastIndex = match.index + match[0].length; } diff --git a/app/src/tests/unit/api/og.test.ts b/app/src/tests/unit/api/og.test.ts new file mode 100644 index 000000000..916a4a7b6 --- /dev/null +++ b/app/src/tests/unit/api/og.test.ts @@ -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" onmouseover="alert(1)'); + expect(html).not.toContain('onmouseover="alert(1)'); + }); +}); diff --git a/app/src/tests/unit/components/blog/MarkdownFormatter.test.tsx b/app/src/tests/unit/components/blog/MarkdownFormatter.test.tsx index ca3caa24d..56c57d84c 100644 --- a/app/src/tests/unit/components/blog/MarkdownFormatter.test.tsx +++ b/app/src/tests/unit/components/blog/MarkdownFormatter.test.tsx @@ -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\nmalicious'; + + // When + render(); + + // Then + expect(screen.getByText('Paragraph before HTML.')).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'malicious' })).toBeNull(); + }); }); describe('FootnotesSection component', () => { @@ -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(); + + // 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(); + }); }); }); diff --git a/middleware.ts b/middleware.ts index 4b5575b3e..6f9d4da14 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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 ` @@ -220,8 +222,8 @@ function generateOgHtml(metadata: OgMetadata, url: string): string { - - + + @@ -230,7 +232,7 @@ function generateOgHtml(metadata: OgMetadata, url: string): string { - + @@ -238,7 +240,7 @@ function generateOgHtml(metadata: OgMetadata, url: string): string {

${safeTitle}

${safeDescription}

-

View on PolicyEngine

+

View on PolicyEngine

`; } diff --git a/website/src/components/blog/MarkdownFormatter.tsx b/website/src/components/blog/MarkdownFormatter.tsx index da9303ca7..ee2aeffd6 100644 --- a/website/src/components/blog/MarkdownFormatter.tsx +++ b/website/src/components/blog/MarkdownFormatter.tsx @@ -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, @@ -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 */ /* ------------------------------------------------------------------ */ @@ -829,11 +852,7 @@ export function MarkdownFormatter({ }; return ( - + {markdown} ); @@ -854,19 +873,23 @@ function parseInlineLinks(text: string): React.ReactNode[] { parts.push(text.slice(lastIndex, match.index)); } parts.push( - - {match[1]} - , + isSafeHref(match[2]) ? ( + + {match[1]} + + ) : ( + {match[1]} + ), ); lastIndex = match.index + match[0].length; }