diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e127a8fea..05133cdefcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed the Storage emulator storing objects whose names contain `.` or `..` segments under the normalized name, so they could read, overwrite or delete a different object. (#11301) - Improved Auth emulator startup time by compiling validation schemas lazily. (#11176) - Updated the Firebase SQL Connect local toolkit to v3.4.26, which includes the following changes: - Internal improvements and bug fixes. diff --git a/src/emulator/storage/files.spec.ts b/src/emulator/storage/files.spec.ts index 5e6fdcd4444..537361b92c2 100644 --- a/src/emulator/storage/files.spec.ts +++ b/src/emulator/storage/files.spec.ts @@ -1,5 +1,7 @@ import { expect } from "chai"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; +import * as path from "path"; import { StoredFileMetadata } from "./metadata"; import { StorageCloudFunctions } from "./cloudFunctions"; @@ -171,6 +173,170 @@ describe("files", () => { }); }); + describe("object names with dot segments", () => { + // Cloud Storage object names are literal, so "a/../b" and "b" are two different objects. + // These upload with the decoded name, the way the Firebase API handler passes it on. + async function uploadDecoded( + storageLayer: StorageLayer, + bucketId: string, + objectId: string, + data: string, + ) { + const upload = _uploadService.multipartUpload({ + bucketId, + objectId, + dataRaw: Buffer.from(data), + metadata: {}, + }); + await storageLayer.uploadObject(upload); + } + + async function readData(storageLayer: StorageLayer, objectId: string) { + const { data } = await storageLayer.getObject({ + bucketId: "bucket", + decodedObjectId: objectId, + }); + return data.toString(); + } + + it("should not overwrite another object on upload", async () => { + const storageLayer = getStorageLayer(ALWAYS_TRUE_RULES_VALIDATOR); + await uploadDecoded(storageLayer, "bucket", "users/B/photo.png", "from B"); + + await uploadDecoded(storageLayer, "bucket", "users/A/../B/photo.png", "from A"); + + expect(await readData(storageLayer, "users/B/photo.png")).to.equal("from B"); + expect(await readData(storageLayer, "users/A/../B/photo.png")).to.equal("from A"); + }); + + it("should not find another object on read", async () => { + const storageLayer = getStorageLayer(ALWAYS_TRUE_RULES_VALIDATOR); + await uploadDecoded(storageLayer, "bucket", "users/B/photo.png", "from B"); + + await expect( + storageLayer.getObject({ bucketId: "bucket", decodedObjectId: "users/A/../B/photo.png" }), + ).to.be.rejectedWith(NotFoundError); + await expect( + storageLayer.getObject({ bucketId: "bucket", decodedObjectId: "users/B/./photo.png" }), + ).to.be.rejectedWith(NotFoundError); + }); + + it("should not delete another object", async () => { + const storageLayer = getStorageLayer(ALWAYS_TRUE_RULES_VALIDATOR); + await uploadDecoded(storageLayer, "bucket", "users/B/photo.png", "from B"); + + await expect( + storageLayer.deleteObject({ + bucketId: "bucket", + decodedObjectId: "users/A/../B/photo.png", + }), + ).to.be.rejectedWith(NotFoundError); + expect(await readData(storageLayer, "users/B/photo.png")).to.equal("from B"); + }); + + it("should not copy over another object", async () => { + const storageLayer = getStorageLayer(ALWAYS_TRUE_RULES_VALIDATOR); + await uploadDecoded(storageLayer, "bucket", "users/B/photo.png", "from B"); + await uploadDecoded(storageLayer, "bucket", "users/A/source.png", "from A"); + + storageLayer.copyObject({ + sourceBucket: "bucket", + sourceObject: "users/A/source.png", + destinationBucket: "bucket", + destinationObject: "users/A/../B/photo.png", + }); + + expect(await readData(storageLayer, "users/B/photo.png")).to.equal("from B"); + expect(await readData(storageLayer, "users/A/../B/photo.png")).to.equal("from A"); + }); + + it("should keep the objects apart through export and import", async () => { + const storageLayer = getStorageLayer(ALWAYS_TRUE_RULES_VALIDATOR); + await uploadDecoded(storageLayer, "bucket", "users/B/photo.png", "from B"); + await uploadDecoded(storageLayer, "bucket", "users/A/../B/photo.png", "from A"); + const exportDir = mkdtempSync(path.join(tmpdir(), "storage-export-")); + const importDir = mkdtempSync(path.join(tmpdir(), "storage-import-")); + try { + await storageLayer.export(exportDir, { initiatedBy: "test" }); + + const imported = new StorageLayer( + "project", + new Map(), + new Map(), + ALWAYS_TRUE_RULES_VALIDATOR, + ALWAYS_TRUE_ADMIN_CREDENTIAL_VALIDATOR, + new Persistence(importDir), + new StorageCloudFunctions("project"), + ); + imported.import(exportDir, { initiatedBy: "test" }); + + expect(await readData(imported, "users/B/photo.png")).to.equal("from B"); + expect(await readData(imported, "users/A/../B/photo.png")).to.equal("from A"); + } finally { + rmSync(exportDir, { recursive: true, force: true }); + rmSync(importDir, { recursive: true, force: true }); + } + }); + }); + + describe("#import()", () => { + it("should import object names exported on Windows with backslash separators", async () => { + const storageLayer = getStorageLayer(ALWAYS_TRUE_RULES_VALIDATOR); + + storageLayer.import( + path.join( + __dirname, + "../../../scripts/storage-emulator-integration/import/windows-emulator-data/storage_export", + ), + { initiatedBy: "test" }, + ); + + const { metadata } = await storageLayer.getObject({ + bucketId: "fake-project-id.appspot.com", + decodedObjectId: "test-directory/test_nested_upload.jpg", + }); + expect(metadata.bucket).to.equal("fake-project-id.appspot.com"); + await storageLayer.getObject({ + bucketId: "fake-project-id.appspot.com", + decodedObjectId: "test_upload.jpg", + }); + }); + + it("should not treat | in a Windows-exported object name as a separator", async () => { + const storageLayer = getStorageLayer(ALWAYS_TRUE_RULES_VALIDATOR); + const exportDir = mkdtempSync(path.join(tmpdir(), "storage-export-")); + try { + mkdirSync(path.join(exportDir, "metadata")); + mkdirSync(path.join(exportDir, "blobs")); + writeFileSync( + path.join(exportDir, "buckets.json"), + JSON.stringify({ buckets: [{ id: "bucket" }] }), + ); + writeFileSync( + path.join(exportDir, "metadata", "object.json"), + StoredFileMetadata.toJSON( + new StoredFileMetadata( + { name: "a|b\\c.txt", bucket: "bucket", contentType: "text/plain" }, + new StorageCloudFunctions("project"), + Buffer.from("data"), + ), + ), + ); + writeFileSync(path.join(exportDir, "blobs", "object"), "data"); + + storageLayer.import(exportDir, { initiatedBy: "test" }); + + const { data } = await storageLayer.getObject({ + bucketId: "bucket", + decodedObjectId: "a|b/c.txt", + }); + expect(data.toString()).to.equal("data"); + } finally { + rmSync(exportDir, { recursive: true, force: true }); + } + }); + }); + const getStorageLayer = (rulesValidator: FirebaseRulesValidator) => new StorageLayer( "project", diff --git a/src/emulator/storage/files.ts b/src/emulator/storage/files.ts index eeacfad0e04..162b2634511 100644 --- a/src/emulator/storage/files.ts +++ b/src/emulator/storage/files.ts @@ -522,8 +522,14 @@ export class StorageLayer { return metadata; } + /** + * Returns the key an object is stored under. Object names are literal in Cloud Storage, so the + * key must keep the name as is: `path.join` would collapse `.` and `..` segments (and, on + * Windows, treat `\` as a separator), letting two different names share one stored object. + * Bucket names cannot contain `/`, so the key is unique per bucket and object. + */ private path(bucket: string, object: string): string { - return path.join(bucket, object); + return `${bucket}/${object}`; } public get dirPath(): string { @@ -628,11 +634,11 @@ export class StorageLayer { continue; } + // Exports written on Windows by older versions stored object names with `\` separators. + // Such a name is recognized by its first separator being `\`. let fileName = metadata.name; - const objectNameSep = getPathSep(fileName); - // Replace all file separators with that of current platform for compatibility - if (fileName !== path.sep) { - fileName = fileName.split(objectNameSep).join(path.sep); + if (/[/\\]/.exec(fileName)?.[0] === "\\") { + fileName = fileName.split("\\").join("/"); } const filepath = this.path(metadata.bucket, fileName); @@ -654,10 +660,3 @@ export class StorageLayer { } } } - -/** Returns file separator used in given path, either '\\' or '/'. */ -function getPathSep(decodedPath: string): string { - // Checks for the first matching file separator - const firstSepIndex = decodedPath.search(/[/|\\\\]/g); - return decodedPath[firstSepIndex]; -}