From 5e81227907dfa79b54199556a9105f75bff69fc5 Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:39:10 +0000 Subject: [PATCH] security: block SSRF targets and make MIDI parse deterministic --- server/src/services/MIDIFetchService.ts | 106 +++++++++- server/src/services/MIDIParseService.ts | 254 ++++++++++++++++++++---- 2 files changed, 319 insertions(+), 41 deletions(-) diff --git a/server/src/services/MIDIFetchService.ts b/server/src/services/MIDIFetchService.ts index feef594..b406190 100644 --- a/server/src/services/MIDIFetchService.ts +++ b/server/src/services/MIDIFetchService.ts @@ -1,6 +1,8 @@ import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; +import dns from 'node:dns/promises'; +import net from 'node:net'; import { ScoreUtils } from '../utils/ScoreUtils.js'; import { SimpleMIDI } from '../utils/SimpleMIDI.js'; import type { CacheEntry } from '../types.js'; @@ -28,6 +30,11 @@ export class MIDIFetchService { return { success: true, data: syntheticBuffer }; } + const target = await this.validateTargetUrl(url); + if (!target.valid) { + return { success: false, error: target.error }; + } + // Check cache first const hash = this.hashUrl(url); const cached = await this.getCached(hash); @@ -101,6 +108,103 @@ export class MIDIFetchService { return { valid: true }; } + private async validateTargetUrl(rawUrl: string): Promise<{ valid: boolean; error?: string }> { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return { valid: false, error: 'Invalid URL' }; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { valid: false, error: 'Only http/https URLs are allowed' }; + } + + if (parsed.username || parsed.password) { + return { valid: false, error: 'URLs with embedded credentials are not allowed' }; + } + + const hostname = parsed.hostname.trim().toLowerCase(); + if (!hostname) { + return { valid: false, error: 'Missing hostname' }; + } + + if (this.isBlockedHostname(hostname)) { + return { valid: false, error: 'Blocked hostname' }; + } + + // If hostname is a literal IP, validate directly. + if (net.isIP(hostname)) { + if (this.isPrivateOrLocalIp(hostname)) { + return { valid: false, error: 'Blocked target IP' }; + } + return { valid: true }; + } + + try { + const resolved = await dns.lookup(hostname, { all: true }); + if (resolved.length === 0) { + return { valid: false, error: 'Unable to resolve hostname' }; + } + + // Require every resolved IP to be public routable. + for (const entry of resolved) { + if (this.isPrivateOrLocalIp(entry.address)) { + return { valid: false, error: 'Blocked target IP' }; + } + } + } catch { + return { valid: false, error: 'Unable to resolve hostname' }; + } + + return { valid: true }; + } + + private isBlockedHostname(hostname: string): boolean { + return hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local'); + } + + private isPrivateOrLocalIp(ip: string): boolean { + if (net.isIPv4(ip)) { + return this.isPrivateOrLocalIPv4(ip); + } + if (net.isIPv6(ip)) { + return this.isPrivateOrLocalIPv6(ip); + } + return true; + } + + private isPrivateOrLocalIPv4(ip: string): boolean { + const parts = ip.split('.').map(Number); + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) { + return true; + } + + const [a, b] = parts; + + if (a === 10) return true; // 10.0.0.0/8 + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 0) return true; // 0.0.0.0/8 "this host" + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a >= 224) return true; // multicast/reserved + + return false; + } + + private isPrivateOrLocalIPv6(ip: string): boolean { + const normalized = ip.toLowerCase(); + if (normalized === '::1') return true; // loopback + if (normalized === '::') return true; // unspecified + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // ULA fc00::/7 + if (normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) { + return true; // link-local fe80::/10 + } + if (normalized.startsWith('ff')) return true; // multicast ff00::/8 + return false; + } + private hashUrl(url: string): string { return crypto.createHash('sha256').update(url).digest('hex').slice(0, 16); } @@ -174,4 +278,4 @@ export class MIDIFetchService { console.error('Cache index save failed:', error); } } -} \ No newline at end of file +} diff --git a/server/src/services/MIDIParseService.ts b/server/src/services/MIDIParseService.ts index 515e640..1108535 100644 --- a/server/src/services/MIDIParseService.ts +++ b/server/src/services/MIDIParseService.ts @@ -3,48 +3,88 @@ import type { ParsedMIDIInfo, TrackInfo } from '../types.js'; export class MIDIParseService { parseMIDI(buffer: ArrayBuffer): ParsedMIDIInfo { try { - // Basic MIDI parsing - simplified for MVP const view = new DataView(buffer); const issues: string[] = []; - // Check header if (buffer.byteLength < 14) { throw new Error('File too small'); } - // Read MIDI header const headerType = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)); if (headerType !== 'MThd') { throw new Error('Invalid MIDI header'); } - - const format = view.getUint16(8); + + const headerLength = view.getUint32(4); + if (headerLength < 6) { + throw new Error('Invalid MIDI header length'); + } + const trackCount = view.getUint16(10); const timeDivision = view.getUint16(12); if (trackCount === 0) { issues.push('No tracks found'); } - - // Estimate duration and tempo (simplified) - const durationSec = this.estimateDuration(view, buffer.byteLength); - const tempoBpm = this.estimateTempo(view, timeDivision); - - // Create mock track info (real implementation would parse each track) + + let offset = 8 + headerLength; + let parsedTracks = 0; + let maxTick = 0; + let tempoMicrosPerQuarter = 500000; // 120 BPM default + let timeSig: { num: number; den: number } | undefined; + const tracks: TrackInfo[] = []; - for (let i = 0; i < Math.min(trackCount, 16); i++) { + for (let i = 0; i < trackCount && offset + 8 <= buffer.byteLength; i++) { + const chunkType = String.fromCharCode( + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + view.getUint8(offset + 3) + ); + const chunkLength = view.getUint32(offset + 4); + const chunkStart = offset + 8; + const chunkEnd = chunkStart + chunkLength; + + if (chunkType !== 'MTrk') { + issues.push(`Unexpected chunk type "${chunkType}" at track ${i + 1}`); + offset = chunkEnd; + continue; + } + if (chunkEnd > buffer.byteLength) { + issues.push(`Track ${i + 1} exceeds file length`); + break; + } + + const parsed = this.parseTrack(view, chunkStart, chunkEnd); + parsedTracks++; + maxTick = Math.max(maxTick, parsed.trackEndTick); + if (parsed.firstTempoMicrosPerQuarter && tempoMicrosPerQuarter === 500000) { + tempoMicrosPerQuarter = parsed.firstTempoMicrosPerQuarter; + } + if (!timeSig && parsed.timeSig) { + timeSig = parsed.timeSig; + } + tracks.push({ id: i, - name: `Track ${i + 1}`, - noteCount: Math.floor(Math.random() * 100) + 10, // Placeholder - channel: i < 9 ? i : i + 1, // Skip channel 10 (drums) - register: i === 0 ? 'low' : i < trackCount / 2 ? 'mid' : 'high' + name: parsed.name || `Track ${i + 1}`, + program: parsed.program, + noteCount: parsed.noteCount, + channel: parsed.channel, + register: this.pitchToRegister(parsed.avgPitch) }); + + offset = chunkEnd; } - + + if (parsedTracks < trackCount) { + issues.push(`Parsed ${parsedTracks}/${trackCount} tracks`); + } + const totalNotes = tracks.reduce((sum, t) => sum + t.noteCount, 0); - - // Add quality issues + const tempoBpm = Math.max(1, Math.round((60_000_000 / tempoMicrosPerQuarter) * 100) / 100); + const durationSec = this.estimateDurationSec(maxTick, timeDivision, tempoBpm); + if (durationSec < 20) issues.push('Very short duration'); if (durationSec > 600) issues.push('Very long duration'); if (totalNotes < 50) issues.push('Very few notes'); @@ -53,7 +93,7 @@ export class MIDIParseService { return { durationSec, tempoBpm, - timeSig: { num: 4, den: 4 }, // Default assumption + timeSig: timeSig || { num: 4, den: 4 }, tracks, noteCount: totalNotes, issues @@ -62,25 +102,159 @@ export class MIDIParseService { throw new Error(`MIDI parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } - - private estimateDuration(view: DataView, totalSize: number): number { - // Very rough estimation based on file size - const sizeKB = totalSize / 1024; - if (sizeKB < 10) return 30; - if (sizeKB < 50) return 120; - if (sizeKB < 200) return 240; - return 300; - } - - private estimateTempo(view: DataView, timeDivision: number): number { - // Default tempo estimation - if (timeDivision & 0x8000) { - // SMPTE format - return 120; - } else { - // Ticks per quarter note format - // Look for tempo meta events (would require full parsing) - return 120; // Default + private parseTrack(view: DataView, start: number, end: number): { + noteCount: number; + trackEndTick: number; + firstTempoMicrosPerQuarter?: number; + timeSig?: { num: number; den: number }; + name?: string; + program?: number; + channel?: number; + avgPitch: number; + } { + let pos = start; + let runningStatus = 0; + let tick = 0; + let firstTempoMicrosPerQuarter: number | undefined; + let timeSig: { num: number; den: number } | undefined; + let name: string | undefined; + let program: number | undefined; + let channel: number | undefined; + let noteCount = 0; + let pitchSum = 0; + + while (pos < end) { + const delta = this.readVarLen(view, pos, end); + pos = delta.next; + tick += delta.value; + + if (pos >= end) break; + + let status = view.getUint8(pos); + if (status < 0x80) { + if (runningStatus === 0) { + break; + } + status = runningStatus; + } else { + pos++; + runningStatus = status; + } + + // Meta event + if (status === 0xff) { + if (pos >= end) break; + const metaType = view.getUint8(pos++); + const len = this.readVarLen(view, pos, end); + pos = len.next; + const dataStart = pos; + const dataEnd = Math.min(end, pos + len.value); + + if (metaType === 0x03 && !name) { + name = this.decodeAscii(view, dataStart, dataEnd); + } else if (metaType === 0x51 && len.value === 3 && firstTempoMicrosPerQuarter === undefined) { + firstTempoMicrosPerQuarter = + (view.getUint8(dataStart) << 16) | + (view.getUint8(dataStart + 1) << 8) | + view.getUint8(dataStart + 2); + } else if (metaType === 0x58 && len.value >= 2 && !timeSig) { + const num = view.getUint8(dataStart); + const denPow = view.getUint8(dataStart + 1); + timeSig = { num, den: Math.pow(2, denPow) }; + } + + pos = dataEnd; + continue; + } + + // SysEx (skip payload) + if (status === 0xf0 || status === 0xf7) { + const len = this.readVarLen(view, pos, end); + pos = Math.min(end, len.next + len.value); + continue; + } + + const eventType = status & 0xf0; + const eventChannel = status & 0x0f; + + if (channel === undefined) { + channel = eventChannel; + } + + const hasTwoDataBytes = + eventType === 0x80 || + eventType === 0x90 || + eventType === 0xa0 || + eventType === 0xb0 || + eventType === 0xe0; + + if (hasTwoDataBytes) { + if (pos + 1 >= end) break; + const data1 = view.getUint8(pos++); + const data2 = view.getUint8(pos++); + + // Program-like estimate for melodic tracks + if (eventType === 0x90 && data2 > 0) { + noteCount++; + pitchSum += data1; + } + } else if (eventType === 0xc0 || eventType === 0xd0) { + if (pos >= end) break; + const data = view.getUint8(pos++); + if (eventType === 0xc0 && program === undefined) { + program = data; + } + } else { + // Unknown status; stop to avoid desync. + break; + } } + + return { + noteCount, + trackEndTick: tick, + firstTempoMicrosPerQuarter, + timeSig, + name, + program, + channel, + avgPitch: noteCount > 0 ? pitchSum / noteCount : 60, + }; } -} \ No newline at end of file + + private readVarLen(view: DataView, pos: number, end: number): { value: number; next: number } { + let value = 0; + let cursor = pos; + for (let i = 0; i < 4 && cursor < end; i++) { + const b = view.getUint8(cursor++); + value = (value << 7) | (b & 0x7f); + if ((b & 0x80) === 0) break; + } + return { value, next: cursor }; + } + + private decodeAscii(view: DataView, start: number, end: number): string { + let out = ''; + for (let i = start; i < end; i++) { + const code = view.getUint8(i); + if (code >= 32 && code <= 126) out += String.fromCharCode(code); + } + return out.trim(); + } + + private pitchToRegister(avgPitch: number): 'low' | 'mid' | 'high' { + if (avgPitch < 48) return 'low'; + if (avgPitch < 72) return 'mid'; + return 'high'; + } + + private estimateDurationSec(maxTick: number, timeDivision: number, tempoBpm: number): number { + // SMPTE time format is more complex; use conservative fallback. + if ((timeDivision & 0x8000) !== 0) { + return Math.max(0, Math.round((maxTick / 1000) * 100) / 100); + } + const ticksPerQuarter = timeDivision || 480; + const seconds = (maxTick / ticksPerQuarter) * (60 / tempoBpm); + return Math.max(0, Math.round(seconds * 100) / 100); + } +}