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:
Garry Tan
2026-04-11 21:35:05 -10:00
parent 54fdd4ba81
commit 87bb2a59eb
6 changed files with 48 additions and 12 deletions
+2 -2
View File
@@ -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);
-2
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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>
+6
View File
@@ -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:');
+15 -3
View File
@@ -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: {