reject traversal segments in disk storage keys and firehose entry names to block cross-tenant cache poisoning
477 unmodified linesExpand all478479480481482483484485486487488489490491492493494495496497498499500501502503504505506502 unmodified linesExpand all477 unmodified linesExpand all return null }}
function isSafeEntryName(name: string): boolean { return ( name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\') && !name.includes('\0') )}
/** * Collect file info from directory entries */function collectFileInfo(entries: Entry[], pathPrefix: string = ''): FileInfo[] { const files: FileInfo[] = []
for (const entry of entries) { // Reject traversal/control segments before they reach a storage key. Site // entry names are untrusted public record data; without this a name like // `..` could relocate an object to another tenant's prefix on disk-backed // tiers and evade prefix invalidation. if (!isSafeEntryName(entry.name)) { const entryPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name throw new Error(`Unsafe filesystem entry name: ${entryPath}`) } const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name const node = entry.node
if ('type' in node && node.type === 'directory' && 'entries' in node) {502 unmodified linesExpand all79 unmodified lines808182838484858687888990919293949596979899100101102103104105106107108109110111112353 unmodified linesExpand all79 unmodified lines compressed: false, checksum: 'evil', }
// Traversal segments are rejected before the cache-root containment check. await expect(tier.set(metadata.key, data, metadata)).rejects.toThrow(/outside configured directory/) await expect(tier.set(metadata.key, data, metadata)).rejects.toThrow(/traversal segment|outside configured directory/) expect(existsSync(traversalTarget)).toBe(false) })
test('should reject cross-tenant traversal segments that stay within the cache root', async () => { // A key that escapes its own prefix but lands inside the cache root must // still be rejected, otherwise directory-aligned deletePrefix/listKeys // miss it and the object poisons another tenant's cache path. const tier = new DiskStorageTier({ directory: testDir }) const data = new TextEncoder().encode('evil') const maliciousKey = 'did:plc:attacker/site/../../did:plc:victim/victimSite/index.html' const metadata = { key: maliciousKey, size: data.byteLength, createdAt: new Date(), lastAccessed: new Date(), accessCount: 0, compressed: false, checksum: 'evil', }
await expect(tier.set(maliciousKey, data, metadata)).rejects.toThrow(/traversal segment/) expect(await tier.get('did:plc:victim/victimSite/index.html')).toBe(null) }) })
describe('Recursive Listing', () => { test('should list all keys across nested directories', async () => {353 unmodified linesExpand all663 unmodified linesExpand all66466566666766866967067167267367467567667767867968068168255 unmodified lines663 unmodified linesExpand all * Get the filesystem path for a key's data file. */ private getFilePath(key: string): string { const encoded = encodeKey(key, this.encodeColons) // Reject traversal segments. The cache-root containment check below only // prevents escape outside the configured directory; a key such as // `a/site/../../b/site/x` still resolves under the root but relocates the // object to another tenant's prefix, and directory-aligned deletePrefix/ // listKeys would then miss it during invalidation. Refuse `.`/`..` // segments outright so logical keys map 1:1 to their physical prefix. for (const segment of encoded.split('/')) { if (segment === '..' || segment === '.') { throw new Error(`Storage key contains a traversal segment: ${key}`) } } const baseDir = resolve(this.config.directory) const filePath = resolve(baseDir, encoded) const relativePath = relative(baseDir, filePath)
55 unmodified lines