security: block SSRF targets and make MIDI parse deterministic

This commit is contained in:
b1rdmania
2026-02-02 23:39:10 +00:00
parent 7a7a76a92b
commit 5e81227907
2 changed files with 319 additions and 41 deletions
+104
View File
@@ -1,6 +1,8 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import crypto from 'crypto'; import crypto from 'crypto';
import dns from 'node:dns/promises';
import net from 'node:net';
import { ScoreUtils } from '../utils/ScoreUtils.js'; import { ScoreUtils } from '../utils/ScoreUtils.js';
import { SimpleMIDI } from '../utils/SimpleMIDI.js'; import { SimpleMIDI } from '../utils/SimpleMIDI.js';
import type { CacheEntry } from '../types.js'; import type { CacheEntry } from '../types.js';
@@ -28,6 +30,11 @@ export class MIDIFetchService {
return { success: true, data: syntheticBuffer }; return { success: true, data: syntheticBuffer };
} }
const target = await this.validateTargetUrl(url);
if (!target.valid) {
return { success: false, error: target.error };
}
// Check cache first // Check cache first
const hash = this.hashUrl(url); const hash = this.hashUrl(url);
const cached = await this.getCached(hash); const cached = await this.getCached(hash);
@@ -101,6 +108,103 @@ export class MIDIFetchService {
return { valid: true }; 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 { private hashUrl(url: string): string {
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 16); return crypto.createHash('sha256').update(url).digest('hex').slice(0, 16);
} }
+205 -31
View File
@@ -3,22 +3,23 @@ import type { ParsedMIDIInfo, TrackInfo } from '../types.js';
export class MIDIParseService { export class MIDIParseService {
parseMIDI(buffer: ArrayBuffer): ParsedMIDIInfo { parseMIDI(buffer: ArrayBuffer): ParsedMIDIInfo {
try { try {
// Basic MIDI parsing - simplified for MVP
const view = new DataView(buffer); const view = new DataView(buffer);
const issues: string[] = []; const issues: string[] = [];
// Check header
if (buffer.byteLength < 14) { if (buffer.byteLength < 14) {
throw new Error('File too small'); 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)); const headerType = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
if (headerType !== 'MThd') { if (headerType !== 'MThd') {
throw new Error('Invalid MIDI header'); 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 trackCount = view.getUint16(10);
const timeDivision = view.getUint16(12); const timeDivision = view.getUint16(12);
@@ -26,25 +27,64 @@ export class MIDIParseService {
issues.push('No tracks found'); issues.push('No tracks found');
} }
// Estimate duration and tempo (simplified) let offset = 8 + headerLength;
const durationSec = this.estimateDuration(view, buffer.byteLength); let parsedTracks = 0;
const tempoBpm = this.estimateTempo(view, timeDivision); let maxTick = 0;
let tempoMicrosPerQuarter = 500000; // 120 BPM default
let timeSig: { num: number; den: number } | undefined;
// Create mock track info (real implementation would parse each track)
const tracks: TrackInfo[] = []; 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({ tracks.push({
id: i, id: i,
name: `Track ${i + 1}`, name: parsed.name || `Track ${i + 1}`,
noteCount: Math.floor(Math.random() * 100) + 10, // Placeholder program: parsed.program,
channel: i < 9 ? i : i + 1, // Skip channel 10 (drums) noteCount: parsed.noteCount,
register: i === 0 ? 'low' : i < trackCount / 2 ? 'mid' : 'high' 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); const totalNotes = tracks.reduce((sum, t) => sum + t.noteCount, 0);
const tempoBpm = Math.max(1, Math.round((60_000_000 / tempoMicrosPerQuarter) * 100) / 100);
const durationSec = this.estimateDurationSec(maxTick, timeDivision, tempoBpm);
// Add quality issues
if (durationSec < 20) issues.push('Very short duration'); if (durationSec < 20) issues.push('Very short duration');
if (durationSec > 600) issues.push('Very long duration'); if (durationSec > 600) issues.push('Very long duration');
if (totalNotes < 50) issues.push('Very few notes'); if (totalNotes < 50) issues.push('Very few notes');
@@ -53,7 +93,7 @@ export class MIDIParseService {
return { return {
durationSec, durationSec,
tempoBpm, tempoBpm,
timeSig: { num: 4, den: 4 }, // Default assumption timeSig: timeSig || { num: 4, den: 4 },
tracks, tracks,
noteCount: totalNotes, noteCount: totalNotes,
issues issues
@@ -62,25 +102,159 @@ export class MIDIParseService {
throw new Error(`MIDI parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(`MIDI parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} }
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;
private estimateDuration(view: DataView, totalSize: number): number { while (pos < end) {
// Very rough estimation based on file size const delta = this.readVarLen(view, pos, end);
const sizeKB = totalSize / 1024; pos = delta.next;
if (sizeKB < 10) return 30; tick += delta.value;
if (sizeKB < 50) return 120;
if (sizeKB < 200) return 240; if (pos >= end) break;
return 300;
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,
};
} }
private estimateTempo(view: DataView, timeDivision: number): number { private readVarLen(view: DataView, pos: number, end: number): { value: number; next: number } {
// Default tempo estimation let value = 0;
if (timeDivision & 0x8000) { let cursor = pos;
// SMPTE format for (let i = 0; i < 4 && cursor < end; i++) {
return 120; const b = view.getUint8(cursor++);
} else { value = (value << 7) | (b & 0x7f);
// Ticks per quarter note format if ((b & 0x80) === 0) break;
// Look for tempo meta events (would require full parsing)
return 120; // Default
} }
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);
} }
} }