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
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,59 @@ describe("GCS endpoint conformance tests", () => {

expect(returnedMetadata.contentType).to.equal("text/plain");
});

emulatorOnly.it(
"should handle signed post policy uploads via the emulator endpoint",
async () => {
const boundary = "signed-post-policy-boundary";
const startBuffer = Buffer.from(`--${boundary}\r
Content-Disposition: form-data; name="key"\r
\r
${TEST_FILE_NAME}\r
--${boundary}\r
Content-Disposition: form-data; name="Content-Type"\r
\r
text/plain\r
--${boundary}\r
Content-Disposition: form-data; name="Cache-Control"\r
\r
public, max-age=60\r
--${boundary}\r
Content-Disposition: form-data; name="Content-Disposition"\r
\r
inline\r
--${boundary}\r
Content-Disposition: form-data; name="x-goog-meta-color"\r
\r
blue\r
--${boundary}\r
Content-Disposition: form-data; name="file"; filename="testFile"\r
Content-Type: text/plain\r
\r
`);
const endBuffer = Buffer.from(`\r
--${boundary}--\r
`);
const body = Buffer.concat([startBuffer, Buffer.from("hello world"), endBuffer]);

await supertest(storageHost)
.post(`/${storageBucket}`)
.set("content-type", `multipart/form-data; boundary=${boundary}`)
.send(body)
.expect(204);

const metadata = await supertest(storageHost)
.get(`/storage/v1/b/${storageBucket}/o/${ENCODED_TEST_FILE_NAME}`)
.expect(200)
.then((res) => res.body);

expect(metadata.name).to.equal(TEST_FILE_NAME);
expect(metadata.contentType).to.equal("text/plain");
expect(metadata.cacheControl).to.equal("public, max-age=60");
expect(metadata.contentDisposition).to.equal("inline");
expect(metadata.metadata).to.deep.equal({ color: "blue" });
},
);
});
});

Expand Down
86 changes: 85 additions & 1 deletion src/emulator/storage/apis/gcloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { StorageEmulator } from "../index";
import { EmulatorLogger } from "../../emulatorLogger";
import { GetObjectResponse, ListObjectsResponse } from "../files";
import type { Request, Response } from "express";
import { parseObjectUploadMultipartRequest } from "../multipart";
import { parseFormDataMultipartRequest, parseObjectUploadMultipartRequest } from "../multipart";
import { Upload, UploadNotActiveError } from "../upload";
import { ForbiddenError, NotFoundError } from "../errors";
import { reqBodyToBuffer } from "../../shared/request";
Expand Down Expand Up @@ -368,6 +368,90 @@ export function createCloudEndpoints(emulator: StorageEmulator): Router {
return sendFileBytes(getObjectResponse.metadata, getObjectResponse.data, req, res);
});

gcloudStorageAPI.post(
"/:bucketId",
(req, res, next) => {
adminStorageLayer.createBucket(req.params.bucketId);
next();
},
async (req, res) => {
const contentTypeHeader = req.header("content-type");
if (!contentTypeHeader?.includes("multipart/form-data")) {
return res.status(400).send("Content-Type must be multipart/form-data");
}

try {
const bodyBuffer = await reqBodyToBuffer(req);

const formData = parseFormDataMultipartRequest(contentTypeHeader, bodyBuffer);

const keyPart = formData.find((p) => p.name === "key");
const filePart = formData.find((p) => p.type === "file");

if (keyPart?.type !== "field" || filePart?.type !== "file") {
return res.status(400).send("Missing 'key' or file.");
}

const metadata: IncomingMetadata = {
contentType: filePart.contentType,
metadata: {},
};

for (const part of formData) {
if (part.type === "file" || part.name === "key") continue;

const key = part.name.toLowerCase();
const value = part.value.trim();
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

Trimming the value of form fields can lead to data corruption for custom metadata (x-goog-meta-*). Google Cloud Storage preserves leading and trailing spaces in custom metadata values. Since the multipart parser already handles the trailing line separator of the part, additional trimming here is likely unnecessary and potentially incorrect.

Suggested change
const value = part.value.trim();
const value = part.value;


if (key.startsWith("x-goog-meta-")) {
const metaKey = key.replace("x-goog-meta-", "");
metadata.metadata![metaKey] = value;
} else {
switch (key) {
case "content-type":
metadata.contentType = value;
break;
case "cache-control":
metadata.cacheControl = value;
break;
case "content-disposition":
metadata.contentDisposition = value;
break;
case "content-encoding":
metadata.contentEncoding = value;
break;
case "content-language":
metadata.contentLanguage = value;
break;
}
}
}
Comment on lines +400 to +428
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.

opinion nit: this seems a little easier to read to me

const HEADER_MAP: Record<string, keyof IncomingMetadata> = {
  "content-type": "contentType",
  "cache-control": "cacheControl",
  "content-disposition": "contentDisposition",
  "content-encoding": "contentEncoding",
  "content-language": "contentLanguage",
};
for (const part of formData) {
  if (part.type === "file" || part.name === "key") continue;
  const key = part.name.toLowerCase();
  if (key.startsWith("x-goog-meta-")) {
    metadata.metadata![key.substring(12)] = part.value;
  } else if (HEADER_MAP[key]) {
    metadata[HEADER_MAP[key]] = part.value.trim();
  }
}


const upload = uploadService.multipartUpload({
bucketId: req.params.bucketId,
objectId: keyPart.value,
dataRaw: filePart.data,
metadata: metadata,
authorization: req.header("authorization"),
});

await adminStorageLayer.uploadObject(upload);
return res.sendStatus(204);
} catch (err) {
if (err instanceof ForbiddenError) {
return res.sendStatus(403);
}
if (err instanceof NotFoundError) {
return res.sendStatus(404);
}
if (err instanceof Error) {
return res.status(400).send(err.message);
}
throw err;
}
},
);

gcloudStorageAPI.post(
"/b/:bucketId/o/:objectId/:method(rewriteTo|copyTo)/b/:destBucketId/o/:destObjectId",
(req, res, next) => {
Expand Down
93 changes: 92 additions & 1 deletion src/emulator/storage/multipart.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { expect } from "chai";
import { parseObjectUploadMultipartRequest } from "./multipart";
import {
parseObjectUploadMultipartRequest,
parseFormDataMultipartRequest,
MultipartFile,
MultipartField,
} from "./multipart";
import { randomBytes } from "crypto";

describe("Storage Multipart Request Parser", () => {
Expand Down Expand Up @@ -142,4 +147,90 @@ hello there!
);
});
});

describe("#parseFormDataMultipartRequest()", () => {
const boundary = "b1d5b2e3-1845-4338-9400-6ac07ce53c1e";
const CONTENT_TYPE_HEADER = `multipart/form-data; boundary=${boundary}`;

const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const imageContent = Buffer.concat([pngSignature, randomBytes(100)]);
const startBuffer = Buffer.from(`--${boundary}\r
Content-Disposition: form-data; name="key"\r
\r
path/image.png\r
--${boundary}\r
Content-Disposition: form-data; name="content-type"\r
\r
image/png\r
--${boundary}\r
Content-Disposition: form-data; name="x-goog-meta-color"\r
\r
blue\r
--${boundary}\r
Content-Disposition: form-data; name="file"; filename="image.png"\r
Content-Type: image/png\r
\r
`);
const endBuffer = Buffer.from(`\r
--${boundary}--\r
`);
const BODY = Buffer.concat([startBuffer, imageContent, endBuffer]);

it("parses a form data multipart request with file and fields successfully", () => {
const parts = parseFormDataMultipartRequest(CONTENT_TYPE_HEADER, BODY);

expect(parts.length).to.equal(4);

const keyPart = parts.find((p) => p.name === "key") as MultipartField;
expect(keyPart.value).to.equal("path/image.png");

const contentTypePart = parts.find((p) => p.name === "content-type") as MultipartField;
expect(contentTypePart.value).to.equal("image/png");

const metadataPart = parts.find((p) => p.name === "x-goog-meta-color") as MultipartField;
expect(metadataPart.value).to.equal("blue");

const filePart = parts.find((p) => p.name === "file") as MultipartFile;
expect(filePart.filename).to.equal("image.png");
expect(filePart.contentType).to.equal("image/png");
expect(filePart.data.byteLength).to.equal(imageContent.byteLength);
});

it("fails to parse a form data multipart request when file part is missing name", () => {
const invalidBody = Buffer.from(`--${boundary}\r
Content-Disposition: form-data; name="key"\r
\r
path/image.png\r
--${boundary}\r
Content-Disposition: form-data; filename="image.png"\r
Content-Type: image/png\r
\r
hello there!\r
--${boundary}--\r
`);

expect(() => parseFormDataMultipartRequest(CONTENT_TYPE_HEADER, invalidBody)).to.throw(
"Missing 'name' in Content-Disposition header.",
);
});

it("fails to parse a form data multipart request when part body is missing trailing line separator", () => {
const invalidStartBuffer = Buffer.from(`--${boundary}\r
Content-Disposition: form-data; name="key"\r
\r
path/image.png\r
--${boundary}\r
Content-Disposition: form-data; name="file"; filename="image.png"\r
Content-Type: image/png\r
\r
`);
const invalidEndBuffer = Buffer.from(`--${boundary}--\r
`);
const invalidBody = Buffer.concat([invalidStartBuffer, imageContent, invalidEndBuffer]);

expect(() => parseFormDataMultipartRequest(CONTENT_TYPE_HEADER, invalidBody)).to.throw(
"Missing trailing line separator.",
);
});
});
});
Loading