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
11 changes: 7 additions & 4 deletions frontend/src/components/dashboard/TopContentList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { OpenContentItem } from '@/types';
import { ExternalLink } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { decodeHtmlEntities } from '@/lib/decodeHtmlEntities';

interface TopContentListProps {
heading: string;
Expand All @@ -10,6 +11,8 @@ interface TopContentListProps {

function ContentRow({ item }: { item: OpenContentItem }) {
const navigate = useNavigate();
const title = decodeHtmlEntities(item.title);
const providerName = decodeHtmlEntities(item.provider_name ?? '');

const handleClick = () => {
if (item.content_type === 'video') {
Expand All @@ -29,19 +32,19 @@ function ContentRow({ item }: { item: OpenContentItem }) {
{item.thumbnail_url ? (
<img
src={item.thumbnail_url}
alt={item.title}
alt={title}
className="w-10 h-10 rounded object-cover shrink-0"
/>
) : (
<div className="w-10 h-10 rounded bg-muted shrink-0" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate">
{item.title}
{title}
</p>
{item.provider_name && (
{providerName && (
<p className="text-xs text-muted-foreground truncate">
{item.provider_name}
{providerName}
</p>
)}
</div>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/decodeHtmlEntities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function decodeHtmlEntities(input: string): string {
if (!input) return input;
const el = document.createElement('textarea');
el.innerHTML = input;
return el.value;
}
Comment on lines +1 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Static analysis warnings are false positives for this entity-decoding pattern.

The static analysis tools flagged innerHTML assignment as an XSS risk. However, this specific pattern is safe because:

  1. The textarea element is created in memory and never appended to the DOM
  2. Scripts cannot execute in a detached element
  3. The function returns .value (decoded text), not .innerHTML (HTML content)
  4. This is a standard lightweight technique for HTML entity decoding

That said, if you want to address the warnings explicitly or improve code clarity, consider these alternatives:

Alternative approaches

Option 1: Use DOMParser (more explicit intent, still native)

 export function decodeHtmlEntities(input: string): string {
     if (!input) return input;
-    const el = document.createElement('textarea');
-    el.innerHTML = input;
-    return el.value;
+    const doc = new DOMParser().parseFromString(input, 'text/html');
+    return doc.documentElement.textContent || input;
 }

Option 2: Use a dedicated library like he or html-entities

import { decode } from 'he';

export function decodeHtmlEntities(input: string): string {
    if (!input) return input;
    return decode(input);
}

Libraries handle edge cases and numeric entities more robustly, but add a dependency.

The current implementation is correct and safe for the stated use case (decoding provider-sourced content like "Q&A" → "Q&A").

🧰 Tools
🪛 ast-grep (0.43.0)

[warning] 3-3: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: el.innerHTML = input
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)


[warning] 3-3: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: el.innerHTML = input
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://www.dhairyashah.dev/posts/why-innerhtml-is-a-bad-idea-and-how-to-avoid-it/
- https://cwe.mitre.org/data/definitions/79.html

(unsafe-html-content-assignment)

🪛 OpenGrep (1.22.0)

[WARNING] 4-4: Setting innerHTML with dynamic content can lead to XSS. Use textContent or createElement with proper escaping instead.

(coderabbit.xss.innerhtml-assignment)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/decodeHtmlEntities.ts` around lines 1 - 6, The static
analyzer flags the innerHTML assignment in decodeHtmlEntities as a potential XSS
false positive; to resolve this either (A) replace the textarea trick with
DOMParser: in decodeHtmlEntities keep the early return for falsy input, create a
DOMParser, call parseFromString(input, 'text/html') and return
document.body.textContent (or textContent of the parsed document) instead of
using el.innerHTML/el.value, or (B) switch to a proven library like
`he`/`html-entities` and call its decode function while preserving the same
falsy-input behavior; alternatively add a short code comment above
decodeHtmlEntities explaining why the textarea approach is safe (detached
element, returning .value) to silence the warning if you want to keep the
current implementation.

3 changes: 2 additions & 1 deletion frontend/src/loaders/routeLoaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '@/types';
import API from '@/api/api';
import { fetchUser } from '@/auth/useAuth';
import { decodeHtmlEntities } from '@/lib/decodeHtmlEntities';

function buildClassBreadcrumbs(
cls: Class,
Expand Down Expand Up @@ -135,7 +136,7 @@ const getLibraryOptionsHelper = async ({ request }: { request: Request }) => {
(library) =>
({
key: library.id,
value: library.title
value: decodeHtmlEntities(library.title)
}) as Option
)
: [];
Expand Down
58 changes: 35 additions & 23 deletions frontend/src/pages/knowledge-center/KnowledgeCenterManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
formatVideoDuration
} from '@/lib/formatters';
import API from '@/api/api';
import { decodeHtmlEntities } from '@/lib/decodeHtmlEntities';

interface CardHandlers {
onToggleFeatured: (
Expand All @@ -66,9 +67,11 @@ function LibraryCard({
library: Library;
handlers: CardHandlers;
}) {
const title = decodeHtmlEntities(library.title);
const description = decodeHtmlEntities(library.description ?? '');
return (
<div
className="media-card group"
className="media-card group flex flex-col h-full"
onClick={() =>
handlers.onNavigate(`/viewer/libraries/${library.id}`)
}
Expand Down Expand Up @@ -102,18 +105,18 @@ function LibraryCard({
<div className="flex items-start gap-3 mb-3">
<img
src={library.thumbnail_url ?? ''}
alt={library.title}
alt={title}
className="size-12 rounded shrink-0 border border-gray-200"
/>
<div className="flex-1 min-w-0 pr-8">
<h3 className="card-title-link">{library.title}</h3>
<h3 className="card-title-link">{title}</h3>
</div>
</div>
<p className="text-sm text-gray-600 line-clamp-2 mb-3">
{library.description}
<p className="text-sm text-gray-600 line-clamp-2 mb-3 flex-1">
{description}
</p>
<div
className="row-with-border"
className="row-with-border mt-auto"
onClick={(e) => e.stopPropagation()}
>
<label className="clickable-row">
Expand Down Expand Up @@ -155,9 +158,12 @@ interface VideoCardProps {

function VideoCard({ video, handlers, onRetry, onViewStatus }: VideoCardProps) {
const available = videoIsAvailable(video);
const title = decodeHtmlEntities(video.title);
const channelTitle = decodeHtmlEntities(video.channel_title ?? '');
const description = decodeHtmlEntities(video.description ?? '');
return (
<div
className={`bg-white rounded-lg border ${!available ? 'border-red-300' : 'border-gray-200'} p-4 hover:shadow-lg hover:border-brand transition-all cursor-pointer group relative`}
className={`bg-white rounded-lg border ${!available ? 'border-red-300' : 'border-gray-200'} p-4 hover:shadow-lg hover:border-brand transition-all cursor-pointer group relative flex flex-col h-full`}
onClick={() => {
if (available)
handlers.onNavigate(`/viewer/videos/${video.id}`);
Expand Down Expand Up @@ -193,26 +199,27 @@ function VideoCard({ video, handlers, onRetry, onViewStatus }: VideoCardProps) {
<div className="relative shrink-0">
<img
src={`/api/photos/${video.external_id}.jpg`}
alt={video.title}
alt={title}
className="size-12 rounded object-cover border border-gray-200"
/>
<div className="absolute -bottom-1 -right-1 bg-black/80 text-white text-[10px] px-1 py-0.5 rounded">
{formatVideoDuration(video.duration)}
</div>
</div>
<div className="flex-1 min-w-0 pr-8">
<h3 className="card-title-link">{video.title}</h3>
<p className="text-sm text-gray-500">
{video.channel_title}
</p>
<h3 className="card-title-link">{title}</h3>
<p className="text-sm text-gray-500">{channelTitle}</p>
</div>
</div>
{available ? (
<p className="text-sm text-gray-600 line-clamp-2 mb-3">
{video.description}
<p className="text-sm text-gray-600 line-clamp-2 mb-3 flex-1">
{description}
</p>
) : (
<div className="mb-3" onClick={(e) => e.stopPropagation()}>
<div
className="mb-3 flex-1"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm text-red-600 mb-2">
{getVideoErrorMessage(video) ??
'Video is processing...'}
Expand All @@ -239,7 +246,7 @@ function VideoCard({ video, handlers, onRetry, onViewStatus }: VideoCardProps) {
</div>
)}
<div
className="row-with-border"
className="row-with-border mt-auto"
onClick={(e) => e.stopPropagation()}
>
<label className="clickable-row">
Expand Down Expand Up @@ -279,8 +286,13 @@ function LinkCard({
handlers: CardHandlers;
onLinkClick: (link: HelpfulLink) => void;
}) {
const title = decodeHtmlEntities(link.title);
const description = decodeHtmlEntities(link.description ?? '');
return (
<div className="media-card group" onClick={() => onLinkClick(link)}>
<div
className="media-card group flex flex-col h-full"
onClick={() => onLinkClick(link)}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -308,14 +320,14 @@ function LinkCard({
</Tooltip>
</TooltipProvider>
<div className="pr-8 mb-2">
<h3 className="card-title-link">{link.title}</h3>
<h3 className="card-title-link">{title}</h3>
</div>
<p className="text-sm text-brand mb-2 truncate">{link.url}</p>
<p className="text-sm text-gray-600 line-clamp-2 mb-3">
{link.description}
<p className="text-sm text-gray-600 line-clamp-2 mb-3 flex-1">
{description}
</p>
<div
className="row-with-border"
className="row-with-border mt-auto"
onClick={(e) => e.stopPropagation()}
>
<label className="clickable-row">
Expand Down Expand Up @@ -687,7 +699,7 @@ export default function KnowledgeCenterManagement() {
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-45">
<SelectValue placeholder="Filter by" />
</SelectTrigger>
<SelectContent>
Expand Down Expand Up @@ -742,7 +754,7 @@ export default function KnowledgeCenterManagement() {
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[280px]">
<SelectTrigger className="w-70">
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
<SelectContent>
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/pages/knowledge-center/LibraryViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import API from '@/api/api';
import { decodeHtmlEntities } from '@/lib/decodeHtmlEntities';
import { useTourContext } from '@/contexts/useTourContext';
import { targetToStepIndexMap } from '@/contexts/tourState';

Expand Down Expand Up @@ -83,7 +84,7 @@ export default function LibraryViewer() {
`libraries/${libraryId}`
)) as ServerResponseOne<Library>;
if (resp.success) {
setLibraryTitle(resp.data.title);
setLibraryTitle(decodeHtmlEntities(resp.data.title));
setProviderID(resp.data.open_content_provider_id);
setLibraryData(resp.data);
}
Expand Down Expand Up @@ -211,7 +212,7 @@ export default function LibraryViewer() {
)}
</div>
<p className="text-sm text-gray-600 line-clamp-1">
{libraryData?.description}
{decodeHtmlEntities(libraryData?.description ?? '')}
</p>
</div>
</div>
Expand All @@ -220,7 +221,7 @@ export default function LibraryViewer() {
<div className="flex-1 bg-surface-hover">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Skeleton className="w-full h-[600px]" />
<Skeleton className="w-full h-150" />
</div>
) : src !== '' ? (
<div className="relative w-full h-full">
Expand Down
34 changes: 18 additions & 16 deletions frontend/src/pages/knowledge-center/ResidentKnowledgeCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { formatVideoDuration } from '@/lib/formatters';
import { isAdministrator, useAuth } from '@/auth/useAuth';
import { toast } from 'sonner';
import API from '@/api/api';
import { decodeHtmlEntities } from '@/lib/decodeHtmlEntities';

const ITEMS_PER_PAGE = 20;

Expand Down Expand Up @@ -248,6 +249,9 @@ export default function ResidentKnowledgeCenter() {
const favorited = pendingFavorites.has(favKey)
? pendingFavorites.get(favKey)!
: item.favorited;
const title = decodeHtmlEntities(item.title);
const description = decodeHtmlEntities(item.description);
const author = item.author ? decodeHtmlEntities(item.author) : '';
const handleClick = () => {
if (item.type === 'library') {
navigate(`/viewer/libraries/${item.id}`);
Expand Down Expand Up @@ -318,15 +322,15 @@ export default function ResidentKnowledgeCenter() {
{item.type === 'library' && item.imageUrl && (
<img
src={item.imageUrl}
alt={item.title}
className="size-12 rounded flex-shrink-0 border border-gray-200"
alt={title}
className="size-12 rounded shrink-0 border border-gray-200"
/>
)}
{item.type === 'video' && item.thumbnailUrl && (
<div className="relative flex-shrink-0">
<div className="relative shrink-0">
<img
src={item.thumbnailUrl}
alt={item.title}
alt={title}
className="size-12 rounded object-cover border border-gray-200"
/>
{item.duration != null && (
Expand All @@ -337,28 +341,26 @@ export default function ResidentKnowledgeCenter() {
</div>
)}
<div className="flex-1 min-w-0 pr-8">
<h3 className="card-title-link">{item.title}</h3>
{item.author && (
<p className="text-sm text-gray-500">
{item.author}
</p>
<h3 className="card-title-link">{title}</h3>
{author && (
<p className="text-sm text-gray-500">{author}</p>
)}
{!item.author && (
{!author && (
<p className="text-sm text-gray-500 invisible">
placeholder
</p>
)}
</div>
</div>

<p className="text-sm text-gray-600 line-clamp-2 mb-3 min-h-[2.5rem]">
{item.description}
<p className="text-sm text-gray-600 line-clamp-2 mb-3 min-h-10">
{description}
</p>

{item.type === 'library' &&
item.categories &&
item.categories.length > 0 && (
<div className="mt-auto pt-3 border-t border-gray-100 flex items-center gap-2 overflow-hidden min-h-[2.5rem]">
<div className="mt-auto pt-3 border-t border-gray-100 flex items-center gap-2 overflow-hidden min-h-10">
<Badge
variant="secondary"
className="bg-surface-hover text-gray-700 hover:bg-surface-hover text-xs shrink-0 max-w-48 truncate"
Expand Down Expand Up @@ -404,15 +406,15 @@ export default function ResidentKnowledgeCenter() {
)}

{item.type === 'link' && item.url && (
<div className="mt-auto pt-3 border-t border-gray-100 min-h-[2.5rem] flex items-center">
<div className="mt-auto pt-3 border-t border-gray-100 min-h-10 flex items-center">
<p className="text-sm text-brand truncate">
{item.url}
</p>
</div>
)}

{item.type === 'video' && (
<div className="mt-auto pt-3 border-t border-gray-100 min-h-[2.5rem]" />
<div className="mt-auto pt-3 border-t border-gray-100 min-h-10" />
)}
</div>
);
Expand Down Expand Up @@ -516,7 +518,7 @@ export default function ResidentKnowledgeCenter() {
>
<SelectTrigger
id="knowledge-center-filters"
className="w-[280px]"
className="w-70"
>
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
Expand Down
Loading
Loading