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
2 changes: 1 addition & 1 deletion apps/activitypub/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryghost/activitypub",
"version": "3.1.32",
"version": "3.1.33",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
6 changes: 3 additions & 3 deletions apps/activitypub/src/views/notifications/notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {Notification, isApiError} from '@src/api/activitypub';
import {handleProfileClick} from '@utils/handle-profile-click';
import {renderFeedAttachment} from '@components/feed/feed-item';
import {renderTimestamp} from '@src/utils/render-timestamp';
import {stripHtml} from '@src/utils/content-formatters';
import {sanitizeHtml, stripHtml} from '@src/utils/content-formatters';
import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path';
import {useNotificationsForUser} from '@hooks/use-activity-pub-queries';

Expand Down Expand Up @@ -192,7 +192,7 @@ const ProfileLinkedContent: React.FC<{

return (
<div
dangerouslySetInnerHTML={{__html: stripHtml(content || '', stripTags)}}
dangerouslySetInnerHTML={{__html: sanitizeHtml(stripHtml(content || '', stripTags))}}
ref={contentRef}
className={className}
/>
Expand Down Expand Up @@ -436,7 +436,7 @@ const Notifications: React.FC = () => {
(group.type !== 'reply' && group.type !== 'mention' ?
<div className='ap-note-content mt-0.5 line-clamp-1 text-sm text-pretty text-gray-700 dark:text-gray-600'>
{group.post?.type === 'article' && group.post?.title && <>{group.post.title} &mdash; </>}
<span dangerouslySetInnerHTML={{__html: stripHtml(group.post?.content || '')}} />
<span dangerouslySetInnerHTML={{__html: sanitizeHtml(stripHtml(group.post?.content || ''))}} />
</div> :
<>
<div className='mt-2.5 rounded-md bg-gray-100 px-5 py-[14px] group-hover:bg-gray-200 dark:bg-gray-950/30 group-hover:dark:bg-black/40'>
Expand Down
71 changes: 71 additions & 0 deletions apps/activitypub/test/acceptance/dom-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,75 @@ test.describe('DOM validation for rendered AP content', async () => {
const excerptText = await excerptElement.textContent();
expect(excerptText).toContain('<script>');
});

test('Notification content preserves safe profile links while removing unsafe HTML', async ({page}) => {
await mockApi({page, requests: {
getNotifications: {
method: 'GET',
path: '/v1/notifications',
response: {
notifications: [{
id: 'notification-1',
type: 'mention',
actor: {
id: 'actor-1',
name: 'Alice',
url: 'https://example.com/@alice',
handle: '@alice@example.com',
avatarUrl: null,
followedByMe: true
},
post: {
id: 'post-1',
type: 'note',
title: null,
content: '<p>Hello <a href="https://example.com" data-profile="@alice@example.com" onpointerenter="window.__xss=true">safe link</a> and <a href="javascript:alert(1)" onclick="window.__xss=true">unsafe link</a></p>',
url: 'https://example.com/post-1',
likeCount: 0,
likedByMe: false,
repostCount: 0,
repostedByMe: false,
replyCount: 0,
attachments: []
},
inReplyTo: null,
createdAt: '2026-06-03T10:00:00.000Z'
}],
next: null
}
},
getNotificationsCount: {
method: 'GET',
path: '/v1/notifications/unread/count',
response: {
count: 0
}
},
getTopics: {
method: 'GET',
path: '/v1/topics',
response: {
topics: []
}
}
}, options: {useActivityPub: true}});

await page.goto('#/notifications');

const safeLink = page.locator('.ap-note-content a', {hasText: /^safe link$/});
await expect(safeLink).toBeVisible();
await expect(safeLink).toHaveAttribute('href', 'https://example.com');
await expect(safeLink).toHaveAttribute('data-profile', '@alice@example.com');

const unsafeLink = page.locator('.ap-note-content a', {hasText: /^unsafe link$/});
await expect(unsafeLink).toBeVisible();
await expect(unsafeLink).not.toHaveAttribute('href', /javascript:/i);
await expect(page.locator('.ap-note-content [onpointerenter], .ap-note-content [onclick]')).toHaveCount(0);

await safeLink.hover();
await unsafeLink.click();

const xssValue = await page.evaluate(() => (window as Window & {__xss?: boolean}).__xss);
expect(xssValue).toBeUndefined();
});
});
207 changes: 157 additions & 50 deletions apps/activitypub/test/unit/utils/content-formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,164 @@
* @vitest-environment jsdom
*/

import {enforceVideoCardInlinePlayback} from '../../../src/utils/content-formatters';

describe('enforceVideoCardInlinePlayback', function () {
it('keeps autoplay video cards autoplaying and inline-playable', function () {
const result = enforceVideoCardInlinePlayback(`
<figure class="kg-card kg-video-card">
<div class="kg-video-container">
<video src="/video.mp4" autoplay loop playsinline></video>
<div class="kg-video-player-container kg-video-hide"></div>
</div>
</figure>
`);

const div = document.createElement('div');
div.innerHTML = result;

const video = div.querySelector('video') as HTMLVideoElement;
const playerContainer = div.querySelector('.kg-video-player-container');

expect(video.hasAttribute('autoplay')).toBe(true);
expect(video.hasAttribute('loop')).toBe(true);
expect(video.autoplay).toBe(true);
expect(video.loop).toBe(true);
expect(video.hasAttribute('muted')).toBe(true);
expect(video.hasAttribute('playsinline')).toBe(true);
expect(video.hasAttribute('webkit-playsinline')).toBe(true);
expect(video.hasAttribute('x5-playsinline')).toBe(true);
expect(playerContainer?.classList.contains('kg-video-hide')).toBe(true);
import {enforceVideoCardInlinePlayback, sanitizeHtml, stripHtml} from '../../../src/utils/content-formatters';

function sanitizeStrippedHtml(html: string, exclude: string[] = ['a']) {
return sanitizeHtml(stripHtml(html, exclude));
}

function renderHtml(html: string) {
const div = document.createElement('div');
div.innerHTML = html;
return div;
}

describe('Content Formatters', function () {
describe('enforceVideoCardInlinePlayback', function () {
it('keeps autoplay video cards autoplaying and inline-playable', function () {
const result = enforceVideoCardInlinePlayback(`
<figure class="kg-card kg-video-card">
<div class="kg-video-container">
<video src="/video.mp4" autoplay loop playsinline></video>
<div class="kg-video-player-container kg-video-hide"></div>
</div>
</figure>
`);

const div = document.createElement('div');
div.innerHTML = result;

const video = div.querySelector('video') as HTMLVideoElement;
const playerContainer = div.querySelector('.kg-video-player-container');

expect(video.hasAttribute('autoplay')).toBe(true);
expect(video.hasAttribute('loop')).toBe(true);
expect(video.autoplay).toBe(true);
expect(video.loop).toBe(true);
expect(video.hasAttribute('muted')).toBe(true);
expect(video.hasAttribute('playsinline')).toBe(true);
expect(video.hasAttribute('webkit-playsinline')).toBe(true);
expect(video.hasAttribute('x5-playsinline')).toBe(true);
expect(playerContainer?.classList.contains('kg-video-hide')).toBe(true);
});

it('adds inline playback attributes to non-autoplay video cards', function () {
const result = enforceVideoCardInlinePlayback(`
<figure class="kg-card kg-video-card">
<div class="kg-video-container">
<video src="/video.mp4" loop muted></video>
<div class="kg-video-player-container kg-video-hide"></div>
</div>
</figure>
`);

const div = document.createElement('div');
div.innerHTML = result;

const video = div.querySelector('video') as HTMLVideoElement;
const playerContainer = div.querySelector('.kg-video-player-container');

expect(video.hasAttribute('autoplay')).toBe(false);
expect(video.hasAttribute('loop')).toBe(true);
expect(video.hasAttribute('playsinline')).toBe(true);
expect(video.hasAttribute('webkit-playsinline')).toBe(true);
expect(video.hasAttribute('x5-playsinline')).toBe(true);
expect(playerContainer?.classList.contains('kg-video-hide')).toBe(true);
});
});

it('adds inline playback attributes to non-autoplay video cards', function () {
const result = enforceVideoCardInlinePlayback(`
<figure class="kg-card kg-video-card">
<div class="kg-video-container">
<video src="/video.mp4" loop muted></video>
<div class="kg-video-player-container kg-video-hide"></div>
</div>
</figure>
`);

const div = document.createElement('div');
div.innerHTML = result;

const video = div.querySelector('video') as HTMLVideoElement;
const playerContainer = div.querySelector('.kg-video-player-container');

expect(video.hasAttribute('autoplay')).toBe(false);
expect(video.hasAttribute('loop')).toBe(true);
expect(video.hasAttribute('playsinline')).toBe(true);
expect(video.hasAttribute('webkit-playsinline')).toBe(true);
expect(video.hasAttribute('x5-playsinline')).toBe(true);
expect(playerContainer?.classList.contains('kg-video-hide')).toBe(true);
describe('sanitizeHtml(stripHtml(...))', function () {
it('preserves safe links and profile navigation data attributes', function () {
const result = sanitizeStrippedHtml(`
<a href="https://example.com/path">Example</a>
<a href="https://example.com/path?query=value#section">Query hash</a>
<a href="http://example.com">HTTP</a>
<a href="mailto:test@example.com">Email</a>
<a href="https://example.com/@alice" data-profile="@alice@example.com">Alice</a>
`);

const div = renderHtml(result);
const links = div.querySelectorAll('a');

expect(links).toHaveLength(5);
expect(links[0].getAttribute('href')).toBe('https://example.com/path');
expect(links[1].getAttribute('href')).toBe('https://example.com/path?query=value#section');
expect(links[2].getAttribute('href')).toBe('http://example.com');
expect(links[3].getAttribute('href')).toBe('mailto:test@example.com');
expect(links[4].getAttribute('href')).toBe('https://example.com/@alice');
expect(links[4].getAttribute('data-profile')).toBe('@alice@example.com');
});

it('removes unsafe event handler attributes from links', function () {
const result = sanitizeStrippedHtml(`
<a
href="https://example.com"
onclick="window.__xss=true"
onfocus="window.__xss=true"
onpointerenter="window.__xss=true"
onmouseover="window.__xss=true"
>Example</a>
`);

const link = renderHtml(result).querySelector('a') as HTMLAnchorElement;

expect(link).not.toBeNull();
expect(link.hasAttribute('onclick')).toBe(false);
expect(link.hasAttribute('onfocus')).toBe(false);
expect(link.hasAttribute('onpointerenter')).toBe(false);
expect(link.hasAttribute('onmouseover')).toBe(false);
});

it('removes unsafe link href protocols', function () {
const result = sanitizeStrippedHtml(`
<a href="javascript:alert(1)">JavaScript</a>
<a href="JaVaScRiPt:alert(1)">Mixed case JavaScript</a>
<a href="data:text/html,<script>alert(1)</script>">Data</a>
<a href="vbscript:msgbox(1)">VBScript</a>
<a href="jav&#x61;script:alert(1)">Encoded JavaScript</a>
`);

const links = renderHtml(result).querySelectorAll('a');

expect(links).toHaveLength(5);
links.forEach((link) => {
expect(link.getAttribute('href')).toBeNull();
});
});

it('strips unsupported tags while keeping anchor text readable', function () {
const result = sanitizeStrippedHtml(`
<p>Hello <strong>bold</strong> <a href="https://example.com">safe link</a></p>
<script>window.__xss=true</script>
<img src="https://example.com/image.png" alt="Image">
<svg><circle></circle></svg>
<iframe src="https://example.com"></iframe>
`);

const div = renderHtml(result);

expect(div.querySelector('p')).toBeNull();
expect(div.querySelector('strong')).toBeNull();
expect(div.querySelector('script')).toBeNull();
expect(div.querySelector('img')).toBeNull();
expect(div.querySelector('svg')).toBeNull();
expect(div.querySelector('iframe')).toBeNull();
expect(div.querySelector('a')?.textContent).toBe('safe link');
expect(div.textContent).toContain('Hello bold safe link');
});

it('returns plain text when stripHtml has no exclusions', function () {
const result = sanitizeHtml(stripHtml(`
<p>Hello <a href="https://example.com">safe link</a></p>
<br>
<strong>Bold</strong>
`));

const div = renderHtml(result);

expect(div.querySelector('a')).toBeNull();
expect(div.querySelector('br')).toBeNull();
expect(div.textContent).toBe('Hello safe link Bold');
});
});
});
47 changes: 43 additions & 4 deletions apps/posts/src/hooks/filter-sources/use-tier-value-source.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,54 @@
import {FilterOption, ValueSource} from '@tryghost/shade/patterns';
import {createLocalValueSource} from './create-local-value-source';
import {getActiveTiers, getArchivedTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers';
import {useMemo} from 'react';
import type {FilterOption, ValueSource} from '@tryghost/shade/patterns';
import type {Tier} from '@tryghost/admin-x-framework/api/tiers';

interface TierValueSource {
valueSource: ValueSource<string>;
// True once more than one paid tier (active or archived) exists, i.e. when
// filtering members by tier is meaningful. Stays false until tiers load.
hasMultipleTiers: boolean;
}

function toTierFilterOption(tier: Tier): FilterOption<string> {
return {
value: tier.id,
label: tier.active ? tier.name : `${tier.name} (archived)`,
detail: tier.slug
};
}

// Active tiers first, then archived; each group keeps the order returned by the API.
function buildTierFilterOptions(tiers: Tier[]): FilterOption<string>[] {
return [
...getActiveTiers(tiers).map(toTierFilterOption),
...getArchivedTiers(tiers).map(toTierFilterOption)
];
}

export function useTierValueSource(): TierValueSource {
// The tiers endpoint returns every match in a single response, so no paging or
// limit is needed; `type:paid` keeps free/complimentary tiers out of the filter.
const {data: tiersData, isLoading} = useBrowseTiers({
searchParams: {filter: 'type:paid'}
});

const tiers = useMemo(() => tiersData?.tiers ?? [], [tiersData?.tiers]);
const options = useMemo(() => buildTierFilterOptions(tiers), [tiers]);
const hasMultipleTiers = tiers.length > 1;

export function useTierValueSource(options: FilterOption<string>[] = []): ValueSource<string> {
const useLocalTierValueSource = createLocalValueSource<FilterOption<string>, string>({
id: 'posts.tiers.local',
useItems: () => ({
data: options,
isLoading: false
isLoading
}),
toOption: option => option
});

return useLocalTierValueSource();
return {
valueSource: useLocalTierValueSource(),
hasMultipleTiers
};
}
Loading
Loading