diff --git a/apps/activitypub/src/utils/content-formatters.ts b/apps/activitypub/src/utils/content-formatters.ts
index 2661a6b783d..080b77693e2 100644
--- a/apps/activitypub/src/utils/content-formatters.ts
+++ b/apps/activitypub/src/utils/content-formatters.ts
@@ -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 " onload="window.__xss=true">
+
+
`);
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(
+ '
'
+ );
+
+ 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(`
+
+
+
+ `);
+
+ // 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(
+ ''
+ );
+
+ 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(`
+
+
+
+
+ `);
+
+ 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 () {
diff --git a/apps/admin-x-design-system/src/global/chrome/desktop-chrome.stories.tsx b/apps/admin-x-design-system/src/global/chrome/desktop-chrome.stories.tsx
deleted file mode 100644
index f28afed48e6..00000000000
--- a/apps/admin-x-design-system/src/global/chrome/desktop-chrome.stories.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import {ReactNode} from 'react';
-import type {Meta, StoryObj} from '@storybook/react-vite';
-
-import DesktopChrome from './desktop-chrome';
-
-const meta = {
- title: 'Global / Chrome / Desktop Chrome',
- component: DesktopChrome,
- tags: ['autodocs'],
- decorators: [(_story: () => ReactNode) => (
This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text.
- -This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text. This is a desktop chrome with lots of text.
-This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text.
- -This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text. This is a mobile chrome with lots of text.
-Use this component to in full-width or bleed modals in which you build a complete page (e.g. Theme grid)
- > - } -}; diff --git a/apps/admin-x-design-system/src/global/modal/modal-page.tsx b/apps/admin-x-design-system/src/global/modal/modal-page.tsx deleted file mode 100644 index 66cedc539c9..00000000000 --- a/apps/admin-x-design-system/src/global/modal/modal-page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import clsx from 'clsx'; -import React from 'react'; -import {Text} from '@tryghost/shade/primitives'; - -export interface ModalPageProps { - heading?: string; - children?: React.ReactNode; - className?: string; -} - -const ModalPage: React.FCThis is a full page in a modal
-Use these {isDataAttributes ? 'data attributes' : 'links'} in your theme to show pages of Portal.
Use these links in your theme to open pages of Portal.
+Theme content
+