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
|
* 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';
|
import { join, relative, basename } from 'path';
|
||||||
|
|
||||||
interface BacklinkGap {
|
interface BacklinkGap {
|
||||||
@@ -69,7 +69,7 @@ export function findBacklinkGaps(brainDir: string): BacklinkGap[] {
|
|||||||
for (const entry of readdirSync(dir)) {
|
for (const entry of readdirSync(dir)) {
|
||||||
if (entry.startsWith('.')) continue;
|
if (entry.startsWith('.')) continue;
|
||||||
const full = join(dir, entry);
|
const full = join(dir, entry);
|
||||||
if (statSync(full).isDirectory()) {
|
if (lstatSync(full).isDirectory()) {
|
||||||
walk(full);
|
walk(full);
|
||||||
} else if (entry.endsWith('.md') && !entry.startsWith('_')) {
|
} else if (entry.endsWith('.md') && !entry.startsWith('_')) {
|
||||||
const relPath = relative(brainDir, full);
|
const relPath = relative(brainDir, full);
|
||||||
|
|||||||
@@ -243,8 +243,6 @@ async function uploadRaw(args: string[]) {
|
|||||||
// Write pointer next to the page that references it
|
// Write pointer next to the page that references it
|
||||||
pointerPath = `${pageSlug}/${filename}.redirect.yaml`;
|
pointerPath = `${pageSlug}/${filename}.redirect.yaml`;
|
||||||
console.error(`Pointer: ${pointerPath}`);
|
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
|
// Record in DB
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* gbrain lint <file.md> # lint single file
|
* 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';
|
import { join, relative } from 'path';
|
||||||
|
|
||||||
export interface LintIssue {
|
export interface LintIssue {
|
||||||
@@ -173,7 +173,7 @@ function collectPages(dir: string): string[] {
|
|||||||
for (const entry of readdirSync(d)) {
|
for (const entry of readdirSync(d)) {
|
||||||
if (entry.startsWith('.') || entry.startsWith('_')) continue;
|
if (entry.startsWith('.') || entry.startsWith('_')) continue;
|
||||||
const full = join(d, entry);
|
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);
|
else if (entry.endsWith('.md')) pages.push(full);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,11 +275,31 @@ export function generateHtml({ title, markdown, encrypted }: GenerateHtmlOptions
|
|||||||
window.__CT = ${JSON.stringify(encrypted.ciphertext)};
|
window.__CT = ${JSON.stringify(encrypted.ciphertext)};
|
||||||
</script>` : '';
|
</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
|
const contentScript = encrypted
|
||||||
? `<script>${DECRYPT_JS}<\/script>`
|
? `<script>${sanitizeScript}${DECRYPT_JS.replace(
|
||||||
: `<script>
|
'document.getElementById(\'content\').innerHTML = marked.parse(result)',
|
||||||
|
'document.getElementById(\'content\').innerHTML = sanitizeHtml(marked.parse(result))'
|
||||||
|
)}<\/script>`
|
||||||
|
: `<script>${sanitizeScript}
|
||||||
const md = ${JSON.stringify(markdown)};
|
const md = ${JSON.stringify(markdown)};
|
||||||
document.getElementById('content').innerHTML = marked.parse(md);
|
document.getElementById('content').innerHTML = sanitizeHtml(marked.parse(md));
|
||||||
<\/script>`;
|
<\/script>`;
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export async function runReport(args: string[]) {
|
|||||||
const reportType = typeIdx >= 0 ? args[typeIdx + 1] : null;
|
const reportType = typeIdx >= 0 ? args[typeIdx + 1] : null;
|
||||||
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
|
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) {
|
if (!reportType) {
|
||||||
console.error('Usage: gbrain report --type <name> --title "..." --content "..." [--dir <brain>]');
|
console.error('Usage: gbrain report --type <name> --title "..." --content "..." [--dir <brain>]');
|
||||||
console.error(' Or pipe content via stdin:');
|
console.error(' Or pipe content via stdin:');
|
||||||
|
|||||||
@@ -98,13 +98,25 @@ export class SupabaseStorage implements StorageBackend {
|
|||||||
// Step 2: Upload chunks
|
// Step 2: Upload chunks
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (offset < data.length) {
|
while (offset < data.length) {
|
||||||
const end = Math.min(offset + TUS_CHUNK_SIZE, data.length);
|
|
||||||
const chunk = data.subarray(offset, end);
|
|
||||||
|
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
const maxAttempts = 3;
|
const maxAttempts = 3;
|
||||||
while (attempt < maxAttempts) {
|
while (attempt < maxAttempts) {
|
||||||
try {
|
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, {
|
const patchRes = await fetch(uploadUrl, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user