Skip to content
Merged
69 changes: 51 additions & 18 deletions apps/activitypub/src/utils/content-formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,18 @@ export function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html);
}

// Must stay in sync with allowedScriptHostnames in the backend sanitizer
// (https://github.com/TryGhost/ActivityPub/blob/main/src/helpers/html.ts)
const ALLOWED_SCRIPT_HOSTNAMES = ['platform.twitter.com', 'platform.x.com'];

// Article content needs looser rules than sanitizeHtml (iframes for YouTube
// embeds, scripts for Twitter embeds), so it gets its own DOMPurify instance
const articlePurify = DOMPurify(window);

articlePurify.addHook('uponSanitizeElement', (node, data) => {
if (data.tagName !== 'script') {
return;
}

const element = node as Element;
const src = element.getAttribute('src') || '';
// Removes any <script> unless it loads a Twitter/X embed widget from an allowed
// host; allowed scripts keep only their src, never inline code.
function stripDisallowedScript(element: Element): void {
// Must stay in sync with allowedScriptHostnames in the backend sanitizer
// (https://github.com/TryGhost/ActivityPub/blob/main/src/helpers/html.ts)
const allowedScriptHostnames = ['platform.twitter.com', 'platform.x.com'];

let hasAllowedSrc = false;
try {
// Relative URLs must not pass, so no base URL here
const url = new URL(src);
hasAllowedSrc = url.protocol === 'https:' && ALLOWED_SCRIPT_HOSTNAMES.includes(url.hostname);
const url = new URL(element.getAttribute('src') || '');
hasAllowedSrc = url.protocol === 'https:' && allowedScriptHostnames.includes(url.hostname);
} catch {
hasAllowedSrc = false;
}
Expand All @@ -56,12 +47,54 @@ articlePurify.addHook('uponSanitizeElement', (node, data) => {

// Allowed scripts may only load code via src, never run inline code
element.textContent = '';
}

// Removes iframes we can't safely embed and forces a restrictive sandbox on the rest.
function sandboxIframe(element: Element): void {
// Only keep iframes with an absolute, cross-origin http(s) source. A relative
// or same-origin src would run same-origin with Ghost Admin, where allow-scripts
// + allow-same-origin can defeat the sandbox. This also rejects javascript:/data:
// and other non-http(s) schemes.
let isCrossOrigin = false;
try {
const url = new URL(element.getAttribute('src') || '', window.location.href);
isCrossOrigin =
(url.protocol === 'https:' || url.protocol === 'http:') &&
url.origin !== window.location.origin;
} catch {
isCrossOrigin = false;
}

if (!isCrossOrigin) {
element.parentNode?.removeChild(element);
return;
}

// Force a restrictive sandbox onto the (now verified cross-origin) frame,
// overriding any supplied value. Omits allow-top-navigation (no tab hijacking);
// allow-same-origin is safe here because the frame is cross-origin. Set before
// attribute sanitization so it survives via ADD_ATTR.
element.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-presentation allow-forms');
}

// Article content needs looser rules than sanitizeHtml (iframes for YouTube
// embeds, scripts for Twitter embeds), so it gets its own DOMPurify instance
const articlePurify = DOMPurify(window);

articlePurify.addHook('uponSanitizeElement', (node, data) => {
const element = node as Element;

if (data.tagName === 'script') {
stripDisallowedScript(element);
} else if (data.tagName === 'iframe') {
sandboxIframe(element);
}
});

export function sanitizeArticleContent(content: string): string {
return articlePurify.sanitize(content, {
ADD_TAGS: ['iframe', 'script'],
ADD_ATTR: ['target', 'frameborder', 'allowfullscreen', 'async', 'charset'],
ADD_ATTR: ['target', 'frameborder', 'allowfullscreen', 'async', 'charset', 'sandbox'],
// Without this the HTML parser hoists leading <script>/<style> tags
// into <head>, which DOMPurify then discards — content starting with
// an embed script would lose it
Expand Down
67 changes: 61 additions & 6 deletions apps/activitypub/test/unit/utils/content-formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,74 @@ describe('Content Formatters', function () {
expect(iframe.hasAttribute('allowfullscreen')).toBe(true);
});

it('removes unsafe iframe attributes and protocols', function () {
it('forces a restrictive sandbox on iframes, overriding any supplied value', function () {
const sandbox = 'allow-scripts allow-same-origin allow-popups allow-presentation allow-forms';

const result = sanitizeArticleContent(`
<iframe src="javascript:alert(1)"></iframe>
<iframe srcdoc="<script>window.__xss=true</script>" onload="window.__xss=true"></iframe>
<iframe src="https://codepen.io/x/embed/abc"></iframe>
<iframe src="https://evil.example/phish" sandbox="allow-top-navigation allow-modals allow-same-origin"></iframe>
`);

const iframes = renderHtml(result).querySelectorAll('iframe');

expect(iframes).toHaveLength(2);
iframes.forEach((iframe) => {
expect(iframe.getAttribute('src')).toBeNull();
expect(iframe.hasAttribute('srcdoc')).toBe(false);
expect(iframe.hasAttribute('onload')).toBe(false);
// Overridden to our fixed set (attacker's allow-top-navigation is gone)
expect(iframe.getAttribute('sandbox')).toBe(sandbox);
});
// Arbitrary embed hosts are kept (not host-filtered), just sandboxed
expect(iframes[0].getAttribute('src')).toBe('https://codepen.io/x/embed/abc');
expect(iframes[1].getAttribute('src')).toBe('https://evil.example/phish');
});

it('does not preserve author-supplied referrerpolicy on non-iframe elements', function () {
const result = sanitizeArticleContent(
'<img src="https://example.com/image.png" referrerpolicy="unsafe-url">'
);

const img = renderHtml(result).querySelector('img') as HTMLImageElement;

expect(img).not.toBeNull();
expect(img.hasAttribute('referrerpolicy')).toBe(false);
});

it('removes iframes with unsafe or non-http(s) sources', function () {
const result = sanitizeArticleContent(`
<iframe src="javascript:alert(1)"></iframe>
<iframe src="data:text/html,<script>window.__xss=true</script>"></iframe>
<iframe srcdoc="<script>window.__xss=true</script>" onload="window.__xss=true"></iframe>
`);

// None are absolute cross-origin http(s) embeds, so all are dropped
expect(renderHtml(result).querySelectorAll('iframe')).toHaveLength(0);
});

it('strips event handlers and srcdoc from surviving cross-origin iframes', function () {
const result = sanitizeArticleContent(
'<iframe src="https://player.vimeo.com/video/1" srcdoc="<script>window.__xss=true</script>" onload="window.__xss=true"></iframe>'
);

const iframe = renderHtml(result).querySelector('iframe') as HTMLIFrameElement;

expect(iframe).not.toBeNull();
expect(iframe.hasAttribute('srcdoc')).toBe(false);
expect(iframe.hasAttribute('onload')).toBe(false);
});

it('removes relative and same-origin iframes, keeping cross-origin embeds', function () {
// A same-origin frame would run same-origin with Ghost Admin, where
// allow-scripts + allow-same-origin can defeat the sandbox
const result = sanitizeArticleContent(`
<iframe src="/ghost/#/dashboard"></iframe>
<iframe src="${window.location.origin}/ghost/"></iframe>
<iframe src="foo.html"></iframe>
<iframe src="https://player.vimeo.com/video/123"></iframe>
`);

const iframes = renderHtml(result).querySelectorAll('iframe');

expect(iframes).toHaveLength(1);
expect(iframes[0].getAttribute('src')).toBe('https://player.vimeo.com/video/123');
});

it('keeps Twitter embed scripts from allowed hostnames', function () {
Expand Down

This file was deleted.

17 changes: 0 additions & 17 deletions apps/admin-x-design-system/src/global/chrome/desktop-chrome.tsx

This file was deleted.

This file was deleted.

17 changes: 0 additions & 17 deletions apps/admin-x-design-system/src/global/chrome/mobile-chrome.tsx

This file was deleted.

This file was deleted.

70 changes: 0 additions & 70 deletions apps/admin-x-design-system/src/global/modal/confirmation-modal.tsx

This file was deleted.

Loading
Loading