Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
166 changes: 166 additions & 0 deletions src/emulator/storage/files.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}
});
Comment on lines +253 to +279

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The temporary directories created with mkdtempSync are never cleaned up, which can clutter the system's temporary folder over time. Wrapping the test logic in a try...finally block ensures that these directories are always deleted, even if the test assertions fail.

      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 });
        }
      });

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, done in 78daee2: the export/import test now creates both temp directories up front and deletes them in a finally, so they get cleaned up even when an assertion fails. The new | import test cleans up its export directory the same way.

});

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",
Expand Down
23 changes: 11 additions & 12 deletions src/emulator/storage/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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("/");
}
Comment on lines 639 to 642

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The helper function getPathSep has a regex bug where the character class [\/|\\\\] includes a literal | character (which is treated as a literal character rather than a logical OR inside []). This means any filename containing a | character would have it incorrectly identified as a path separator. Since getPathSep is only used here, we can avoid using it entirely and match the first path separator correctly using a standard regex match.

Suggested change
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 (getPathSep(fileName) === "\\") {
fileName = fileName.split("\\").join("/");
}
let fileName = metadata.name;
const firstSep = fileName.match(/[\/\\]/)?.[0];
if (firstSep === "\\\\") {
fileName = fileName.split("\\\\").join("/");
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. getPathSep was existing code, but this PR relies on its result, so a name like a|b\c.txt from an old Windows export was seen as using | and its backslashes were not mapped to /. In 78daee2 I removed getPathSep (this was its only caller) and check the first separator directly with /[/\\]/.exec(fileName)?.[0] === "\\". I compared against a single \ rather than the suggested "\\\\": the match is always one character, so a two-backslash string would never equal it and the Windows mapping would stop working. I also added a test that imports a|b\c.txt and reads it back as a|b/c.txt.


const filepath = this.path(metadata.bucket, fileName);
Expand All @@ -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];
}
Loading