key.js (1818B)
1 // src/lib/storage/key.js - object key validation shared by both backends 2 // 3 // Keys are slash separated and always relative. Validation is centralised 4 // because a key frequently originates from a job artifact path, which is 5 // attacker controlled for any worker or repository that is not fully trusted. 6 7 export class StorageNotFound extends Error { 8 constructor(key) { 9 super(`object not found: ${key}`); 10 this.name = 'StorageNotFound'; 11 this.code = 'ENOENT'; 12 this.key = key; 13 } 14 } 15 16 export function assertKey(key) { 17 if (typeof key !== 'string' || key.length === 0) { 18 throw new Error('storage key must be a non-empty string'); 19 } 20 if (key.length > 1024) { 21 throw new Error(`storage key exceeds 1024 characters: ${key.slice(0, 64)}...`); 22 } 23 if (key.startsWith('/')) { 24 throw new Error(`storage key must be relative: ${key}`); 25 } 26 if (key.includes('\\')) { 27 throw new Error(`storage key must use forward slashes: ${key}`); 28 } 29 // A NUL byte truncates the path in some syscalls. 30 if (key.includes('\0')) { 31 throw new Error('storage key contains a NUL byte'); 32 } 33 for (const segment of key.split('/')) { 34 if (segment === '' || segment === '.' || segment === '..') { 35 throw new Error(`storage key contains an invalid path segment: ${key}`); 36 } 37 } 38 return key; 39 } 40 41 // Normalizes a path reported by a worker into a safe key suffix. Returns null 42 // when nothing usable remains, so the caller can reject the upload. 43 export function sanitizeRelativePath(input) { 44 if (typeof input !== 'string') return null; 45 const parts = input 46 .replace(/\\/g, '/') 47 .split('/') 48 .filter((p) => p !== '' && p !== '.' && p !== '..' && !p.includes('\0')); 49 if (parts.length === 0) return null; 50 const joined = parts.join('/'); 51 return joined.length > 512 ? null : joined; 52 }