mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 11:39:26 +01:00
Fixes 4 high-severity findings from a comprehensive security audit: 1. Path traversal on ZIM file delete — resolve()+startsWith() containment 2. Path traversal on Map file delete — same pattern 3. Path traversal on docs read — same pattern (already used in rag_service) 4. SSRF on download endpoints — block private/internal IPs, require TLD Also adds assertNotPrivateUrl() to content update endpoints. Full audit report attached as admin/docs/security-audit-v1.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
204 lines
5.7 KiB
TypeScript
204 lines
5.7 KiB
TypeScript
import Markdoc from '@markdoc/markdoc'
|
|
import { streamToString } from '../../util/docs.js'
|
|
import { getFile, getFileStatsIfExists, listDirectoryContentsRecursive } from '../utils/fs.js'
|
|
import path from 'path'
|
|
import InternalServerErrorException from '#exceptions/internal_server_error_exception'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
|
|
export class DocsService {
|
|
private docsPath = path.join(process.cwd(), 'docs')
|
|
|
|
private static readonly DOC_ORDER: Record<string, number> = {
|
|
'home': 1,
|
|
'getting-started': 2,
|
|
'use-cases': 3,
|
|
'faq': 4,
|
|
'about': 5,
|
|
'release-notes': 6,
|
|
}
|
|
|
|
async getDocs() {
|
|
const contents = await listDirectoryContentsRecursive(this.docsPath)
|
|
const files: Array<{ title: string; slug: string }> = []
|
|
|
|
for (const item of contents) {
|
|
if (item.type === 'file' && item.name.endsWith('.md')) {
|
|
const cleaned = this.prettify(item.name)
|
|
files.push({
|
|
title: cleaned,
|
|
slug: item.name.replace(/\.md$/, ''),
|
|
})
|
|
}
|
|
}
|
|
|
|
return files.sort((a, b) => {
|
|
const orderA = DocsService.DOC_ORDER[a.slug] ?? 999
|
|
const orderB = DocsService.DOC_ORDER[b.slug] ?? 999
|
|
return orderA - orderB
|
|
})
|
|
}
|
|
|
|
parse(content: string) {
|
|
try {
|
|
const ast = Markdoc.parse(content)
|
|
const config = this.getConfig()
|
|
const errors = Markdoc.validate(ast, config)
|
|
|
|
// Filter out attribute-undefined errors which may be caused by emojis and special characters
|
|
const criticalErrors = errors.filter((e) => e.error.id !== 'attribute-undefined')
|
|
if (criticalErrors.length > 0) {
|
|
logger.error('Markdoc validation errors:', errors.map((e) => JSON.stringify(e.error)).join(', '))
|
|
throw new Error('Markdoc validation failed')
|
|
}
|
|
|
|
return Markdoc.transform(ast, config)
|
|
} catch (error) {
|
|
logger.error('Error parsing Markdoc content:', error)
|
|
throw new InternalServerErrorException(`Error parsing content: ${(error as Error).message}`)
|
|
}
|
|
}
|
|
|
|
async parseFile(_filename: string) {
|
|
try {
|
|
if (!_filename) {
|
|
throw new Error('Filename is required')
|
|
}
|
|
|
|
const filename = _filename.endsWith('.md') ? _filename : `${_filename}.md`
|
|
|
|
// Prevent path traversal — resolved path must stay within the docs directory
|
|
const basePath = path.resolve(this.docsPath)
|
|
const fullPath = path.resolve(path.join(this.docsPath, filename))
|
|
if (!fullPath.startsWith(basePath + path.sep)) {
|
|
throw new Error('Invalid document slug')
|
|
}
|
|
|
|
const fileExists = await getFileStatsIfExists(fullPath)
|
|
if (!fileExists) {
|
|
throw new Error(`File not found: ${filename}`)
|
|
}
|
|
|
|
const fileStream = await getFile(fullPath, 'stream')
|
|
if (!fileStream) {
|
|
throw new Error(`Failed to read file stream: ${filename}`)
|
|
}
|
|
const content = await streamToString(fileStream)
|
|
return this.parse(content)
|
|
} catch (error) {
|
|
throw new InternalServerErrorException(`Error parsing file: ${(error as Error).message}`)
|
|
}
|
|
}
|
|
|
|
private static readonly TITLE_OVERRIDES: Record<string, string> = {
|
|
'faq': 'FAQ',
|
|
}
|
|
|
|
private prettify(filename: string) {
|
|
const slug = filename.replace(/\.md$/, '')
|
|
if (DocsService.TITLE_OVERRIDES[slug]) {
|
|
return DocsService.TITLE_OVERRIDES[slug]
|
|
}
|
|
// Remove hyphens, underscores, and file extension
|
|
const cleaned = slug.replace(/_/g, ' ').replace(/-/g, ' ')
|
|
// Convert to Title Case
|
|
const titleCased = cleaned.replace(/\b\w/g, (char) => char.toUpperCase())
|
|
return titleCased.charAt(0).toUpperCase() + titleCased.slice(1)
|
|
}
|
|
|
|
private getConfig() {
|
|
return {
|
|
tags: {
|
|
callout: {
|
|
render: 'Callout',
|
|
attributes: {
|
|
type: {
|
|
type: String,
|
|
default: 'info',
|
|
matches: ['info', 'warning', 'error', 'success'],
|
|
},
|
|
title: {
|
|
type: String,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
nodes: {
|
|
heading: {
|
|
render: 'Heading',
|
|
attributes: {
|
|
level: { type: Number, required: true },
|
|
id: { type: String },
|
|
},
|
|
},
|
|
list: {
|
|
render: 'List',
|
|
attributes: {
|
|
ordered: { type: Boolean },
|
|
start: { type: Number },
|
|
},
|
|
},
|
|
list_item: {
|
|
render: 'ListItem',
|
|
attributes: {
|
|
marker: { type: String },
|
|
className: { type: String },
|
|
class: { type: String }
|
|
}
|
|
},
|
|
table: {
|
|
render: 'Table',
|
|
},
|
|
thead: {
|
|
render: 'TableHead',
|
|
},
|
|
tbody: {
|
|
render: 'TableBody',
|
|
},
|
|
tr: {
|
|
render: 'TableRow',
|
|
},
|
|
th: {
|
|
render: 'TableHeader',
|
|
},
|
|
td: {
|
|
render: 'TableCell',
|
|
},
|
|
paragraph: {
|
|
render: 'Paragraph',
|
|
},
|
|
image: {
|
|
render: 'Image',
|
|
attributes: {
|
|
src: { type: String, required: true },
|
|
alt: { type: String },
|
|
title: { type: String },
|
|
},
|
|
},
|
|
link: {
|
|
render: 'Link',
|
|
attributes: {
|
|
href: { type: String, required: true },
|
|
title: { type: String },
|
|
},
|
|
},
|
|
fence: {
|
|
render: 'CodeBlock',
|
|
attributes: {
|
|
content: { type: String },
|
|
language: { type: String },
|
|
},
|
|
},
|
|
code: {
|
|
render: 'InlineCode',
|
|
attributes: {
|
|
content: { type: String },
|
|
},
|
|
},
|
|
hr: {
|
|
render: 'HorizontalRule',
|
|
},
|
|
},
|
|
}
|
|
}
|
|
}
|