Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 6ec5ee3

Browse files
committed
Add validation for custom metadata size
1 parent 9308a3d commit 6ec5ee3

File tree

4 files changed

+98
-12
lines changed

4 files changed

+98
-12
lines changed

packages/tre/src/plugins/r2/errors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ enum CfCode {
1414
InternalError = 10001,
1515
NoSuchObjectKey = 10007,
1616
EntityTooLarge = 100100,
17+
MetadataTooLarge = 10012,
1718
InvalidObjectName = 10020,
1819
InvalidMaxKeys = 10022,
1920
InvalidArgument = 10029,
@@ -109,6 +110,16 @@ export class EntityTooLarge extends R2Error {
109110
}
110111
}
111112

113+
export class MetadataTooLarge extends R2Error {
114+
constructor() {
115+
super(
116+
Status.BadRequest,
117+
"Your metadata headers exceed the maximum allowed metadata size.",
118+
CfCode.MetadataTooLarge
119+
);
120+
}
121+
}
122+
112123
export class BadDigest extends R2Error {
113124
constructor(
114125
algorithm: "MD5" | "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512",

packages/tre/src/plugins/r2/gateway.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export class R2Gateway {
111111
const checksums = validate
112112
.key(key)
113113
.size(value)
114+
.metadataSize(options.customMetadata)
114115
.condition(meta, options.onlyIf)
115116
.hash(value, options);
116117

packages/tre/src/plugins/r2/validator.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import {
55
EntityTooLarge,
66
InvalidMaxKeys,
77
InvalidObjectName,
8+
MetadataTooLarge,
89
PreconditionFailed,
910
} from "./errors";
1011
import { R2Object, R2ObjectMetadata } from "./r2Object";
1112
import { R2Conditional } from "./schemas";
1213

1314
export const MAX_LIST_KEYS = 1_000;
1415
const MAX_KEY_SIZE = 1024;
15-
const MAX_VALUE_SIZE = 5 * 1_000 * 1_000 * 1_000 - 5 * 1_000 * 1_000;
16+
// https://developers.cloudflare.com/r2/platform/limits/
17+
const MAX_VALUE_SIZE = 5_000_000_000 - 5_000_000; // 5GB - 5MB
18+
const MAX_METADATA_SIZE = 2048; // 2048B
1619

1720
function identity(ms: number) {
1821
return ms;
@@ -67,6 +70,14 @@ export type R2Hashes = Record<
6770
Buffer | undefined
6871
>;
6972

73+
function serialisedLength(x: string) {
74+
// Adapted from internal R2 gateway implementation
75+
for (let i = 0; i < x.length; i++) {
76+
if (x.charCodeAt(i) >= 256) return x.length * 2;
77+
}
78+
return x.length;
79+
}
80+
7081
export class Validator {
7182
hash(value: Uint8Array, hashes: R2Hashes): R2StringChecksums {
7283
const checksums: R2StringChecksums = {};
@@ -95,13 +106,24 @@ export class Validator {
95106
}
96107

97108
size(value: Uint8Array): Validator {
98-
// TODO: should we be validating httpMetadata/customMetadata size too
99109
if (value.byteLength > MAX_VALUE_SIZE) {
100110
throw new EntityTooLarge();
101111
}
102112
return this;
103113
}
104114

115+
metadataSize(customMetadata?: Record<string, string>): Validator {
116+
if (customMetadata === undefined) return this;
117+
let metadataLength = 0;
118+
for (const [key, value] of Object.entries(customMetadata)) {
119+
metadataLength += serialisedLength(key) + serialisedLength(value);
120+
}
121+
if (metadataLength > MAX_METADATA_SIZE) {
122+
throw new MetadataTooLarge();
123+
}
124+
return this;
125+
}
126+
105127
key(key: string): Validator {
106128
const keyLength = Buffer.byteLength(key);
107129
if (keyLength >= MAX_KEY_SIZE) {

packages/tre/test/plugins/r2/index.spec.ts

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,31 @@ class TestR2Bucket implements R2Bucket {
146146
options?: R2PutOptions
147147
): Promise<R2Object> {
148148
const url = `http://localhost/${encodeURIComponent(this.ns + key)}`;
149+
150+
let valueBlob: Blob;
151+
if (value === null) {
152+
valueBlob = new Blob([]);
153+
} else if (value instanceof ArrayBuffer) {
154+
valueBlob = new Blob([new Uint8Array(value)]);
155+
} else if (ArrayBuffer.isView(value)) {
156+
valueBlob = new Blob([viewToArray(value)]);
157+
} else if (value instanceof ReadableStream) {
158+
// @ts-expect-error `ReadableStream` is an `AsyncIterable`
159+
valueBlob = await blob(value);
160+
} else {
161+
valueBlob = new Blob([value]);
162+
}
163+
164+
// We can't store options in headers as some put() tests include extended
165+
// characters in them, and `undici` validates all headers are byte strings,
166+
// so use a form data body instead
167+
const formData = new FormData();
168+
formData.set("options", maybeJsonStringify(options));
169+
formData.set("value", valueBlob);
149170
const res = await this.mf.dispatchFetch(url, {
150171
method: "PUT",
151-
headers: {
152-
Accept: "multipart/form-data",
153-
"Test-Options": maybeJsonStringify(options),
154-
},
155-
body: ArrayBuffer.isView(value) ? viewToArray(value) : value,
172+
headers: { Accept: "multipart/form-data" },
173+
body: formData,
156174
});
157175
return deconstructResponse(res);
158176
}
@@ -376,11 +394,12 @@ const test = miniflareTest<{ BUCKET: R2Bucket }, Context>(
376394
const options = maybeJsonParse(optionsHeader);
377395
return constructResponse(await env.BUCKET.get(key, options));
378396
} else if (method === "PUT") {
379-
const optionsHeader = request.headers.get("Test-Options");
380-
const options = maybeJsonParse(optionsHeader);
381-
return constructResponse(
382-
await env.BUCKET.put(key, await request.arrayBuffer(), options)
383-
);
397+
const formData = await request.formData();
398+
const optionsData = formData.get("options");
399+
if (typeof optionsData !== "string") throw new TypeError();
400+
const options = maybeJsonParse(optionsData);
401+
const value = formData.get("value");
402+
return constructResponse(await env.BUCKET.put(key, value, options));
384403
} else if (method === "DELETE") {
385404
const keys = await request.json<string | string[]>();
386405
await env.BUCKET.delete(keys);
@@ -796,6 +815,39 @@ test("put: stores only if passes onlyIf", async (t) => {
796815
const object = await r2.put("no-key", "2", { onlyIf: { etagMatches: etag } });
797816
t.is(object as R2Object | null, null);
798817
});
818+
test("put: validates metadata size", async (t) => {
819+
const { r2 } = t.context;
820+
821+
// TODO(soon): add check for max value size once we have streaming support
822+
// (don't really want to allocate 5GB buffers in tests :sweat_smile:)
823+
824+
const expectations: ThrowsExpectation = {
825+
instanceOf: Error,
826+
message:
827+
"put: Your metadata headers exceed the maximum allowed metadata size. (10012)",
828+
};
829+
830+
// Check with ASCII characters
831+
await r2.put("key", "value", { customMetadata: { key: "x".repeat(2045) } });
832+
await t.throwsAsync(
833+
r2.put("key", "value", { customMetadata: { key: "x".repeat(2046) } }),
834+
expectations
835+
);
836+
await r2.put("key", "value", { customMetadata: { hi: "x".repeat(2046) } });
837+
838+
// Check with extended characters: note "🙂" is 2 UTF-16 code units, so
839+
// `"🙂".length === 2`, and it requires 4 bytes to store
840+
await r2.put("key", "value", { customMetadata: { key: "🙂".repeat(511) } }); // 3 + 4*511 = 2047
841+
await r2.put("key", "value", { customMetadata: { key1: "🙂".repeat(511) } }); // 4 + 4*511 = 2048
842+
await t.throwsAsync(
843+
r2.put("key", "value", { customMetadata: { key12: "🙂".repeat(511) } }), // 5 + 4*511 = 2049
844+
expectations
845+
);
846+
await t.throwsAsync(
847+
r2.put("key", "value", { customMetadata: { key: "🙂".repeat(512) } }), // 3 + 4*512 = 2051
848+
expectations
849+
);
850+
});
799851

800852
test("delete: deletes existing keys", async (t) => {
801853
const { r2 } = t.context;

0 commit comments

Comments
 (0)