mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
fix: news feature security, accessibility, performance improvements
- DOMPurify strict allowlist with sandbox on iframes - safeColor() CSS injection prevention for category_color - URL validation (http/https) for images and links - encodeURIComponent for slug in API calls - Keyboard navigation on news cards (Enter/Space) - aria-label, aria-pressed, type=button on all buttons - 44px touch targets on remaining buttons - memo() wrappers + stable callbacks (fewer re-renders) - Canvas draw call batching (~75% reduction) - TipTap extensions useMemo deps fix ([t] -> []) - Featured image loading=eager fetchPriority=high (LCP) - Unsafe Number(id) NaN guard
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -11,13 +11,27 @@ import type { NewsListItem } from '../types/news';
|
||||
|
||||
// Icons
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -27,7 +41,14 @@ const RefreshIcon = () => (
|
||||
);
|
||||
|
||||
const PencilIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -37,7 +58,14 @@ const PencilIcon = () => (
|
||||
);
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -53,6 +81,7 @@ const StarIcon = ({ filled }: { filled: boolean }) => (
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -63,7 +92,14 @@ const StarIcon = ({ filled }: { filled: boolean }) => (
|
||||
);
|
||||
|
||||
const NewsIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -72,7 +108,15 @@ const NewsIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
function ArticleRow({
|
||||
// --- Security: hex color validation to prevent CSS injection ---
|
||||
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
function safeColor(color: string | null | undefined, fallback = '#888888'): string {
|
||||
if (!color || !HEX_COLOR_RE.test(color)) return fallback;
|
||||
return color;
|
||||
}
|
||||
|
||||
// memo: prevents rows from re-rendering when sibling rows or parent state change
|
||||
const ArticleRow = memo(function ArticleRow({
|
||||
article,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -86,6 +130,7 @@ function ArticleRow({
|
||||
onToggleFeatured: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const color = safeColor(article.category_color);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-all hover:border-dark-600">
|
||||
@@ -95,14 +140,11 @@ function ArticleRow({
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-0.5 font-mono text-[10px] font-bold uppercase"
|
||||
style={{
|
||||
color: article.category_color,
|
||||
background: `${article.category_color}15`,
|
||||
color,
|
||||
background: `${color}15`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1 w-1 rounded-full"
|
||||
style={{ background: article.category_color }}
|
||||
/>
|
||||
<span className="h-1 w-1 rounded-full" style={{ background: color }} />
|
||||
{article.category}
|
||||
</span>
|
||||
<span
|
||||
@@ -143,6 +185,7 @@ function ArticleRow({
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFeatured}
|
||||
className={`min-h-[44px] min-w-[44px] rounded-lg p-2.5 transition-colors ${
|
||||
article.is_featured
|
||||
@@ -150,21 +193,30 @@ function ArticleRow({
|
||||
: 'text-dark-500 hover:bg-dark-700 hover:text-dark-300'
|
||||
}`}
|
||||
title={t('news.admin.featured')}
|
||||
aria-label={t('news.admin.featured')}
|
||||
>
|
||||
<StarIcon filled={article.is_featured} />
|
||||
</button>
|
||||
<Toggle checked={article.is_published} onChange={onTogglePublish} />
|
||||
<Toggle
|
||||
checked={article.is_published}
|
||||
onChange={onTogglePublish}
|
||||
aria-label={t('news.admin.published')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
title={t('news.admin.edit')}
|
||||
aria-label={t('news.admin.edit')}
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-error-500/10 hover:text-error-400"
|
||||
title={t('news.admin.delete')}
|
||||
aria-label={t('news.admin.delete')}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
@@ -172,8 +224,50 @@ function ArticleRow({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Thin wrapper that provides stable per-row callbacks so ArticleRow (memo'd)
|
||||
// does not re-render on every parent render due to new inline lambdas.
|
||||
interface ArticleRowWrapperProps {
|
||||
article: NewsListItem;
|
||||
onNavigate: (path: string) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onTogglePublish: (id: number) => void;
|
||||
onToggleFeatured: (id: number) => void;
|
||||
}
|
||||
|
||||
const ArticleRowWrapper = memo(function ArticleRowWrapper({
|
||||
article,
|
||||
onNavigate,
|
||||
onDelete,
|
||||
onTogglePublish,
|
||||
onToggleFeatured,
|
||||
}: ArticleRowWrapperProps) {
|
||||
const handleEdit = useCallback(
|
||||
() => onNavigate(`/admin/news/${article.id}/edit`),
|
||||
[article.id, onNavigate],
|
||||
);
|
||||
const handleDelete = useCallback(() => onDelete(article.id), [article.id, onDelete]);
|
||||
const handleTogglePublish = useCallback(
|
||||
() => onTogglePublish(article.id),
|
||||
[article.id, onTogglePublish],
|
||||
);
|
||||
const handleToggleFeatured = useCallback(
|
||||
() => onToggleFeatured(article.id),
|
||||
[article.id, onToggleFeatured],
|
||||
);
|
||||
|
||||
return (
|
||||
<ArticleRow
|
||||
article={article}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onTogglePublish={handleTogglePublish}
|
||||
onToggleFeatured={handleToggleFeatured}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default function AdminNews() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -218,12 +312,31 @@ export default function AdminNews() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const confirmed = await confirm(t('news.admin.confirmDelete'));
|
||||
if (confirmed) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
// Stable callbacks passed to ArticleRowWrapper — reference equality is
|
||||
// required for memo to prevent unnecessary re-renders of each row.
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
const confirmed = await confirm(t('news.admin.confirmDelete'));
|
||||
if (confirmed) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
},
|
||||
[confirm, deleteMutation, t],
|
||||
);
|
||||
|
||||
const handleTogglePublish = useCallback(
|
||||
(id: number) => {
|
||||
togglePublishMutation.mutate(id);
|
||||
},
|
||||
[togglePublishMutation],
|
||||
);
|
||||
|
||||
const handleToggleFeatured = useCallback(
|
||||
(id: number) => {
|
||||
toggleFeaturedMutation.mutate(id);
|
||||
},
|
||||
[toggleFeaturedMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -291,13 +404,13 @@ export default function AdminNews() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{articles.map((article) => (
|
||||
<ArticleRow
|
||||
<ArticleRowWrapper
|
||||
key={article.id}
|
||||
article={article}
|
||||
onEdit={() => navigate(`/admin/news/${article.id}/edit`)}
|
||||
onDelete={() => handleDelete(article.id)}
|
||||
onTogglePublish={() => togglePublishMutation.mutate(article.id)}
|
||||
onToggleFeatured={() => toggleFeaturedMutation.mutate(article.id)}
|
||||
onNavigate={navigate}
|
||||
onDelete={handleDelete}
|
||||
onTogglePublish={handleTogglePublish}
|
||||
onToggleFeatured={handleToggleFeatured}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -121,10 +121,13 @@ interface ToolbarButtonProps {
|
||||
function ToolbarButton({ onClick, isActive, title, children }: ToolbarButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'rounded p-2 transition-colors',
|
||||
'min-h-[44px] min-w-[44px] rounded p-2.5 transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'text-dark-400 hover:bg-dark-700 hover:text-dark-200',
|
||||
@@ -135,11 +138,22 @@ function ToolbarButton({ onClick, isActive, title, children }: ToolbarButtonProp
|
||||
);
|
||||
}
|
||||
|
||||
// --- Security: URL scheme validation ---
|
||||
function isSafeUrl(url: string | null | undefined): boolean {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slug utility ---
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-а-яё]/gi, '')
|
||||
.replace(/[^\w\s\-а-яёА-ЯЁ]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
@@ -161,10 +175,11 @@ const CATEGORY_COLORS = [
|
||||
export default function AdminNewsCreate() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { id: rawId } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const haptic = useHapticFeedback();
|
||||
const isEdit = !!id;
|
||||
const articleId = rawId != null ? Number(rawId) : undefined;
|
||||
const isEdit = articleId != null && !Number.isNaN(articleId);
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
@@ -180,7 +195,11 @@ export default function AdminNewsCreate() {
|
||||
const [isFeatured, setIsFeatured] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// TipTap editor — memoize extensions to avoid re-creation on every render
|
||||
// TipTap editor — extensions are memoized with NO deps so the editor is
|
||||
// never destroyed/recreated on re-renders. The PlaceholderExtension
|
||||
// placeholder reads the translation at mount time only, which is acceptable
|
||||
// since locale changes at runtime are rare and the editor retains content.
|
||||
// Using [t] as the dependency would destroy the editor on every locale change.
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
StarterKit.configure({
|
||||
@@ -202,7 +221,8 @@ export default function AdminNewsCreate() {
|
||||
}),
|
||||
HighlightExtension,
|
||||
],
|
||||
[t],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
@@ -224,8 +244,11 @@ export default function AdminNewsCreate() {
|
||||
|
||||
// Fetch article for editing
|
||||
const { data: articleData, isLoading: isLoadingArticle } = useQuery({
|
||||
queryKey: ['admin', 'news', 'article', id],
|
||||
queryFn: () => newsApi.getAdminArticle(Number(id)),
|
||||
queryKey: ['admin', 'news', 'article', articleId],
|
||||
queryFn: () => {
|
||||
if (articleId == null) throw new Error('Missing article id parameter');
|
||||
return newsApi.getAdminArticle(articleId);
|
||||
},
|
||||
enabled: isEdit,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
@@ -264,8 +287,8 @@ export default function AdminNewsCreate() {
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: NewsCreateRequest) => {
|
||||
if (isEdit) {
|
||||
return newsApi.updateArticle(Number(id), data);
|
||||
if (isEdit && articleId != null) {
|
||||
return newsApi.updateArticle(articleId, data);
|
||||
}
|
||||
return newsApi.createArticle(data);
|
||||
},
|
||||
@@ -294,7 +317,7 @@ export default function AdminNewsCreate() {
|
||||
category: category.trim(),
|
||||
category_color: categoryColor,
|
||||
tag: tag.trim() || null,
|
||||
featured_image_url: featuredImageUrl.trim() || null,
|
||||
featured_image_url: isSafeUrl(featuredImageUrl.trim()) ? featuredImageUrl.trim() : null,
|
||||
is_published: isPublished,
|
||||
is_featured: isFeatured,
|
||||
read_time_minutes: readTimeMinutes,
|
||||
@@ -425,13 +448,14 @@ export default function AdminNewsCreate() {
|
||||
{CATEGORY_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setCategoryColor(color)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-lg border-2 transition-all',
|
||||
'min-h-[44px] min-w-[44px] rounded-lg border-2 transition-all',
|
||||
categoryColor === color ? 'scale-110 border-white' : 'border-transparent',
|
||||
)}
|
||||
style={{ background: color }}
|
||||
aria-label={color}
|
||||
aria-label={t('news.admin.selectColor', { color })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -483,7 +507,7 @@ export default function AdminNewsCreate() {
|
||||
className="input"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
{featuredImageUrl && (
|
||||
{isSafeUrl(featuredImageUrl) && (
|
||||
<div className="mt-2 overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={featuredImageUrl}
|
||||
@@ -498,11 +522,19 @@ export default function AdminNewsCreate() {
|
||||
{/* Toggles row */}
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle checked={isPublished} onChange={() => setIsPublished((v) => !v)} />
|
||||
<Toggle
|
||||
checked={isPublished}
|
||||
onChange={() => setIsPublished((v) => !v)}
|
||||
aria-label={t('news.admin.published')}
|
||||
/>
|
||||
<span className="text-sm text-dark-300">{t('news.admin.published')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle checked={isFeatured} onChange={() => setIsFeatured((v) => !v)} />
|
||||
<Toggle
|
||||
checked={isFeatured}
|
||||
onChange={() => setIsFeatured((v) => !v)}
|
||||
aria-label={t('news.admin.featured')}
|
||||
/>
|
||||
<span className="text-sm text-dark-300">{t('news.admin.featured')}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -649,7 +681,7 @@ export default function AdminNewsCreate() {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !title.trim() || !slug.trim() || !category.trim()}
|
||||
className="w-full rounded-lg bg-accent-500 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="min-h-[44px] w-full rounded-lg bg-accent-500 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? t('news.admin.saving') : t('news.admin.save')}
|
||||
</button>
|
||||
|
||||
@@ -53,36 +53,181 @@ const CalendarIcon = () => (
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content using DOMPurify to prevent XSS attacks.
|
||||
* All article content from the API is sanitized before rendering.
|
||||
* Uses explicit allowlists (not ADD_TAGS/ADD_ATTR) for defense-in-depth.
|
||||
*
|
||||
* iframe elements are allowed only when their src points to a trusted
|
||||
* video host (YouTube, Vimeo) over HTTPS. All other iframes are stripped.
|
||||
*/
|
||||
const ALLOWED_IFRAME_HOSTS = [
|
||||
const ALLOWED_IFRAME_HOSTS = new Set([
|
||||
'www.youtube.com',
|
||||
'youtube.com',
|
||||
'player.vimeo.com',
|
||||
'www.youtube-nocookie.com',
|
||||
];
|
||||
]);
|
||||
|
||||
// Register DOMPurify hook to restrict iframe src to trusted hosts
|
||||
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
||||
if (data.tagName === 'iframe') {
|
||||
const el = node as Element;
|
||||
const src = el.getAttribute?.('src') ?? '';
|
||||
try {
|
||||
const url = new URL(src);
|
||||
if (!ALLOWED_IFRAME_HOSTS.includes(url.hostname)) {
|
||||
el.parentNode?.removeChild(el);
|
||||
}
|
||||
} catch {
|
||||
el.parentNode?.removeChild(el);
|
||||
/**
|
||||
* Validate that an iframe src URL points to a trusted host over HTTPS.
|
||||
* Returns false for any non-HTTPS, non-allowlisted, or malformed URL.
|
||||
*/
|
||||
function isAllowedIframeSrc(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
return url.protocol === 'https:' && ALLOWED_IFRAME_HOSTS.has(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hook: strip iframes with disallowed src before DOMPurify finalizes the DOM.
|
||||
// Using 'afterSanitizeAttributes' is more reliable than 'uponSanitizeElement'
|
||||
// because the node is fully formed at this point.
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'IFRAME') {
|
||||
const src = node.getAttribute('src') ?? '';
|
||||
if (!isAllowedIframeSrc(src)) {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
// Force sandbox attribute on surviving iframes for defense-in-depth.
|
||||
// allow-scripts + allow-same-origin are needed for YouTube/Vimeo embeds.
|
||||
node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation');
|
||||
// Strip any allow/allowfullscreen values we did not explicitly set
|
||||
if (node.hasAttribute('allow')) {
|
||||
// Only permit safe feature policies for video embeds
|
||||
node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Strict allowlist of tags and attributes for article content.
|
||||
* Using ALLOWED_TAGS / ALLOWED_ATTR instead of ADD_TAGS / ADD_ATTR
|
||||
* ensures nothing from the permissive defaults leaks through.
|
||||
*/
|
||||
const SANITIZE_CONFIG = {
|
||||
ALLOWED_TAGS: [
|
||||
// Block elements
|
||||
'p',
|
||||
'div',
|
||||
'br',
|
||||
'hr',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'pre',
|
||||
'code',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
// Inline elements
|
||||
'a',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'del',
|
||||
'ins',
|
||||
'span',
|
||||
'mark',
|
||||
'sub',
|
||||
'sup',
|
||||
'small',
|
||||
'img',
|
||||
// Embed (validated by afterSanitizeAttributes hook)
|
||||
'iframe',
|
||||
'figure',
|
||||
'figcaption',
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'href',
|
||||
'target',
|
||||
'rel',
|
||||
'src',
|
||||
'alt',
|
||||
'title',
|
||||
'width',
|
||||
'height',
|
||||
'loading',
|
||||
'class',
|
||||
'start',
|
||||
'reversed',
|
||||
'type',
|
||||
// iframe-specific (validated by hook)
|
||||
'frameborder',
|
||||
'allowfullscreen',
|
||||
'allow',
|
||||
'sandbox',
|
||||
// text-align from TipTap editor
|
||||
'style',
|
||||
],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
// Force all links to open in new tab safely
|
||||
ADD_ATTR: ['target'],
|
||||
};
|
||||
|
||||
// Hook: force rel="noopener noreferrer" on all <a> tags to prevent tabnabbing
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A') {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Restrict inline styles to only text-align (used by TipTap).
|
||||
* Strip any other CSS property to prevent CSS injection / exfiltration.
|
||||
*/
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.hasAttribute('style')) {
|
||||
const style = node.getAttribute('style') ?? '';
|
||||
const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i);
|
||||
if (match) {
|
||||
node.setAttribute('style', `text-align: ${match[1]}`);
|
||||
} else {
|
||||
node.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['allowfullscreen', 'frameborder', 'src', 'allow'],
|
||||
});
|
||||
return DOMPurify.sanitize(html, SANITIZE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a color string is a safe hex color.
|
||||
* Prevents CSS injection via malicious category_color values.
|
||||
* Returns the color if valid, otherwise returns a safe fallback.
|
||||
*/
|
||||
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
function safeColor(color: string | null | undefined, fallback = '#888888'): string {
|
||||
if (!color || !HEX_COLOR_RE.test(color)) return fallback;
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL uses a safe protocol (https or http only).
|
||||
* Prevents javascript:, data:, and other dangerous URI schemes.
|
||||
*/
|
||||
function isSafeUrl(url: string | null | undefined): url is string {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function NewsArticlePage() {
|
||||
@@ -109,7 +254,10 @@ export default function NewsArticlePage() {
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['news', 'article', slug],
|
||||
queryFn: () => newsApi.getArticle(slug!),
|
||||
queryFn: () => {
|
||||
if (!slug) throw new Error('Missing slug parameter');
|
||||
return newsApi.getArticle(slug);
|
||||
},
|
||||
enabled: !!slug,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
@@ -138,7 +286,7 @@ export default function NewsArticlePage() {
|
||||
{!capabilities.hasBackButton && (
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
aria-label={t('news.backToHome')}
|
||||
>
|
||||
<BackIcon />
|
||||
@@ -157,7 +305,7 @@ export default function NewsArticlePage() {
|
||||
{!capabilities.hasBackButton && (
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex h-10 items-center gap-2 rounded-xl border border-dark-700 bg-dark-800 px-4 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200"
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-xl border border-dark-700 bg-dark-800 px-4 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200"
|
||||
aria-label={t('news.backToHome')}
|
||||
>
|
||||
<BackIcon />
|
||||
@@ -173,35 +321,42 @@ export default function NewsArticlePage() {
|
||||
>
|
||||
{/* Category + tag */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1 font-mono text-[11px] font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
color: article.category_color,
|
||||
background: `${article.category_color}15`,
|
||||
border: `1px solid ${article.category_color}30`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full"
|
||||
style={{
|
||||
background: article.category_color,
|
||||
boxShadow: `0 0 8px ${article.category_color}`,
|
||||
}}
|
||||
/>
|
||||
{article.category}
|
||||
</span>
|
||||
{article.tag && (
|
||||
<span
|
||||
className="inline-block rounded px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wider"
|
||||
style={{
|
||||
color: article.category_color,
|
||||
border: `1px solid ${article.category_color}33`,
|
||||
background: `${article.category_color}11`,
|
||||
}}
|
||||
>
|
||||
{article.tag}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const color = safeColor(article.category_color);
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1 font-mono text-[11px] font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
color,
|
||||
background: `${color}15`,
|
||||
border: `1px solid ${color}30`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full"
|
||||
style={{
|
||||
background: color,
|
||||
boxShadow: `0 0 8px ${color}`,
|
||||
}}
|
||||
/>
|
||||
{article.category}
|
||||
</span>
|
||||
{article.tag && (
|
||||
<span
|
||||
className="inline-block rounded px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wider"
|
||||
style={{
|
||||
color,
|
||||
border: `1px solid ${color}33`,
|
||||
background: `${color}11`,
|
||||
}}
|
||||
>
|
||||
{article.tag}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
@@ -228,8 +383,8 @@ export default function NewsArticlePage() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Featured image */}
|
||||
{article.featured_image_url && (
|
||||
{/* Featured image - only render if URL uses safe protocol */}
|
||||
{isSafeUrl(article.featured_image_url) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -237,10 +392,11 @@ export default function NewsArticlePage() {
|
||||
className="overflow-hidden rounded-xl"
|
||||
>
|
||||
<img
|
||||
src={article.featured_image_url}
|
||||
src={article.featured_image_url!}
|
||||
alt={article.title}
|
||||
className="h-auto w-full rounded-xl object-cover"
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user