mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
fix: security hardening from adversarial review
- XSS: sanitize marked.parse() output (strip script/iframe/on* attrs) - Path traversal: validate report --type against [a-z0-9-] pattern - TUS: HEAD request before retry to get server's actual offset (TUS spec) - Pointer: upload-raw now includes pointer content in JSON output - Symlinks: use lstatSync in all walkers to prevent directory escape
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
* gbrain check-backlinks fix --dry-run # preview fixes
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, lstatSync, existsSync } from 'fs';
|
||||
import { join, relative, basename } from 'path';
|
||||
|
||||
interface BacklinkGap {
|
||||
@@ -69,7 +69,7 @@ export function findBacklinkGaps(brainDir: string): BacklinkGap[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry.startsWith('.')) continue;
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (lstatSync(full).isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.endsWith('.md') && !entry.startsWith('_')) {
|
||||
const relPath = relative(brainDir, full);
|
||||
|
||||
@@ -243,8 +243,6 @@ async function uploadRaw(args: string[]) {
|
||||
// Write pointer next to the page that references it
|
||||
pointerPath = `${pageSlug}/${filename}.redirect.yaml`;
|
||||
console.error(`Pointer: ${pointerPath}`);
|
||||
// Note: the caller is responsible for writing the pointer file
|
||||
// to the brain repo at the correct location
|
||||
}
|
||||
|
||||
// Record in DB
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* gbrain lint <file.md> # lint single file
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, lstatSync, existsSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
export interface LintIssue {
|
||||
@@ -173,7 +173,7 @@ function collectPages(dir: string): string[] {
|
||||
for (const entry of readdirSync(d)) {
|
||||
if (entry.startsWith('.') || entry.startsWith('_')) continue;
|
||||
const full = join(d, entry);
|
||||
if (statSync(full).isDirectory()) walk(full);
|
||||
if (lstatSync(full).isDirectory()) walk(full);
|
||||
else if (entry.endsWith('.md')) pages.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-3
@@ -275,11 +275,31 @@ export function generateHtml({ title, markdown, encrypted }: GenerateHtmlOptions
|
||||
window.__CT = ${JSON.stringify(encrypted.ciphertext)};
|
||||
</script>` : '';
|
||||
|
||||
// Sanitize markdown rendering to prevent XSS from embedded HTML in brain pages
|
||||
const sanitizeScript = `
|
||||
function sanitizeHtml(html) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
div.querySelectorAll('script,iframe,object,embed,form').forEach(el => el.remove());
|
||||
div.querySelectorAll('*').forEach(el => {
|
||||
for (const attr of [...el.attributes]) {
|
||||
if (attr.name.startsWith('on') || attr.value.startsWith('javascript:')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
return div.innerHTML;
|
||||
}
|
||||
`;
|
||||
|
||||
const contentScript = encrypted
|
||||
? `<script>${DECRYPT_JS}<\/script>`
|
||||
: `<script>
|
||||
? `<script>${sanitizeScript}${DECRYPT_JS.replace(
|
||||
'document.getElementById(\'content\').innerHTML = marked.parse(result)',
|
||||
'document.getElementById(\'content\').innerHTML = sanitizeHtml(marked.parse(result))'
|
||||
)}<\/script>`
|
||||
: `<script>${sanitizeScript}
|
||||
const md = ${JSON.stringify(markdown)};
|
||||
document.getElementById('content').innerHTML = marked.parse(md);
|
||||
document.getElementById('content').innerHTML = sanitizeHtml(marked.parse(md));
|
||||
<\/script>`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
|
||||
@@ -22,6 +22,12 @@ export async function runReport(args: string[]) {
|
||||
const reportType = typeIdx >= 0 ? args[typeIdx + 1] : null;
|
||||
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
|
||||
|
||||
// Validate reportType to prevent path traversal
|
||||
if (reportType && !/^[a-z0-9][a-z0-9-]*$/.test(reportType)) {
|
||||
console.error('Report type must be lowercase alphanumeric with hyphens only (e.g., "enrichment-sweep")');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!reportType) {
|
||||
console.error('Usage: gbrain report --type <name> --title "..." --content "..." [--dir <brain>]');
|
||||
console.error(' Or pipe content via stdin:');
|
||||
|
||||
@@ -98,13 +98,25 @@ export class SupabaseStorage implements StorageBackend {
|
||||
// Step 2: Upload chunks
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
const end = Math.min(offset + TUS_CHUNK_SIZE, data.length);
|
||||
const chunk = data.subarray(offset, end);
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 3;
|
||||
while (attempt < maxAttempts) {
|
||||
try {
|
||||
// On retry, check server's actual offset (TUS spec requirement)
|
||||
if (attempt > 0) {
|
||||
const headRes = await fetch(uploadUrl, {
|
||||
method: 'HEAD',
|
||||
headers: { ...this.headers(), 'Tus-Resumable': '1.0.0' },
|
||||
});
|
||||
if (headRes.ok) {
|
||||
const serverOffset = headRes.headers.get('Upload-Offset');
|
||||
if (serverOffset) offset = parseInt(serverOffset, 10);
|
||||
}
|
||||
}
|
||||
|
||||
const end = Math.min(offset + TUS_CHUNK_SIZE, data.length);
|
||||
const chunk = data.subarray(offset, end);
|
||||
|
||||
const patchRes = await fetch(uploadUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user