diff --git a/apps/api/package.json b/apps/api/package.json
index bef908f..56a2245 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -36,9 +36,13 @@
"better-auth": "^1.4.17",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
+ "highlight.js": "^11.11.1",
"marked": "^17.0.1",
+ "marked-gfm-heading-id": "^4.1.3",
+ "marked-highlight": "^2.2.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
+ "sanitize-html": "^2.17.0",
"slugify": "^1.6.6"
},
"devDependencies": {
@@ -49,7 +53,9 @@
"@nestjs/testing": "^11.1.12",
"@swc/core": "^1.10.18",
"@types/express": "^5.0.1",
+ "@types/highlight.js": "^10.1.0",
"@types/node": "^22.13.4",
+ "@types/sanitize-html": "^2.16.0",
"@vitest/coverage-v8": "^4.0.18",
"express": "^5.2.1",
"prisma": "^6.19.2",
diff --git a/apps/api/src/knowledge/knowledge.service.ts b/apps/api/src/knowledge/knowledge.service.ts
index ea3560c..10b420d 100644
--- a/apps/api/src/knowledge/knowledge.service.ts
+++ b/apps/api/src/knowledge/knowledge.service.ts
@@ -4,7 +4,6 @@ import {
ConflictException,
} from "@nestjs/common";
import { EntryStatus } from "@prisma/client";
-import { marked } from "marked";
import slugify from "slugify";
import { PrismaService } from "../prisma/prisma.service";
import type { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
@@ -12,20 +11,15 @@ import type {
KnowledgeEntryWithTags,
PaginatedEntries,
} from "./entities/knowledge-entry.entity";
+import { renderMarkdown } from "./utils/markdown";
/**
* Service for managing knowledge entries
*/
@Injectable()
export class KnowledgeService {
- constructor(private readonly prisma: PrismaService) {
- // Configure marked for security and consistency
- marked.setOptions({
- gfm: true, // GitHub Flavored Markdown
- breaks: false,
- pedantic: false,
- });
- }
+ constructor(private readonly prisma: PrismaService) {}
+
/**
* Get all entries for a workspace (paginated and filterable)
@@ -175,8 +169,8 @@ export class KnowledgeService {
const baseSlug = this.generateSlug(createDto.title);
const slug = await this.ensureUniqueSlug(workspaceId, baseSlug);
- // Render markdown to HTML
- const contentHtml = await marked.parse(createDto.content);
+ // Render markdown to HTML with sanitization
+ const contentHtml = await renderMarkdown(createDto.content);
// Use transaction to ensure atomicity
const result = await this.prisma.$transaction(async (tx) => {
@@ -299,7 +293,7 @@ export class KnowledgeService {
// Render markdown if content is updated
let contentHtml = existing.contentHtml;
if (updateDto.content) {
- contentHtml = await marked.parse(updateDto.content);
+ contentHtml = await renderMarkdown(updateDto.content);
}
// Build update data object conditionally
diff --git a/apps/api/src/knowledge/utils/README.md b/apps/api/src/knowledge/utils/README.md
new file mode 100644
index 0000000..b1a1886
--- /dev/null
+++ b/apps/api/src/knowledge/utils/README.md
@@ -0,0 +1,121 @@
+# Knowledge Module Utilities
+
+## Markdown Rendering
+
+### Overview
+
+The `markdown.ts` utility provides secure markdown rendering with GFM (GitHub Flavored Markdown) support, syntax highlighting, and XSS protection.
+
+### Features
+
+- **GFM Support**: Tables, task lists, strikethrough, autolinks
+- **Syntax Highlighting**: Code blocks with language detection via highlight.js
+- **XSS Protection**: HTML sanitization using sanitize-html
+- **Header IDs**: Automatic ID generation for headers (for linking)
+- **Security**: Blocks dangerous HTML (scripts, iframes, event handlers)
+
+### Usage
+
+```typescript
+import { renderMarkdown, markdownToPlainText } from './utils/markdown';
+
+// Render markdown to HTML (async)
+const html = await renderMarkdown('# Hello **World**');
+// Result:
Hello World
+
+// Extract plain text (for search indexing)
+const plainText = await markdownToPlainText('# Hello **World**');
+// Result: "Hello World"
+```
+
+### Supported Markdown Features
+
+#### Basic Formatting
+- **Bold**: `**text**` or `__text__`
+- *Italic*: `*text*` or `_text_`
+- ~~Strikethrough~~: `~~text~~`
+- `Inline code`: `` `code` ``
+
+#### Headers
+```markdown
+# H1
+## H2
+### H3
+```
+
+#### Lists
+```markdown
+- Unordered list
+ - Nested item
+
+1. Ordered list
+2. Another item
+```
+
+#### Task Lists
+```markdown
+- [ ] Unchecked task
+- [x] Completed task
+```
+
+#### Tables
+```markdown
+| Header 1 | Header 2 |
+|----------|----------|
+| Cell 1 | Cell 2 |
+```
+
+#### Code Blocks
+````markdown
+```typescript
+const greeting: string = "Hello";
+console.log(greeting);
+```
+````
+
+#### Links and Images
+```markdown
+[Link text](https://example.com)
+
+```
+
+#### Blockquotes
+```markdown
+> This is a quote
+> Multi-line quote
+```
+
+### Security
+
+The renderer implements multiple layers of security:
+
+1. **HTML Sanitization**: Only allows safe HTML tags and attributes
+2. **URL Validation**: Blocks `javascript:` and other dangerous protocols
+3. **External Links**: Automatically adds `target="_blank"` and `rel="noopener noreferrer"`
+4. **Task Lists**: Checkboxes are disabled to prevent interaction
+5. **No Event Handlers**: Blocks `onclick`, `onload`, etc.
+6. **No Dangerous Tags**: Blocks `\n\n**Safe** content';
+ const html = await renderMarkdown(markdown);
+
+ expect(html).not.toContain("';
+ const html = renderMarkdownSync(markdown);
+
+ expect(html).not.toContain("';
+ const html = await renderMarkdown(markdown);
+
+ expect(html).not.toContain("